The Python toolchain has finally converged. The last three years we got: uv replacing pip/venv/poetry/pyenv, Ruff replacing flake8/black/isort/pyupgrade, ty (and Pyrefly) joining mypy/pyright in the type-checker space. Builds are 10–100× faster. The result is a Python developer experience that, in 2026, is genuinely pleasant.

This post is the working setup. The minimum knowledge to run a modern Python project, and the rationale for each piece.

What’s in vs. what’s out

Job20222026
Install Pythonpyenvuv python install
Virtualenvpython -m venvuv venv (auto)
Install depspip installuv add / uv sync
Lock depspip-compile / poetryuv.lock
Run scriptpython -m foouv run python -m foo
Lintflake8, pylintruff check
Formatblack, isortruff format
Upgrade syntaxpyupgraderuff check --select UP --fix
Type checkmypymypy / pyright / ty

The big change isn’t features — it’s speed. uv installs deps in 100ms what pip did in 30s. Ruff lints a 100k-LoC repo in 200ms. The friction that made Python tooling unpleasant has just… gone away.

uv — the one tool to rule them all

Install once:

curl -LsSf https://astral.sh/uv/install.sh | sh

Now everything:

uv python install 3.13          # install a Python version
uv init my-app                  # new project (pyproject.toml + src layout)
cd my-app

uv add fastapi 'pydantic>=2'    # add deps (writes pyproject.toml + uv.lock)
uv add --dev pytest ruff        # dev deps
uv remove fastapi               # remove

uv sync                         # install deps from lockfile, exact versions
uv run pytest                   # run a command in the project's venv

What this replaces:

  • pyenvuv python
  • python -m venv + manual activation → uv does it transparently
  • pip install -r requirements.txtuv sync
  • pip-compile / poetry lockuv lock
  • pipxuv tool install

One binary, no Python required to install (it’s static), 100× faster than the union of what it replaces.

A real pyproject.toml

[project]
name = "my-app"
version = "0.1.0"
description = "A modern Python app"
readme = "README.md"
requires-python = ">=3.13"
authors = [{ name = "You", email = "[email protected]" }]
dependencies = [
    "fastapi>=0.115",
    "pydantic>=2.10",
    "sqlalchemy[asyncio]>=2.0",
    "asyncpg>=0.30",
    "httpx>=0.28",
]

[project.optional-dependencies]
dev = [
    "pytest>=8",
    "pytest-asyncio>=0.24",
    "ruff>=0.7",
    "mypy>=1.13",
    "pre-commit>=4",
]

[project.scripts]
my-app = "my_app.main:cli"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
package = true                  # makes `uv run` aware of your package

# ---- Ruff ----
[tool.ruff]
line-length = 100
target-version = "py313"
src = ["src"]

[tool.ruff.lint]
select = [
    "E", "F", "W",              # pycodestyle / pyflakes
    "I",                        # isort
    "N",                        # pep8-naming
    "UP",                       # pyupgrade
    "B",                        # flake8-bugbear
    "C4",                       # flake8-comprehensions
    "SIM",                      # flake8-simplify
    "ASYNC",                    # async-correctness
    "RUF",                      # ruff-specific
]
ignore = [
    "E501",                     # line length (handled by formatter)
    "B008",                     # function call in default argument (FastAPI Depends)
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"

# ---- mypy ----
[tool.mypy]
python_version = "3.13"
strict = true
plugins = ["pydantic.mypy"]
exclude = ["build", "dist"]

# ---- pytest ----
[tool.pytest.ini_options]
addopts = "-q --strict-markers"
asyncio_mode = "auto"
testpaths = ["tests"]

Notice:

  • One file for project metadata, deps, lint config, type config, test config.
  • src/ layoutsrc/my_app/. Prevents accidental imports of the project from the repo root.
  • tool.uv.package = true — uv treats the project as a package, not just a venv.

Ruff — lint + format

Ruff replaces:

  • flake8 (and most plugins)
  • black (formatting)
  • isort (import sorting)
  • pyupgrade (modern syntax)
  • autoflake (unused imports)
  • bandit (security lints, partial)
ruff check .                    # lint
ruff check . --fix              # auto-fix what's safe
ruff format .                   # format

The rule selection is the only thing that takes thought. The block in the pyproject above is a sane default for backend Python. Add more as you want stricter:

[tool.ruff.lint]
extend-select = [
    "S",                        # security (bandit)
    "PT",                       # pytest style
    "TID",                      # tidy imports
    "TCH",                      # typing-only imports
    "ANN",                      # require type annotations
    "PERF",                     # performance
    "PL",                       # pylint subset
]

Ruff turns lint from “I’ll run it once before merging” into “the IDE auto-fixes on save, in milliseconds.” Different category of tool.

Type checking — mypy, pyright, or ty?

In 2026 you have three serious options:

ToolSpeedEcosystemWhen
mypySlowMature, plugins (Django, Pydantic)Default; broadest plugin support
pyrightFastBest with VSCodeEditor / large codebases
ty (Astral)Very fastNew (2025–2026)Watch this space; great for CI
Pyrefly (Meta)Very fastNew, type-inference focusedWatch this space

For 2026, I’d:

  • Use pyright in the editor (built into Pylance, fast, accurate).
  • Use mypy in CI (best plugin ecosystem, especially for Django and SQLAlchemy).
  • Try ty when it stabilizes — likely the future.

CI command:

uv run mypy src/

Type errors caught here are way cheaper than in production. Run on every PR.

Pre-commit hooks

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.7.4
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/astral-sh/uv-pre-commit
    rev: 0.5.4
    hooks:
      - id: uv-lock                # ensure uv.lock matches pyproject

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: end-of-file-fixer
      - id: trailing-whitespace
      - id: check-merge-conflict
      - id: check-yaml
      - id: check-added-large-files

Set up:

uv add --dev pre-commit
uv run pre-commit install

Now lint, format, and lockfile checks run on every commit. PR reviews stay focused on logic.

CI in 50 lines

# .github/workflows/ci.yml
name: ci
on: [pull_request, push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with: { enable-cache: true }
      - run: uv python install 3.13
      - run: uv sync --all-extras
      - run: uv run ruff check .
      - run: uv run ruff format --check .
      - run: uv run mypy src/
      - run: uv run pytest --cov=src --cov-report=xml
      - uses: codecov/codecov-action@v4
        with: { files: coverage.xml }

End-to-end Python CI in well under a minute on most repos. The enable-cache: true is uv’s Astral-maintained cache that makes warm runs absurd.

Project skeleton

my-app/
├── pyproject.toml
├── uv.lock
├── README.md
├── .pre-commit-config.yaml
├── .gitignore
├── src/
│   └── my_app/
│       ├── __init__.py
│       ├── main.py
│       └── ...
└── tests/
    ├── conftest.py
    └── test_main.py

Why src/:

  • Forces you to install your package (uv sync) before testing it. You can’t accidentally import the working tree.
  • Keeps pyproject.toml and tests at the top level where tools find them.
  • Decouples your package name from the repo name.

Migrating from older setups

From requirements.txt + venv

uv init                                 # creates pyproject + .venv + uv.lock
uv add $(grep -v '^#' requirements.txt)
rm requirements.txt

From Poetry

uvx migrate-to-uv                       # community tool that converts pyproject.toml

Or by hand: copy [tool.poetry.dependencies] into [project] dependencies, run uv lock. The lock files differ but the deps don’t.

From Pipenv

There’s a pipenv-to-uv converter; honestly, just uv add everything from Pipfile and walk away. Pipenv’s resolver was famously slow; you’ll feel the upgrade.

Speed that changes behavior

  • uv add foo — typically 50–200ms.
  • uv sync from lockfile — typically 100ms warm.
  • ruff check on a 100k-LoC repo — typically 100–500ms.
  • ruff format on the same — similar.

These numbers matter because they change behavior. When uv add foo finishes in a blink, you experiment more. When ruff format is instant, you don’t fight your formatter. When uv sync is 100ms, you run git pull && uv sync without flinching.

Tooling speed is a developer experience multiplier. This is the unsung Python win of 2024–2026.

Things still rough in 2026

  • Lock-file-portable wheels. Cross-platform wheels are mostly fine, but exotic deps (some C extensions, GPU torch builds) still surprise you.
  • uv for monorepos. Workspaces work, but the story is younger than Cargo’s. Watch the docs.
  • pip install -e . semantics with namespaces. Get the src/ layout right and most of these go away.
  • Type-checker disagreements. mypy and pyright disagree on edge cases. Use one as the source of truth in CI.

What I’d add as the project grows

  • hypothesis for property-based tests once your domain types stabilize.
  • scriv for changelog management.
  • trio or anyio if you need a more controlled async story than asyncio’s defaults.
  • logfire (Pydantic) or structlog for structured logs.
  • pydantic-settings for typed env config.

Read this next

If you want my full cookiecutter-style starter — pyproject.toml, pre-commit, CI, Dockerfile, FastAPI bootstrap, all wired up — it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .