Formatting & Linting
Formatters rewrite your code into a consistent style automatically; linters analyze it for bugs, style violations, and risky patterns — together they turn PEP 8 from a suggestion into an automated guarantee.
Formatting and linting are two related but distinct kinds of automated code tooling. A formatter rewrites source code into a canonical layout (spacing, line breaks, quote style) with no judgment calls left to the author. A linter analyzes source code for problems — style violations, unused imports, likely bugs, overly complex functions — and reports them without necessarily changing the file. All of the specific tools in this topic (Black, Ruff, Flake8 and its plugin ecosystem, isort) are third-party packages, not part of the Python standard library; you install them separately (typically via pip or uv) and, usually, run them via CI.
Formatting vs Linting
The distinction matters because the two tools solve different problems. A formatter answers "how should this be laid out?" and enforces one answer with no configuration debates (this is Black's explicit design philosophy — it deliberately offers very few style options so teams stop arguing about them). A linter answers "is this code correct/safe/well-structured?" and often can't be fully automatic, because some findings (an unused variable that's actually intentional, a deliberately broad except) need human judgment to resolve.
In practice a Python project typically runs both: a formatter to normalize layout on every save/commit, and a linter to catch problems formatting alone can't fix.
Black (third-party)
Black is an opinionated, third-party code formatter for Python. "Opinionated" means it has very few configuration knobs — it decides quote style, line wrapping, trailing commas, and spacing for you, and running it twice on the same code produces identical output (it's idempotent). The tagline is "any color you like, as long as it's black": the value is that formatting stops being a discussion.
Black normalized spacing, quote style (single to double quotes by default), and dict spacing without being told each individual rule.
$ pip install black
$ black mymodule.py
reformatted mymodule.py
All done! ✨ 🍰 ✨
1 file reformatted.# before Black
def f(a,b, c = 1, *args,**kwargs):
return {'a':a,'b':b,"c":c}
# after black mymodule.py
def f(a, b, c=1, *args, **kwargs):
return {"a": a, "b": b, "c": c}Ruff (third-party)
Ruff is a third-party, high-performance Python linter (and, more recently, formatter) written in Rust. Its headline appeal is speed — it's commonly reported as tens to hundreds of times faster than older pure-Python linters on large codebases — combined with reimplementing the rule sets of many older tools (Flake8 and its plugin ecosystem, isort, pyupgrade, and more) behind one configuration file and one binary. Many teams in the mid-2020s onward use Ruff to replace several older tools at once.
Ruff's rule codes (F401, E501, etc.) intentionally mirror Flake8's own codes for many checks, which is part of how it positions itself as a drop-in replacement.
$ pip install ruff
$ ruff check .
mymodule.py:3:1: F401 'os' imported but unused
mymodule.py:10:5: E501 line too long (95 > 88 characters)
Found 2 errors.
$ ruff format . # Ruff also has a Black-compatible formatter mode
1 file reformatted.Flake8 Ecosystem (third-party)
Flake8 is an older, widely used third-party linter that itself is a thin wrapper combining three separate tools: pycodestyle (PEP 8 style checks), pyflakes (logical error checks — unused imports, undefined names), and mccabe (cyclomatic complexity checks). Its real strength is its plugin ecosystem: hundreds of third-party plugins add checks for bugbear-style anti-patterns (flake8-bugbear), docstring conventions (flake8-docstrings), security issues (flake8-bandit), and more, all reporting through Flake8's unified interface.
$ pip install flake8
$ flake8 mymodule.py
mymodule.py:1:1: F401 'json' imported but unused
mymodule.py:5:80: E501 line too long (92 > 79 characters)
mymodule.py:8:1: C901 'process_all' is too complex (12)isort (third-party)
isort is a third-party tool dedicated to one job: sorting and grouping import statements, applying the stdlib/third-party/local ordering PEP 8 describes automatically rather than by hand. It can run standalone or as a Ruff rule set (I rules) / Black-compatible profile so the two tools don't fight over blank lines.
# before isort
from myapp.models import User
import os
import requests
from pathlib import Path
# after isort .
import os
from pathlib import Path
import requests
from myapp.models import UserStatic Analysis Concept
Both linters and formatters are forms of static analysis — they examine source code without running it, as opposed to dynamic analysis (running the code and observing behavior, e.g. tests, profilers, coverage tools). Static analysis catches a class of problems before a single test runs: syntax and style issues instantly, and some logical issues (unused variables, obviously undefined names, unreachable code) without needing to execute anything. It cannot catch problems that only manifest at runtime with specific data (see the dedicated Type Checking & Static Analysis topic for the deeper, type-aware end of this spectrum).
Configuration
Modern Python tooling converges on pyproject.toml as the single configuration file for a project, with each tool reading its own [tool.X] section. This avoids scattering .flake8, .isort.cfg, and setup.cfg across the repo.
# pyproject.toml
[tool.black]
line-length = 88
target-version = ["py312"]
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "B"] # pycodestyle, pyflakes, isort, bugbear rules
ignore = ["E501"] # let the formatter own line lengthWorkflow Integration
The value of formatters/linters compounds when they run automatically rather than "whenever someone remembers." Common integration points: editor-on-save, a pre-commit hook (third-party pre-commit framework runs configured checks before a commit is allowed to land), and CI (the check re-runs on every pull request so nothing merges unformatted or unlinted — see the CI/CD for Python topic).
# .pre-commit-config.yaml (third-party pre-commit framework)
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-formatFixable vs Unfixable Findings
Linter findings split into two categories in practice. Some are auto-fixable — Ruff and similar tools can rewrite the file to resolve them (ruff check --fix), the same way a formatter would (e.g. removing a genuinely unused import, reordering imports). Others require a human decision — a function flagged as too complex needs to be refactored by a person; a broad except Exception: might be intentional and just needs a # noqa suppression with a reason, or a real fix. Treat autofix as a starting point, not a substitute for reading the diff.
Suppressing a lint finding (# noqa: E501 or a Ruff-specific # ruff: noqa) should be rare and commented with *why* — a wall of unexplained suppressions defeats the point of linting.
Complete Example
A typical local loop: check, auto-fix what's fixable, format, then re-check to confirm a clean run — the same sequence a pre-commit hook or CI job would run non-interactively.
$ ruff check .
mymodule.py:2:1: F401 'sys' imported but unused
Found 1 error.
$ ruff check --fix .
Fixed 1 error.
$ ruff format .
1 file reformatted.
$ ruff check .
All checks passed!Quick Reference
Tool Kind What it does
-------- ------------------ --------------------------------------------
Black formatter rewrites layout, opinionated, few options
Ruff linter + formatter fast; reimplements Flake8/isort/pyupgrade rules
Flake8 linter pycodestyle + pyflakes + mccabe + plugins
isort formatter (imports) sorts/groups import statements
pre-commit workflow tool runs configured checks before each commit
All of the above are third-party packages, installed via pip/uv,
not part of the Python standard library.Key Takeaways
Formatters (Black, Ruff's formatter) rewrite layout automatically; linters (Ruff, Flake8) report problems that often still need a human decision.
Black, Ruff, Flake8, isort, and pre-commit are all third-party tools — install and configure them explicitly, they are not part of Python itself.
Ruff has largely absorbed the roles of Flake8, isort, and several other older tools behind one fast implementation.
pyproject.toml is the modern convention for centralizing per-tool configuration.
Automation only pays off when it's wired into the workflow — editor-on-save, pre-commit hooks, and CI — not left as a manual step.
Manual, occasional formatting drifts — files get inconsistent, and CI catches problems only after a PR is already open. Automating the check removes the human-memory dependency.
import os # unused import sys def f(): return sys.argv import sys def f(): return sys.argv
An unused import is flagged by pyflakes/Ruff (F401) — removing it isn't just tidiness, unused imports can hide typos (importing the wrong module) and slow down import time.
x = 1 # noqa y = 2 # noqa z = 3 # noqa: E501, F401, C901 z = 3 # noqa: E501 -- long URL in the string literal, can't wrap
Blanket or unexplained noqa suppressions defeat the purpose of linting; scope a suppression to the specific rule and record why it's intentional.
What's the core difference between what a formatter does and what a linter does?
A formatter rewrites source code into a canonical layout with no judgment calls (spacing, quotes, line breaks). A linter analyzes code and reports problems — some auto-fixable, many requiring human judgment (a flagged complex function, an intentionally broad except).
Are Black, Ruff, and Flake8 part of the Python standard library?
No — all three (and isort, and the pre-commit framework) are third-party packages installed separately via pip or uv. Python's interpreter has no built-in formatter or linter.
Why might a team migrate from Flake8 + isort + Black separately to Ruff?
Ruff reimplements the rule sets of pyflakes, pycodestyle, isort, and other plugins in one fast Rust-based tool, replacing several separate configurations and processes with one, at a large speed advantage on bigger codebases.
What does ruff check --fix do that plain ruff check doesn't?
ruff check only reports findings. --fix additionally rewrites the file to resolve every auto-fixable finding (e.g. removing unused imports, reordering imports) — findings that need human judgment are still just reported, not silently changed.
Why is a pre-commit hook a stronger guarantee than "remember to run the formatter before committing"?
A pre-commit hook runs automatically and can block the commit if checks fail, removing reliance on a developer remembering a manual step — the same way CI blocks a PR from merging unformatted code.
When is suppressing a lint finding with # noqa appropriate?
Rarely, and only when the finding is a genuine false positive or an intentional, justified exception — scoped to the specific rule code, with a short comment explaining why, rather than a blanket unexplained suppression.
Given def f(a,b,c=1):return a+b+c, what would black/ruff format rewrite this to?
def f(a, b, c=1): return a + b + c The formatter adds spaces after commas and around the +, and moves the compound one-line statement onto its own indented line — the body of a function is never left on the same line as the def under Black's style.
Running ruff check on this file reports F401 'os' imported but unused. What is the auto-fixed result of ruff check --fix here?
The unused import os line is removed, leaving import requests and the function unchanged. Ruff can safely autofix this because deleting a genuinely unused import cannot change program behavior.
Write a minimal pyproject.toml snippet configuring Ruff to target Python 3.12 with an 88-character line length and the pyflakes + pycodestyle + isort rule sets enabled.
[tool.ruff] line-length = 88 target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I"]
E selects pycodestyle rules, F selects pyflakes rules, and I selects isort import-sorting rules; line-length and target-version configure formatting/lint behavior for the chosen Python version.
import os
import requests
def fetch(url):
return requests.get(url).text