The Python Tooling Stack for 2026: uv, Ruff, mypy, pytest
A default toolchain for a new Python project in 2026: uv, Ruff, mypy and pytest in one pyproject.toml, with every command run and a CI workflow.
For a new Python project in 2026, use uv for environments and dependencies, Ruff for linting and formatting, mypy for types and pytest for tests, all configured in one pyproject.toml. None of these is the only good choice for its job. Together they make a small, well-documented default that runs the same way on your laptop and in CI, and each one can be swapped out later without disturbing the others.
This guide builds a project from scratch with that stack, running every command as it goes. It is also a hub: each section links to the detailed guide for that tool.
Tested on 4 November 2026 with uv 0.13.7, Ruff 0.17.3, mypy 2.4.1, pytest 9.2.0 and Python 3.13.14. Versions move quickly, so check each tool’s release notes if a command behaves differently.
The stack at a glance
| Job | Tool | Command |
|---|---|---|
| Python version, venv, dependencies | uv | uv add, uv run, uv sync |
| Linting | Ruff | uv run ruff check . |
| Formatting | Ruff | uv run ruff format . |
| Type checking | mypy | uv run mypy src tests |
| Tests | pytest | uv run pytest |
Everything runs through uv run, which executes a command inside the project’s virtual environment, so you never activate anything by hand.
Step 1: create the project with uv
Install uv first (see the uv resource page or uv vs venv vs conda for how it compares with the older tools). Then:
uv init my-app
cd my-app
uv init creates a packaged project: a pyproject.toml, a README.md, a .python-version file pinning the interpreter, a src/my_app/__init__.py containing a main() function, and a git repository. It also registers a my-app command that calls main(). The generated file includes an authors entry taken from your git configuration; delete it if you do not want it.
The src/ layout means your code lives in src/my_app/ rather than next to pyproject.toml. That keeps tests from accidentally importing your source folder instead of the installed package; src layout vs flat layout explains the trade-off.
If you only want a script-style project with a plain main.py, run uv init --no-package my-app instead. The rest of this guide assumes the packaged default.
Step 2: add dependencies
Runtime dependencies and development tools are kept apart. uv add records a runtime dependency under [project]:
uv add httpx
uv add --dev records a development dependency in a [dependency-groups] table, so these tools are installed for you but are not part of the package your users install:
uv add --dev ruff mypy pytest
uv creates .venv, resolves everything and writes a uv.lock file. Commit uv.lock: it pins the exact version of every package, so a teammate or a CI run installs the same thing you did. When you pull someone else’s changes, uv sync makes your environment match the lockfile.
For how uv compares with Poetry, Hatch and pip, see Python package managers compared and pip vs uv.
Step 3: configure Ruff
Ruff does both jobs that used to need two tools. ruff check lints, and ruff format formats. Its linter enables a large default rule set, roughly 400 rules in the 0.17 series, so it is useful before you configure anything. To go beyond the defaults, use extend-select, which adds rules to them. Plain select replaces the defaults, which is rarely what you want. The what is Ruff guide covers the rule set in more detail.
A reasonable first addition is three rule families: I (import sorting), UP (modernise old syntax) and B (likely bugs). Add this to pyproject.toml:
[tool.ruff.lint]
extend-select = ["I", "UP", "B"]
Run both commands:
uv run ruff check .
uv run ruff format --check .
On a clean project they print All checks passed! and 4 files already formatted. Drop --check to let ruff format rewrite files, and add --fix to ruff check to apply the fixes Ruff marks as safe.
To see it do something, here is a deliberately bad file:
import os, sys
from typing import List
def f(x: List[int]) -> int:
return "a"
ruff check reports five problems: an unsorted import block (I001), two unused imports (F401), and two uses of the deprecated typing.List (UP035 and UP006). It marks four as fixable, and ruff check --fix clears all five. It says nothing about return "a", which is a type error that Ruff does not check for. That is the next tool’s job. If you are replacing Black or flake8, Black vs Ruff vs autopep8 and Ruff vs Pylint vs flake8 explain what changes.
Step 4: add a type checker
A type checker reads your annotations and reports code that contradicts them. The site’s type checkers comparison covers four options. This stack uses mypy, because it is production-stable and supported almost everywhere. ty from Astral is the natural fit with uv and Ruff, but it is still labelled beta, so treat it as a second opinion until it leaves beta.
Turn on strict mode in pyproject.toml:
[tool.mypy]
strict = true
Strict mode is easiest to adopt at the start of a project. Retrofitting it onto an existing codebase is a bigger job, and there you would enable it gradually. Run it on the source and the tests:
uv run mypy src tests
On the clean project this prints Success: no issues found in 3 source files. Run against the bad file above, it reports:
src/my_app/bad.py:5: error: Incompatible return value type (got "str", expected "int") [return-value]
Found 1 error in 1 file (checked 3 source files)
If annotations are new to you, start with the type hints guide.
Step 5: write and run a test
Add a small function in src/my_app/stats.py:
def mean(values: list[float]) -> float:
"""Return the arithmetic mean of a non-empty list."""
if not values:
raise ValueError("mean() needs at least one value")
return sum(values) / len(values)
And a test in tests/test_stats.py:
import pytest
from my_app.stats import mean
def test_mean() -> None:
assert mean([1, 2, 3]) == 2
def test_mean_empty() -> None:
with pytest.raises(ValueError):
mean([])
pytest finds files named test_*.py and functions named test_*, and a plain assert is enough. Point it at the tests folder so it does not search the whole repository:
[tool.pytest.ini_options]
testpaths = ["tests"]
uv run pytest
The output ends with 2 passed. If you are weighing pytest against the standard library’s unittest, see pytest vs unittest and best Python testing tools compared.
The complete pyproject.toml
This is the full file after the steps above, with the generated authors line removed. It is the file the commands in this guide were run against:
[project]
name = "my-app"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = ["httpx>=0.28.1"]
[project.scripts]
my-app = "my_app:main"
[build-system]
requires = ["uv_build>=0.13.7,<0.14.0"]
build-backend = "uv_build"
[dependency-groups]
dev = ["mypy>=2.4.1", "pytest>=9.2.0", "ruff>=0.17.3"]
[tool.ruff.lint]
extend-select = ["I", "UP", "B"]
[tool.mypy]
strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
requires-python = ">=3.13" comes from the interpreter uv picked when it created the project, which is also why .python-version says 3.13. Change both together if you need to support older versions; pyenv vs uv covers managing several interpreters. Every tool reads its settings from this one file, which is the main reason the stack stays easy to maintain. For what each table means, see pyproject.toml explained.
Run everything in one go
The four checks are independent, and a failing one should not hide the others, but for a quick local gate chain them:
uv run ruff check . && uv run ruff format --check . && uv run mypy src tests && uv run pytest
The && stops at the first failure, which is what you want before committing. In CI you often prefer separate steps so you can see every failure at once.
The same four commands in GitHub Actions
CI should run exactly what you run locally. This workflow installs uv with Astral’s official setup-uv action, installs the dependencies from the lockfile, then runs the checks:
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.3.3
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src tests
- run: uv run pytest
uv sync --locked fails if uv.lock no longer matches pyproject.toml, which catches a dependency change that was never locked. uv sync installs the dev group by default, so the tools are present. uv’s own documentation recommends pinning actions to a full commit SHA and pinning the uv version for reproducible builds; the version tags above are shorter for reading, so tighten them for a real repository. The version numbers here were current on 4 November 2026.
What to add later
You do not need these on day one. Add each when you feel its absence:
- pre-commit hooks run Ruff and mypy before each commit, so problems never reach CI.
- tox or nox run your tests across several Python versions; see tox vs nox.
- Docker packages the app for deployment; see Docker for Python with uv.
- Dependency scanning checks your lockfile for known vulnerabilities; see Python dependency security.
- Style habits that no tool enforces are in how to write clean Python code.
When to choose a different stack
Swap a piece when you have a reason, not because a newer one exists. Keep Poetry or Hatch if your team already uses them well (Poetry vs uv vs Hatch). Use Pyright if your team lives in VS Code and wants the same checker in the editor and in CI. Keep an existing Black and flake8 setup until you have time to migrate. The point of one pyproject.toml is that moving any one tool is a small change.
Frequently Asked Questions
What tools should a new Python project use in 2026?
A sensible default is uv for Python versions, environments and dependencies, Ruff for linting and formatting, mypy for type checking and pytest for tests, all configured in one pyproject.toml. Each job has several good alternatives, but this set is small, widely documented and easy to run in CI.
Do I still need pip, venv or virtualenv if I use uv?
Not for day-to-day work. uv creates and manages the virtual environment for a project and installs into it, and uv run executes commands inside it. pip stays useful for reading older tutorials and for environments where uv is not installed.
Which type checker should I use with this stack?
This guide uses mypy because it is production-stable and supported by almost every editor and CI setup. ty from Astral fits the same toolchain but is still in beta, so it works better as a second opinion than as the check that blocks a merge.
Should Ruff replace Black and flake8?
For a new project, yes: ruff check covers linting and ruff format covers formatting, so one tool replaces both. Existing projects can migrate gradually, since Ruff’s formatter is designed to be a drop-in alternative to Black.
What should I add after these four tools?
Add pre-commit hooks so checks run before each commit, tox or nox if you need to test across several Python versions, and a Dockerfile when you deploy. All three build on the same pyproject.toml and the same four commands.