Skip to content

Can I Use Python 3 Tools to Lint and Format Python 2 Code?

I stared at the CI pipeline failure. Again.

ERROR: pip requires Python 3.6+ to run

The year is 2026, and I’m stuck maintaining a Python 2.7 codebase. The product owner says “migrate to Python 3” every sprint planning, but here we are. Meanwhile, my colleague just set up a beautiful pre-commit configuration with ruff and isort for their greenfield Python 3.11 project.

I wanted the same tooling. But the question haunted me: Can I actually use Python 3 linting tools on Python 2 code?

I spent a weekend figuring this out. Here’s what I learned.

The Problem

Our legacy system runs Python 2.7.18. We can’t just “upgrade to Python 3” because:

  • The embedded hardware vendor only supports Python 2.7
  • Several third-party libraries have no Python 3 ports
  • The migration budget keeps getting deprioritized

But I still want:

  • Fast linting (ruff is 10-100x faster than flake8)
  • Consistent import sorting
  • Modern pre-commit hooks
  • CI/CD that doesn’t look like it’s from 2015

The core problem: Modern tools require Python 3.7+ runtime, but my code targets Python 2.7.

What I Tried First

I naively ran:

Terminal window
pip2 install ruff

This failed spectacularly. ruff requires Python 3.7+. Same with modern isort (5.x+ requires Python 3.10+ for the runtime).

So I thought: “What if I run Python 3 tools, but tell them my code is Python 2?”

The Answer: Yes, But With Caveats

Yes, you can use Python 3 tools to lint and format Python 2 code, but you need to configure them properly and accept some limitations.

Here’s the breakdown:

┌─────────────────────────────────────────────────────────┐
│ Python 3 Runtime │
│ (runs ruff, isort, pre-commit on Python 3.10+) │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Version-Aware Configuration │
│ - ruff: disable pyupgrade rules │
│ - isort: py_version=27 setting │
│ - pre-commit: target Python 2 files │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Python 2.7 Codebase │
│ (linted and formatted by Python 3 tools) │
└─────────────────────────────────────────────────────────┘

Solution 1: ruff with Disabled pyupgrade

ruff doesn’t officially support Python 2, but in practice, it works for 99% of Python 2 code. The main issue I encountered was the pyupgrade ruleset, which suggests Python 3-only syntax changes.

My first attempt broke the build:

# Before ruff --fix with pyupgrade enabled
print "Hello, World!"
# After (Python 3 only - breaks Python 2!)
print("Hello, World!")

The fix: disable pyupgrade rules explicitly.

pyproject.toml
[tool.ruff]
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
]
ignore = [
"UP", # pyupgrade - ALL of these assume Python 3
]

Then I ran:

Terminal window
ruff check --fix src/
ruff format src/

The formatter worked perfectly for most code. But there’s one known issue:

# ruff may format this with a trailing comma
result = func(
arg1,
arg2,
**kw, # Trailing comma after **kw - Python 3 only!
)

This comma is valid in Python 3.7+ but a syntax error in Python 2.7. Watch out for it.

Solution 2: isort with py_version Setting

isort has explicit Python 2 support via the py_version setting. This was the smoothest experience.

pyproject.toml
[tool.isort]
py_version = 27
profile = "black"
known_first_party = ["myproject"]

The py_version = 27 tells isort running on Python 3 to format imports in a way that works on Python 2.7.

Example transformation:

# Before (messy imports)
import sys, os
from myproject.utils import helper
from third_party import module
# After isort with py_version=27
import os
import sys
from third_party import module
from myproject.utils import helper

Key insight: isort 5.x+ requires Python 3.10+ to run, but produces Python 2-compatible output when configured correctly.

Solution 3: pre-commit for Cross-Version Hooks

This is where it all comes together. pre-commit runs on Python 3 but can check Python 2 files.

.pre-commit-config.yaml
default_language_version:
python: python3.11
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.0
hooks:
- id: ruff-format
- id: ruff
args: [--fix, --ignore=UP]
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: [--py-version=27, --profile=black]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml

The default_language_version ensures pre-commit uses Python 3.11. The hook arguments configure the tools for Python 2 output.

I added this to my pre-commit setup and committed. The CI pipeline now runs modern tooling on legacy code:

pre-commit run --all-files

What About Python 2/3 Compatible Code?

The best approach I found for long-term maintainability: write code that runs on both Python 2 and 3.

# -*- coding: utf-8 -*-
"""Module that works on both Python 2 and 3."""
from __future__ import print_function, unicode_literals
import sys
if sys.version_info[0] >= 3:
text_type = str
else:
text_type = unicode # noqa: F821 - valid in Python 2
def process_data(data):
"""Process data with Python 2/3 compatibility."""
return text_type(data)

This approach gives you:

  • Full modern tooling support
  • Easier Python 2 to 3 migration path
  • One codebase for both runtimes

The from __future__ import print_function, unicode_literals line enables Python 3 behavior for print and string literals on Python 2.

Common Mistakes I Made

Mistake 1: Enabling pyupgrade Rules

# WRONG - will break Python 2 syntax
[tool.ruff.lint]
select = ["UP"] # pyupgrade removes Python 2 compatibility

This changed all my print "hello" statements to print("hello"), which looks correct but broke when I tested on Python 2.7.

Mistake 2: Assuming 100% Compatibility

I formatted everything with ruff and assumed it would work. The trailing comma after **kw issue showed up in production. Always run your tests on the actual Python version.

Mistake 3: Using Python 2 Runtime for Tools

Terminal window
# WRONG - modern tools require Python 3
pip2 install ruff # Either fails or installs ancient version

Correct approach:

Terminal window
# Use Python 3 to install and run tools
pip3 install ruff isort pre-commit
# Configure tools for Python 2 output
ruff check --ignore=UP src/

The Complete Working Configuration

After many iterations, here’s my production-ready setup:

# pyproject.toml - For Python 2 project with Python 3 tooling
[tool.ruff]
line-length = 88
target-version = "py37" # ruff runtime version (not your code's version)
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"C", # flake8-comprehensions
]
ignore = [
"UP", # pyupgrade - incompatible with Python 2
"UP009", # UTF8 encoding declaration
]
[tool.ruff.format]
docstring-code-format = true
[tool.isort]
py_version = 27
profile = "black"
known_first_party = ["myproject"]
.pre-commit-config.yaml
default_language_version:
python: python3.11
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.0
hooks:
- id: ruff-format
- id: ruff
args: [--fix, --ignore=UP]
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
args: [--py-version=27, --profile=black]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml

Why This Matters

The performance difference alone justified the switch:

┌─────────────────┬───────────────┬─────────────────┐
│ Tool │ Runtime Speed │ Compatibility │
├─────────────────┼───────────────┼─────────────────┤
│ flake8 + pylint │ ~30s │ Python 2 native │
│ ruff │ ~0.5s │ Python 3 only │
│ isort │ ~2s │ Both versions │
└─────────────────┴───────────────┴─────────────────┘

CI/CD time dropped from 2+ minutes to under 30 seconds. That’s meaningful when you’re running it on every pull request.

Summary

You can use Python 3 tools on Python 2 code by:

  1. Running tools on Python 3.7+ runtime
  2. Configuring version-aware settings (py_version=27 for isort)
  3. Disabling incompatible rules (ignore UP for ruff)
  4. Watching for edge cases (trailing comma after **kw)

For production Python 2 codebases, consider writing Python 2/3 compatible code. This maximizes tooling benefits while maintaining backward compatibility.

The weekend was worth it. My legacy codebase now has modern linting, and the CI pipeline no longer looks like a relic from 2015. When the product owner finally approves that migration budget, we’ll be ready.

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