Linting, formatting and type checking
Use ruff to catch mistakes and normalise formatting, configure it in pyproject.toml, and run a type checker over annotated code.
What you will learn
- Run ruff check and ruff format, and tell what each one is for
- Apply safe automatic fixes and review the ones ruff will not apply
- Select lint rules in pyproject.toml
- Read a mypy error and fix the code it points at
On this page
Python runs code that is badly formatted, imports things it never uses, and passes a string where a number belongs, as long as the faulty line is never reached. Three kinds of tool catch these problems without running the program: a linter reports likely mistakes, a formatter rewrites layout into one agreed style, and a type checker compares your annotations with how values are actually used.
ruff check: finding mistakes
ruff is a linter and a formatter in a single tool. Install it into your project’s environment or as a command of its own:
In a venv, install it with pip:
$ python -m pip install ruffIn a uv project, add it to the development group instead:
$ uv add --dev ruffUse whichever matches your setup. Here is a file that runs correctly and still has problems:
import osimport sysimport json
def average(numbers): total=0 for n in numbers : total+=n unused = len(numbers) return total/len(numbers)
print( average([3,4,5]) )Run the linter with three rule families selected. E is pycodestyle errors, F is the checks that Pyflakes pioneered (unused names, undefined names), and I checks import order. The concise output format prints one line per finding:
$ ruff check --select E,F,I --output-format concise messy.pymessy.py:1:1: I001 [*] Import block is un-sorted or un-formattedmessy.py:1:8: F401 [*] `os` imported but unusedmessy.py:2:8: F401 [*] `sys` imported but unusedmessy.py:3:8: F401 [*] `json` imported but unusedmessy.py:9:5: F841 Local variable `unused` is assigned to but never usedFound 5 errors.[*] 4 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).Each line gives the file, line and column, a rule code, and a message. The code is how you look a rule up, silence it, or turn it on everywhere. The [*] marks findings that ruff knows how to repair.
--fix applies the safe repairs:
$ ruff check --select E,F,I --fix --output-format concise messy.pymessy.py:6:5: F841 Local variable `unused` is assigned to but never usedFound 4 errors (3 fixed, 1 remaining).No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option).The three unused imports are gone, and the remaining one needs you. Deleting unused = len(numbers) is mechanical, but ruff holds back because removing an assignment can remove a call with side effects, and only you know whether it matters here. Ruff calls such repairs unsafe. Edit that line yourself.
The exact wording and layout of ruff’s output can change between releases, so treat the examples as the shape of the thing. The rule codes are stable.
ruff format: one style, no arguments
A formatter is not about correctness. It ends arguments about spaces and line breaks by applying one style to everything. Ask what it would change first, using --diff:
$ ruff format --diff messy.py--- messy.py+++ messy.py@@ -2,11 +2,13 @@ import sys import json
-def average(numbers):- total=0- for n in numbers :- total+=n++def average(numbers):+ total = 0+ for n in numbers:+ total += n unused = len(numbers)- return total/len(numbers)+ return total / len(numbers)+
-print( average([3,4,5]) )+print(average([3, 4, 5]))Then run ruff format messy.py to apply it. ruff format --check changes nothing and reports whether any file would change, with a non-zero exit status if so. That is the form to use in automated checks. After the fixes and formatting, the file reads as expected:
def average(numbers): total = 0 for n in numbers: total += n return total / len(numbers)
print(average([3, 4, 5]))4.0The linter and formatter overlap in spirit and are separate commands. Use both. black is another widely used formatter, and projects that already use it are fine.
Configuring it in pyproject.toml
Typing --select E,F,I every time is tiresome. Put the choice where the whole team and the editor see it, in the [tool.ruff] tables of the pyproject.toml from the previous lessons:
[tool.ruff]line-length = 100
[tool.ruff.lint]select = ["E", "F", "I"]ignore = ["E501"]line-length applies to the formatter and to the line-length rule. select chooses rule families by code prefix. ignore removes individual rules from the selection, here E501, the “line too long” check, because the formatter already manages line length. Once this is in place, a bare ruff check and ruff format do the right thing from anywhere inside the project.
A good habit is to run both before every commit:
$ ruff check && ruff format --checkType checking, briefly
Annotations from the type hints lesson do nothing at run time. A type checker is a separate program that reads them and reports conflicts. The most established is mypy; pyright is another. Newer checkers such as ty and pyrefly also exist, and all of them work on the same annotations.
This program runs without error and prints sensible-looking output, but two of its calls break the promises in its own annotations:
def find_age(ages: dict[str, int], name: str) -> int | None: return ages.get(name)
def double(n: int) -> int: return n * 2
ages = {"ana": 31, "ben": 27}
print(double(find_age(ages, "ana")))print(double("ab"))62ababRunning the checker:
$ mypy typed.pytyped.py:11: error: Argument 1 to "double" has incompatible type "int | None"; expected "int" [arg-type]typed.py:12: error: Argument 1 to "double" has incompatible type "str"; expected "int" [arg-type]Found 2 errors in 1 file (checked 1 source file)The first message is the useful one. find_age can return None when the name is missing. Today "ana" exists, so the program works, but with "zoe" it would crash inside double. Nobody tested that path, and the checker found it. The second is a quieter bug: "ab" * 2 is valid Python, so the program printed abab instead of failing.
Both are fixed by handling the None and passing an int:
def find_age(ages: dict[str, int], name: str) -> int | None: return ages.get(name)
def double(n: int) -> int: return n * 2
ages = {"ana": 31, "ben": 27}
age = find_age(ages, "ana")if age is not None: print(double(age))print(double(21))6242$ mypy typed_fixed.pySuccess: no issues found in 1 source fileBy default mypy skips the body of any function that has no annotations at all, so unannotated code gets no checking. Annotate the signatures, and the checker has something to compare. Type checking is optional in Python and works best when applied gradually, a module at a time. The advanced track returns to typing in depth.
Take messy.py from the lesson. Without running anything, predict how many findings ruff check --select E,F,I --output-format concise messy.py reports after you have run ruff format messy.py. Then explain why the answer is not zero, and what --fix would add.
Expected: the number N from the Found N errors. line, and the rule codes with their count.
Show solution
All five. The command reports:
messy.py:1:1: I001 [*] Import block is un-sorted or un-formattedmessy.py:1:8: F401 [*] `os` imported but unusedmessy.py:2:8: F401 [*] `sys` imported but unusedmessy.py:3:8: F401 [*] `json` imported but unusedmessy.py:10:5: F841 Local variable `unused` is assigned to but never usedFound 5 errors.Formatting changes layout, not content: it does not remove imports or variables, and it leaves the import order as written. The three F401 findings, the unsorted-imports I001 and the F841 unused variable all remain (the last one moves down a line, because the formatter added a blank line).
Running ruff check --fix then removes the three unused imports, which also clears the sorting finding, since nothing is left to sort. It leaves the unused variable for you to edit by hand. Linting and formatting are complementary, and neither replaces the other.
This function computes an average and is called with an empty list, which would crash with ZeroDivisionError. Make it return None for an empty list, give it annotations, and call it so that mypy is satisfied: print the average when there is one, and print None for the empty case. Expected output:
4.0Nonedef average(numbers): return sum(numbers) / len(numbers)
print(average([3, 4, 5]))print(average([]))Show solution
def average(numbers: list[float]) -> float | None: if not numbers: return None return sum(numbers) / len(numbers)
result = average([3, 4, 5])if result is not None: print(result)print(average([]))mypy reports no issues, and ruff check and ruff format --check both pass. The return annotation float | None is the honest description of the function, and the if result is not None check is what narrows it back to float for the code that follows. The last line only prints the value, which accepts None, so it needs no check.
Take the textkit project from the previous lesson. Add ruff to its development group, select the rule families E, F and I in pyproject.toml, and run both ruff commands from the project root without any flags. What command would you run before each commit?
Expected: the pyproject.toml tables you add, and this output when a clean project passes both checks:
All checks passed!2 files already formattedShow solution
[dependency-groups]dev = ["pytest", "ruff"]
[tool.ruff.lint]select = ["E", "F", "I"]$ uv add --dev ruff$ uv run ruff check$ uv run ruff format --checkWith the configuration in pyproject.toml, no flags are needed. Before a commit, run uv run ruff check && uv run ruff format --check. Both must succeed, and the && stops at the first failure. In a pip-based environment, drop uv run.
Summary
ruff checkfinds likely mistakes and reports rule codes;--fixapplies the safe repairs, and unsafe ones are left for you.ruff formatapplies one layout, and--checkor--diffpreviews it.- Select rules and line length in
[tool.ruff]inpyproject.toml, so the command line stays short and everyone gets the same result. - A type checker such as mypy compares your annotations with how values are used, and catches cases like the unhandled
Nonethat tests missed. - Going further: the advanced lesson Typing fundamentals teaches the annotations a type checker reads.