Skip to content
← All Python articles

Python explainer

Set Up Ruff for Linting and Formatting

About 4 min to read. Allow extra time to work through examples.

Configure Ruff in pyproject.toml, run its linter and formatter as separate checks, catch an import hidden behind TYPE_CHECKING, and adopt a noisy rule without a giant diff.

This article uses Python. You can make it your practice mode to browse related content across the site.

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

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.

step 34 of 41

Modules, Packaging and Tooling

Up next Decide Which Ruff Rules Should Fail CI