How to Write Python Code Compatible with Both Python 2 and Python 3
I was working on a legacy Jython project in 2026, and I hit a wall. The project needed to run on Jython 2.7, but I wanted to use modern development tools like Pyright for type checking and Ruff for formatting. Every time I tried to set up the tooling, it complained about Python 2 syntax.
error: Print statement syntax not supportederror: Unicode literal syntax errorI tried running mypy with Python 2 mode, but it was slow and kept giving me false positives. I needed a better approach.
The Problem
Python 3 introduced breaking changes that made Python 2 code incompatible:
printchanged from statement to function- String literals became unicode by default
- Integer division behavior changed
- Standard library modules were reorganized
The naive solution? Maintain two separate codebases. But that’s a maintenance nightmare.
I needed one codebase that works everywhere and unlocks modern tooling.
The Discovery: Future Imports
I stumbled across a Reddit discussion about Python 2 tooling in 2026. Someone mentioned a simple trick:
Write code that is compatible with both Python 3 and 2.7.
The key insight? Use Python 3 syntax that Python 2 can understand through future imports.
from __future__ import print_functionfrom __future__ import unicode_literalsfrom __future__ import divisionThese three lines at the top of my file enabled Python 3 behavior in Python 2:
print_function- Makesprint("text")work in both versionsunicode_literals- Makes string literals unicode by defaultdivision- Makes/always return float (use//for integer division)
But there was more to the story.
Handling Module Renames: The Six Library
Python 3 reorganized the standard library. urllib got split into urllib.parse, urllib.request, etc. This broke imports.
I tried:
import urllib.parse # ImportError in Python 2!The solution was the six library (named because 2 x 3 = 6):
from six.moves import urllib
# Now this works in both versionsresponse = urllib.request.urlopen('http://example.com')Six provides:
six.PY2 # True if running Python 2six.PY3 # True if running Python 3six.text_type # unicode in Py2, str in Py3six.binary_type # str in Py2, bytes in Py3six.string_types # (str, unicode) in Py2, (str,) in Py3six.moves.urllib # Compatible urllib accesssix.moves.configparser # Renamed from ConfigParserThis solved most of my compatibility issues, but file I/O still had problems.
The io.open Solution
The built-in open() function behaves differently between Python 2 and 3, especially with encoding:
# Python 2: No encoding parameter# Python 3: encoding parameter supportedwith open('file.txt', encoding='utf-8') as f: # Fails in Py2! content = f.read()The solution? Use io.open:
import io
with io.open('file.txt', encoding='utf-8') as f: content = f.read()io.open provides a consistent interface in both versions with proper encoding support.
Type Checking with Type Comments
Here’s where things got interesting. I wanted to use Pyright for type checking, but Python 2 doesn’t support the def func(x: int) -> int: syntax.
I discovered type comments:
# Python 3 style (doesn't work in Py2)def greet(name: str) -> str: return 'Hello ' + name
# Python 2/3 compatible type commentsdef greet(name): # type: (str) -> str return 'Hello ' + nameType comments work with Pyright and provide full type checking support while maintaining Python 2 compatibility.
Putting It All Together
Here’s a complete example showing all the patterns:
"""Python 2/3 compatible module demonstrating all best practices."""from __future__ import print_function, unicode_literals, divisionimport siximport io
def read_config(filepath): """ Read configuration file with proper encoding. Works in both Python 2.7 and Python 3.x """ # type: (str) -> dict config = {}
try: with io.open(filepath, encoding='utf-8') as f: for line in f: line = line.strip() if line and not line.startswith('#'): if '=' in line: key, value = line.split('=', 1) config[key.strip()] = value.strip() except IOError as e: six.raise_from(ValueError("Cannot read config: " + str(e)), e)
return config
def process_text(data): """ Process text with proper string handling. """ # type: (six.text_type) -> six.text_type if isinstance(data, six.string_types): result = data.upper() print("Processed:", result) # Works in both versions return result else: raise TypeError("Expected string type")
def calculate_ratio(a, b): """ Division with Python 3 behavior. """ # type: (int, int) -> float result = a / b # Returns float in both versions print("Ratio:", result) return result
# Module imports using six.movesfrom six.moves import urllib
def fetch_content(url): """ Fetch URL content with compatible urllib. """ # type: (str) -> six.text_type try: response = urllib.request.urlopen(url) content = response.read()
# Handle bytes to text conversion if isinstance(content, six.binary_type): content = content.decode('utf-8')
return content except Exception as e: print("Error:", str(e)) return ""Common Pitfalls I Encountered
Pitfall 1: Forgetting Future Imports
# WRONG: Missing future importsprint("Hello") # SyntaxError in Python 2!
# CORRECT: Always include at topfrom __future__ import print_functionprint("Hello") # Works everywherePitfall 2: Using Built-in open()
# WRONG: Different behavior in Py2 vs Py3with open('file.txt') as f: content = f.read()
# CORRECT: Use io.open with explicit encodingimport iowith io.open('file.txt', encoding='utf-8') as f: content = f.read()Pitfall 3: Hardcoded Module Imports
# WRONG: Py3-only importsimport urllib.parse # ImportError in Python 2!
# CORRECT: Use six.movesfrom six.moves import urlliburllib.parse.quote('hello world')Pitfall 4: Ruff Formatting Edge Case
Ruff has one minor issue with Python 2 compatibility:
# WRONG in Python 2: Trailing comma after **kwargsfunc(*args, **kwargs,) # SyntaxError in Py2!
# CORRECT: No trailing comma after **kwargsfunc(*args, **kwargs)Ruff formats this correctly 99% of the time, but watch out for this edge case.
Tooling Configuration
I configured Pyright with these settings:
[tool.pyright]pythonVersion = "2.7"pythonPlatform = "All"typeCheckingMode = "basic"useLibraryCodeForTypes = truereportMissingImports = truereportMissingTypeStubs = falseFor Ruff:
[tool.ruff]target-version = "py27"Why This Matters in 2026
You might wonder: “Why care about Python 2 in 2026?”
Several real-world scenarios:
- Jython Projects: Jython (Python on JVM) still uses Python 2.7 syntax
- Legacy Infrastructure: Organizations with Python 2-dependent systems
- Migration Path: Gradual transition without breaking changes
- Single Codebase: Maintain one version instead of two
The surprising benefit? You get modern tooling with legacy compatibility:
+-------------------+ +-------------------+| Modern Tooling | | Legacy Support || Pyright (fast) | | Jython 2.7 || Ruff (fast) | --> | Python 2.7 || VS Code | | Legacy Systems |+-------------------+ +-------------------+ ^ ^ | | +----------+-------------+ | Python 2/3 Compatible CodeComplete Example: Web Handler
Here’s a more complete example showing how all patterns work together:
"""Web application component with Python 2/3 compatibility."""from __future__ import print_function, unicode_literals, divisionimport siximport ioimport json
class APIHandler: """ API handler with compatible request/response handling. """
def __init__(self): # type: () -> None self.handlers = {} # type: dict[six.text_type, callable]
def register_handler(self, path, handler): # type: (six.text_type, callable) -> None """Register a handler for a path.""" self.handlers[path] = handler
def handle_request(self, path, request_data): # type: (six.text_type, six.text_type) -> six.text_type """ Handle an API request. Returns JSON response compatible with both versions. """ try: # Parse request request = json.loads(request_data)
# Find handler handler = self.handlers.get(path) if handler is None: return self._error_response("Not found")
# Process and return result = handler(request) return self._json_dumps(result)
except Exception as e: error_msg = str(e) if isinstance(error_msg, six.binary_type): error_msg = error_msg.decode('utf-8') return self._error_response(error_msg)
def _error_response(self, message): # type: (six.text_type) -> six.text_type """Generate an error response.""" return self._json_dumps({ 'success': False, 'error': message })
def _json_dumps(self, data): # type: (dict) -> six.text_type """Serialize to JSON with proper string handling.""" result = json.dumps(data, ensure_ascii=False) if isinstance(result, six.binary_type): result = result.decode('utf-8') return result
# Example handlerdef user_handler(request): # type: (dict) -> dict """Handler for user endpoint.""" user_id = request.get('id', '') return { 'success': True, 'user': { 'id': user_id, 'name': 'Example User' } }Summary
The strategy for Python 2/3 compatible code in 2026:
- Future imports:
print_function,unicode_literals,division - Six library: Handle module renames and type differences
- io.open: Consistent file handling with encoding
- Type comments: Enable Pyright type checking
- Modern tooling: Use Pyright and Ruff on compatible code
This approach lets you maintain one codebase while supporting both Python 2 and 3, with the added benefit of modern development tooling.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Six: Python 2 and 3 Compatibility Library
- 👨💻 Pyright: Static Type Checker for Python
- 👨💻 Ruff: An Extremely Fast Python Linter
- 👨💻 Python-Future: Easy Support for Python 2 and 3
- 👨💻 PEP 418: Add strftime and strptime format
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments