Readable code in practice

Adopting PEP 8 and a Formatter in an Existing Python Codebase

Readable code in practice · · Published 2026-10-27

The United States Capitol seen across a wide lawn, with scaffolding on part of its facade during renovation
Scaffolding on the United States Capitol during an exterior renovation. US Department of Defense, public domain.

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:

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:

  1. 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.
  2. Record the rest. ruff's --add-noqa option adds a # noqa comment with the specific code to every remaining violation, so the check passes today and new violations fail. Alternatively, use per-file-ignores for whole legacy modules.
  3. Enforce on everything new. CI now fails on any new warning.
  4. Pay down the debt rule by rule. Remove the noqa comments for one code at a time, in small pull requests, starting with rules that point to real bugs, such as bare except: or comparisons to None with ==.

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

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