Skip to content

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 supported
error: Unicode literal syntax error

I 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:

  • print changed 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_function
from __future__ import unicode_literals
from __future__ import division

These three lines at the top of my file enabled Python 3 behavior in Python 2:

  • print_function - Makes print("text") work in both versions
  • unicode_literals - Makes string literals unicode by default
  • division - 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 versions
response = urllib.request.urlopen('http://example.com')

Six provides:

six.PY2 # True if running Python 2
six.PY3 # True if running Python 3
six.text_type # unicode in Py2, str in Py3
six.binary_type # str in Py2, bytes in Py3
six.string_types # (str, unicode) in Py2, (str,) in Py3
six.moves.urllib # Compatible urllib access
six.moves.configparser # Renamed from ConfigParser

This 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 supported
with 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 comments
def greet(name):
# type: (str) -> str
return 'Hello ' + name

Type 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, division
import six
import 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.moves
from 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 imports
print("Hello") # SyntaxError in Python 2!
# CORRECT: Always include at top
from __future__ import print_function
print("Hello") # Works everywhere

Pitfall 2: Using Built-in open()

# WRONG: Different behavior in Py2 vs Py3
with open('file.txt') as f:
content = f.read()
# CORRECT: Use io.open with explicit encoding
import io
with io.open('file.txt', encoding='utf-8') as f:
content = f.read()

Pitfall 3: Hardcoded Module Imports

# WRONG: Py3-only imports
import urllib.parse # ImportError in Python 2!
# CORRECT: Use six.moves
from six.moves import urllib
urllib.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 **kwargs
func(*args, **kwargs,) # SyntaxError in Py2!
# CORRECT: No trailing comma after **kwargs
func(*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:

pyproject.toml
[tool.pyright]
pythonVersion = "2.7"
pythonPlatform = "All"
typeCheckingMode = "basic"
useLibraryCodeForTypes = true
reportMissingImports = true
reportMissingTypeStubs = false

For Ruff:

pyproject.toml
[tool.ruff]
target-version = "py27"

Why This Matters in 2026

You might wonder: “Why care about Python 2 in 2026?”

Several real-world scenarios:

  1. Jython Projects: Jython (Python on JVM) still uses Python 2.7 syntax
  2. Legacy Infrastructure: Organizations with Python 2-dependent systems
  3. Migration Path: Gradual transition without breaking changes
  4. 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 Code

Complete 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, division
import six
import io
import 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 handler
def 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:

  1. Future imports: print_function, unicode_literals, division
  2. Six library: Handle module renames and type differences
  3. io.open: Consistent file handling with encoding
  4. Type comments: Enable Pyright type checking
  5. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments