Setting Up a Modern Python + FastAPI Environment with uv, Ruff, and Husky
The Python tooling landscape finally feels settled: uv for environments and dependencies, Ruff for linting and formatting, and a pre-commit hook so the rules actually get enforced instead of living in a README nobody reads.
Here's the setup, end to end.
Bootstrap with uv
uv replaces pip + venv + pip-tools with one fast, Rust-based tool. Starting from an existing project:
git clone https://github.com/astral-sh/uv-fastapi-example backend cd backend rm -rf .git/ uv python pin 3.11 # downloads 3.11 if needed, writes .python-version uv lock # resolve dependencies against the pinned version uv sync # create .venv and install everything
uv sync creates .venv and installs every dependency from uv.lock in milliseconds. No more "works on my machine" because someone's global Python drifted.
Pin the Python version everywhere - not just once
It's easy to pin a version in one place and forget the other two. There are three spots that all need to agree:
# pyproject.toml [project] requires-python = ">=3.11"
# .python-version 3.11
# Dockerfile FROM python:3.11-slim-bullseye
If the Dockerfile still says 3.12 while pyproject.toml says 3.11, your container build and your local uv sync are silently running different interpreters.
Configure Ruff for how FastAPI actually looks
Ruff's docs are explicit: don't start with select = ["ALL"]. It's noisy and churns with every release. Start with a curated set instead:
[tool.ruff] target-version = "py311" line-length = 88 [tool.ruff.lint] select = [ "E", "W", # pycodestyle "F", # Pyflakes "I", # isort "B", # flake8-bugbear "C4", # comprehensions "UP", # pyupgrade "ARG", # unused arguments "SIM", # simplify "RUF", # Ruff-specific ]
FastAPI's dependency-injection style leans on function calls as default argument values - q: str = Query(None), dependencies=[Depends(get_token)]. That's exactly the pattern flake8-bugbear's B008 rule exists to catch ("don't call functions in default args, they only evaluate once"). Disabling B008 entirely throws out a useful check. Ruff has a sharper tool for this:
[tool.ruff.lint.flake8-bugbear] extend-immutable-calls = [ "fastapi.Depends", "fastapi.Query", "fastapi.Path", "fastapi.Body", "fastapi.Header", "fastapi.Cookie", "fastapi.Form", "fastapi.File", "fastapi.Security", ]
This allowlists FastAPI's own helpers as "known immutable," so B008 still fires on everything else.
Wire up a Makefile
Small, but it means nobody has to remember the exact uv run invocation:
.PHONY: dev lint format format-check fix dev: uv run fastapi dev lint: uv run ruff check . format-check: uv run ruff format --check . fix: uv run ruff check --fix . format: uv run ruff format .
make dev, make lint, make fix - done.
Enforce it with a pre-commit hook (via Husky)
Linting that only runs in CI catches problems an hour too late. A pre-commit git hook catches them before the commit even exists. Husky is normally a JS tool, but it's a perfectly good place to manage hooks for a whole repo, even a Python backend living in a subdirectory - especially once a frontend shows up alongside it.
bun add --dev husky bunx husky init
That sets core.hooksPath to .husky/_ and scaffolds .husky/pre-commit. Point it at the backend:
# .husky/pre-commit cd backend && make lint && make format-check
Now a non-zero exit from either command blocks the commit - no bad formatting or lint errors sneak into history. The one catch: hooks live outside git history, so every clone needs one bun install (it runs the prepare script that wires the hook up) before the hook is active locally.
Wrap-up
None of these pieces are exotic on their own, but together they remove an entire category of "wait, why is the linter different on CI" conversations: one interpreter version pinned three places, one curated Ruff config that understands FastAPI's idioms instead of fighting them, and one hook that enforces both before a bad commit ever lands.