In code review, your team keeps arguing about import order and line length.
Meanwhile a misspelled variable in a branch that rarely runs reached
production as a NameError. Ruff handles both problems. It is a linter,
which reads code and reports likely mistakes without running it. It is also
a formatter, which rewrites the layout of code so nobody argues about it.
Start with a small configuration
Put this in pyproject.toml for a project that supports Python 3.14 and
newer:
[tool.ruff]
target-version = "py314"
line-length = 100
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "B", "I", "UP", "TC"]
Each code in select turns on a family of rules:
| Selection | Examples of what it checks |
|---|---|
E4, E7, E9 |
Import placement, statement mistakes and files Ruff cannot read |
F |
Undefined names and unused imports |
B |
Bug patterns such as mutable default arguments |
I |
Import order |
UP |
Older spellings that the target Python can write more simply |
TC |
Imports used only in type hints, and type-checking imports used at runtime |
Ruff reports syntax errors whatever you select.
target-version names the oldest Python you support, so UP suggests only
rewrites that work there. Ruff can read it from requires-python instead,
but an explicit value keeps this example clear. Pin the Ruff version you run
in CI, because a new release can add rules to a family you selected. See
Ruff configuration.
Run lint and format as separate checks
From the project folder, with source in src and tests in tests:
ruff check src tests
ruff format --check --diff src tests
The first command reports lint findings. The second reports any file that
the formatter would change, and shows the change without writing it.
Import order is a lint rule, I, so ruff format alone does not sort your
imports.
To apply changes locally, run ruff check --fix src tests, then
ruff format src tests, and read the diff before you commit. --fix
applies only the fixes Ruff marks as safe. See fix safety.
Ruff's formatter is close to Black but not identical. If you switch, make one commit that contains only the formatting changes, and stop running Black on the same files.
Catch an import hidden behind TYPE_CHECKING
TYPE_CHECKING is False when the program runs and True only for type
checkers. This code passes mypy --strict, but calling today_iso() raises
NameError:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import date
def today_iso() -> str:
return date.today().isoformat()
Rule TC004 flags it, because the function body needs date at runtime.
Move the import out of the if block. See the
TC004 rule.
The opposite move can break code too. Some libraries, such as Pydantic, read
annotations at runtime, so a name used only in an annotation still has to
exist. Tell Ruff about such base classes, so TC stops suggesting that you
move those imports:
[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = ["pydantic.BaseModel"]
This setting changes only Ruff's advice. After you move imports, create one of the models in a test to check that it still works. See the type-checking settings.
Re-export names instead of silencing F401
Ruff reports F401, the unused-import rule, for an __init__.py that
imports a name for its users. Don't turn F401 off for every __init__.py. Mark the
re-export instead:
from .client import Client as Client
The X as X form, or a listing in __all__, tells Ruff and mypy that the
import is deliberate. Ruff still reports the other unused imports in the
file. The next article covers how to scope other exceptions to the files
that need them.
Adopt a noisy rule in steps
If a new rule reports hundreds of findings, read a sample first. Decide whether the rule fits your code and whether its automatic fixes keep the behavior you need. Put the mechanical fixes in their own commit, so a reviewer can check them apart from real changes. Leaving a rule off is a valid decision too.
Check your understanding Both Ruff commands pass. What do you still not know? Show answer Hide answer
You know the files follow the rules you selected and the formatter's layout. You do not know that the code works, that the tests are useful, that the types are right or that the module boundaries hold. Tests, mypy, import-graph checks and code review each check one of those.