Adopting PEP 8 and a Formatter in an Existing Python Codebase

Starting a new project with a formatter and a linter is easy. Bringing a ten-year-old codebase in line is a different job: thousands of warnings, open branches that will conflict, and a history that git blame should still be able to explain. PEP 8 itself warns against breaking things for style. This guide describes a route that respects that.
What PEP 8 says about old code
PEP 8's opening section lists good reasons to ignore a guideline, and two of them apply directly: "to be consistent with surrounding code that also breaks it", and "because the code in question predates the introduction of the guideline and there is no other reason to be modifying that code". It also says plainly: "do not break backwards compatibility just to comply with this PEP!"
That does not mean old code must stay as it is. It means changing style is a decision with costs, to be made deliberately, and that public names in particular should not be renamed just to match a convention. Renaming a public function from getData to get_data breaks every caller; layout changes break nothing.
Step 1: agree on the rules
Most of the decision is already made if you adopt PEP 8 plus a formatter. What remains is a short list:
- Line length: 79, 88 or up to 99, the limit PEP 8 allows for teams that agree on it.
- Formatter: Black or ruff's formatter, and whether to normalise string quotes.
- Linter rules: which families to enable, and which to leave out, for example E501 when the formatter handles length.
- Import sorting and which packages count as first-party.
Write the answers in pyproject.toml (or setup.cfg for flake8), together with an .editorconfig file for basic editor settings such as indentation and final newlines. Configuration that tools read is a better record than a page that people have to remember.
Step 2: formatting, all at once or gradually?
There are two strategies, each with real costs.
One reformatting commit
Run the formatter over the whole codebase in a single commit that contains nothing else, and enforce it from then on. The advantages are simplicity and a clean end state: every file is formatted, editors can format on save without producing unrelated diffs, and nobody needs to remember which files are "done". The costs are a large diff and conflicts with every open branch.
Both costs can be managed. The blame problem has a standard fix: put the commit's full hash in a .git-blame-ignore-revs file at the root of the repository and run:
git config blame.ignoreRevsFile .git-blame-ignore-revs
From then on, git blame skips that commit and attributes lines to the change before it. Some code hosting platforms read the file automatically in their blame views. For open branches, announce a date, ask people to merge what they can beforehand, and give the rest a recipe: format their branch with the same tool and version before merging, which removes most conflicts.
Gradual adoption
The alternative is to format only what changes: new files, or files touched in each pull request. It avoids the big diff, but leaves the codebase inconsistent for years, and every "small" change to an old file arrives with a reformatting diff attached, which makes review harder. Tools that format only modified lines exist, such as darker for Black, but they add complexity. flake8 used to have a --diff option to lint only changed lines; it was removed in version 6.0.0 in November 2022.
For most projects, the single commit is the better trade. Gradual formatting makes sense mainly for very large repositories with many teams, where coordinating a date is impractical.
Step 3: linting with a baseline
Linter warnings are different from formatting: many cannot be fixed automatically, and fixing them all at once is a large, risky change. The practical approach is a baseline:
- Enable the safe, automatic part first. Unused imports, import order and simple syntax upgrades can usually be fixed by the tool (
ruff check --fix) in one reviewed commit. - Record the rest. ruff's
--add-noqaoption adds a# noqacomment with the specific code to every remaining violation, so the check passes today and new violations fail. Alternatively, useper-file-ignoresfor whole legacy modules. - Enforce on everything new. CI now fails on any new warning.
- Pay down the debt rule by rule. Remove the
noqacomments for one code at a time, in small pull requests, starting with rules that point to real bugs, such as bareexcept:or comparisons toNonewith==.
Count before you start: ruff check --statistics or pycodestyle --statistics -qq shows how many violations each rule has, which tells you which rules are cheap to adopt and which need a plan.
Step 4: make it stick
- CI runs the formatter in check mode and the linter on every pull request.
- Pre-commit hooks catch problems before they are committed; see our guide to pre-commit hooks for Python.
- Editor settings, shared through the configuration files, format on save.
- Pinned versions of the formatter, because a new release can change output; upgrade deliberately, in a reformatting commit added to
.git-blame-ignore-revs. Black's stability policy, described in our guide to Black, limits such changes to once a year.
The human side
Style changes are easy to resent when they arrive as a surprise or as review comments. Announce the plan, explain the reasons (shorter reviews, fewer merge conflicts over whitespace, easier onboarding) and make the tooling do the enforcing, so that nobody has to be the style police in review. Our code review checklist starts from that principle.
Newcomers are often the first to notice inconsistent style, because they have not yet learned to read past it. If you have just joined a team, perhaps while also settling into a new job and a new city, proposing this kind of automation is a useful early contribution, as long as you start by asking how the team works today.
For a quick look at what a linter would say about a single old function, paste it into the checker on our home page.
Sources
- PEP 8, "A Foolish Consistency is the Hobgoblin of Little Minds".
- Git documentation,
git blame:--ignore-revs-fileandblame.ignoreRevsFile. - flake8 6.0.0 release notes (removal of
--diff, 23 November 2022). - Black documentation, "Stability policy" and "Introducing Black to your project"; ruff documentation,
--add-noqa; EditorConfig documentation.
Linters & formattersBlack, the code formatter
88 characters, double quotes and the magic trailing comma: what Black changes, the few options it has, and how to make it agree with your linter.
Linters & formattersPre-commit hooks
Catch trailing whitespace, unsorted imports and formatting before they reach a commit: git hooks, the pre-commit framework and a configuration to start from.
Readable code in practicePython code review checklist
Automate the style, then review what tools cannot see: behaviour, tests, names, error handling and security, plus how to phrase comments people can act on.