What you'll learn
- Lint and format with
ruff, one fast tool that replaces several older ones - Configure ruff and mypy in
pyproject.tomlso every developer gets the same checks - Run the checks automatically before each commit with
pre-commit - Run them again on every push with a GitHub Actions workflow, and understand why both layers exist
Concept
Quality tooling turns "please remember to" into "it is impossible to forget". A linter reads code and reports likely bugs, unused imports, and style violations; a formatter rewrites code into one consistent layout so nobody argues about it; a type checker verifies the hints; tests verify behavior. ruff does the first two, replacing flake8, isort, black, and a dozen plugins with one tool that runs in milliseconds. With mypy and pytest, that is the complete set, and the rest of this lesson is about making them run without anyone deciding to.

ruff check lints and ruff format formats; both read [tool.ruff] in pyproject.toml. Start with the default rule set plus a few families, E and F for errors, I for import sorting, UP for modern syntax, B for common bugs, and let ruff check --fix correct what it can. Set line-length once and stop discussing it. Mypy's [tool.mypy] with strict = true is the goal for new projects; for existing code, start permissive and tighten module by module. Committed configuration means the checks are identical for every developer and for CI.
| Tool | Job | Command | Config |
|---|---|---|---|
ruff check |
lint: bugs, unused code, imports | ruff check --fix . |
[tool.ruff], [tool.ruff.lint] |
ruff format |
format: one layout, no debate | ruff format . |
[tool.ruff.format] |
mypy |
verify type hints | mypy src |
[tool.mypy] |
pytest |
verify behavior | pytest |
[tool.pytest.ini_options] |
pre-commit |
run the above before each commit | pre-commit run --all-files |
.pre-commit-config.yaml |
| CI workflow | run the above on every push | automatic | .github/workflows/ci.yml |
pre-commit installs a Git hook that runs configured checks on the files you are about to commit and blocks the commit if any fail. The configuration, .pre-commit-config.yaml, lists hooks with pinned versions; ruff and mypy publish official hooks, and a local hook can run pytest. It catches problems in seconds, before they reach a branch, and it fixes formatting for you. Everyone on the project runs pre-commit install once and then never thinks about formatting again.
Continuous integration runs the same checks on a clean machine every time code is pushed, and blocks merging when they fail. It exists because local hooks can be skipped, environments drift, and "works on my machine" is not evidence. A GitHub Actions workflow is a YAML file: on push and pull request, check out the code, install the pinned Python and dependencies with uv sync, then run ruff, mypy, and pytest. A matrix can run the tests on several Python versions and operating systems. The workflow file lives in the repository, so the pipeline is versioned with the code it checks, and a second workflow on a v* tag can build and publish the package with the token from the repository's secrets.

The job body is the same four commands you ran locally, in the same order: lint, format check, types, tests. The order matters only for speed, cheapest checks first, because the first failure stops the job and marks the pull request red. A green run is what lets the merge button appear, so the checks are a gate, not a report.

Code
The tool configuration, all in pyproject.toml, and a file with deliberate problems for the tools to find: an unused import, an f-string with nothing in it, an old-style type hint, a formatting mess, and a real type error.
# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.ruff.format]
quote-style = "double"
[tool.mypy]
strict = true
# messy.py
import os
import sys
from typing import List, Optional
def total(prices: List[float], tax: Optional[float]=None) -> float:
rate=tax if tax is not None else 0.0
print(f"computing")
return sum(prices)*(1+rate)
def label(n: int) -> str:
return "items: " + n
ruff check finds the lint problems and fixes the safe ones; ruff format fixes the layout; the type error is left for mypy, which is the point of running both. The script exits non-zero because mypy does, which is exactly what a hook or CI job needs.
# lint_format.sh
ruff check messy.py 2>&1 | grep -E "^[A-Z]+[0-9]+ |Found|fixable" | sed -E 's/\x1b\[[0-9;]*m//g'
ruff check --fix -q messy.py; ruff format -q messy.py
echo "--- after ruff:"
cat messy.py
echo "--- mypy:"
mypy messy.py
I001 [*] Import block is un-sorted or un-formatted
F401 [*] `os` imported but unused
F401 [*] `sys` imported but unused
UP035 `typing.List` is deprecated, use `list` instead
UP006 [*] Use `list` instead of `List` for type annotation
UP045 [*] Use `X | None` for type annotations
F541 [*] f-string without any placeholders
Found 7 errors.
[*] 6 fixable with the `--fix` option.
--- after ruff:
def total(prices: list[float], tax: float | None = None) -> float:
rate = tax if tax is not None else 0.0
print("computing")
return sum(prices) * (1 + rate)
def label(n: int) -> str:
return "items: " + n
--- mypy:
messy.py:8: error: Returning Any from function declared to return "str" [no-any-return]
messy.py:8: error: Unsupported operand types for + ("str" and "int") [operator]
Found 2 errors in 1 file (checked 1 source file)The pre-commit configuration with pinned hooks, and a dry run over all files. Installing it into a repository is one command.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.6
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.3.1
hooks:
- id: mypy
args: [--strict]
- repo: local
hooks:
- id: pytest
name: pytest (fast tests)
entry: pytest -q -x
language: system
pass_filenames: false
# install_hooks.sh
pre-commit install # once per clone: writes .git/hooks/pre-commit
pre-commit run --all-files # run everything now; later it runs on each commit automatically
git commit -m "..." # blocked if any hook fails; ruff's fixes are staged for you to re-add
The CI workflow. It runs the identical commands on push and pull request, with the exact Python and locked dependencies, and a matrix shows how to add versions.
# .github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.14.7"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv python install ${{ matrix.python }}
- run: uv sync --python ${{ matrix.python }}
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src
- run: uv run pytest -q
# .github/workflows/release.yml
name: release
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv build
- run: uv publish
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
The same checks as a single local script, which is what CI runs and what you run before pushing. Exit codes make it usable from any tool.
# check_all.sh
set -e
printf 'def greet(name: str) -> str:\n return f"hi {name}"\n' > clean.py
printf 'from clean import greet\n\n\ndef test_greet() -> None:\n assert greet("ada") == "hi ada"\n' > test_clean.py
ruff check clean.py test_clean.py && echo "ruff check: ok"
ruff format --check clean.py test_clean.py && echo "ruff format: ok"
mypy --strict clean.py test_clean.py | tail -1
pytest -q test_clean.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/' | tail -1
echo "all checks passed: exit $?"
All checks passed!
ruff check: ok
2 files already formatted
ruff format: ok
Success: no issues found in 2 source files
1 passed in Ns
all checks passed: exit 0Try it yourself
Add [tool.ruff] and [tool.mypy] to the expense project's pyproject.toml, run ruff check --fix . and ruff format ., and commit the result as one "apply formatting" commit. Add .pre-commit-config.yaml with the hooks above, run pre-commit install and pre-commit run --all-files, then try to commit a file with an unused import and watch the hook block it. Push the repository to GitHub with .github/workflows/ci.yml and confirm the check runs and passes on the pull request.
Common mistakes
- Unpinned hook and action versions.
rev: mainor an unversioned action changes under you; pinrevand action tags, and update deliberately. - CI that differs from local. If CI runs commands developers do not, failures are surprises; keep one script or one config both use.
- Treating formatting as a review topic. The formatter decides; if it is in the config, it is settled.
What's next
ruff checkandruff formatreplace linters and formatters; configure them and mypy inpyproject.tomlso checks are identical everywhere.pre-commitruns pinned hooks before every commit and blocks bad ones; CI runs the same commands on every push and gates merging.- A release workflow on a version tag builds and publishes with a token from repository secrets.
Quality is now automatic. Next you will put the finished project into a container and run it the way production does, with Docker.