Python Modern Toolchain: Why Three Tools Became One Problem

Running flake8, black, isort, and Poetry in parallel is not a workflow — it’s a maintenance contract nobody signed up for. The Python modern toolchain  has shifted hard: uv handles installs and environments, Ruff replaces the entire linting/formatting stack, and Pyright enforces types without the mypy ceremony. This page breaks down why the migration is worth it and how production setups actually look.


TL;DR: Quick Takeaways

  • Ruff replaces flake8 + black + isort as a single binary — benchmarks show 10–100× faster lint on large codebases
  • uv resolves and installs dependencies faster than pip or Poetry, with a lockfile that’s actually readable
  • Pyright strict mode catches errors mypy misses, but requires explicit typing for decorators and third-party stubs
  • Distroless + uv multi-stage Docker images cut attack surface and shrink final image size below 80 MB for most microservices

Why Your Old Python Toolchain Is Holding You Back

The problem isn’t that flake8, black, and isort are bad tools. The problem is that they were designed in different eras, maintained by different teams, and configured through different files — and now they share pyproject.toml like three developers in a two-person apartment. Conflicts between black’s formatting decisions and isort’s import ordering show up in CI at the worst possible moment. Pre-commit hooks that chain all three add 8–15 seconds to every commit on a mid-size codebase. That’s not a minor inconvenience — that’s developer friction compounding daily across a team.

The Problem with Running Three Tools for One Job

isort and black have a known incompatibility surface: isort moves imports, black reformats them, and the result can still fail isort’s own check on the next pass. The fix is a documented configuration dance in pyproject.toml that everyone copy-pastes from Stack Overflow and hopes it holds. It usually does, until someone upgrades one tool but not the other.

Splitting code quality into three processes also means three failure modes in CI. A pipeline that fails on isort but passes on flake8 sends confusing signals. Junior developers learn to cargo-cult the config without understanding why it’s structured the way it is — which is a legitimate onboarding cost.

Ruff Benchmark — Numbers That Convince a Tech Lead

Ruff is written in Rust. On the CPython repository — roughly 680 000 lines of Python — Ruff lints the entire codebase in under 0.5 seconds. Flake8 on the same codebase takes around 30–40 seconds depending on plugin load. That’s not marketing copy; that’s a real benchmark available in Ruff’s own documentation and reproducible locally.

The CI math is straightforward: if your lint step takes 35 seconds and runs on every PR across 10 engineers doing 4 PRs per day, you’re burning 23 minutes of CI time daily on linting alone. Ruff brings that to under 1 minute. For teams paying per CI minute, this pays for itself inside a sprint.

# Before: three separate tool invocations in CI
pip install flake8 black isort
flake8 src/
black --check src/
isort --check-only src/

# After: one tool, one invocation
pip install ruff
ruff check src/
ruff format --check src/

The consolidation removes two pip installs and two subprocess calls. On cold CI runners with no cache, dependency installation time matters. Ruff’s single binary with zero runtime dependencies makes the difference measurable.

Migrating from Poetry to uv: What Actually Changes

The uv vs Poetry migration is less about features and more about philosophy. Poetry wraps everything — virtualenv creation, dependency resolution, publishing, versioning — in one opinionated CLI. uv is narrowly focused: fast resolver, fast installer, lockfile, virtualenv. It does fewer things and does them faster. The migration guide from Poetry to uv involves three concrete changes: pyproject.toml format, lockfile format, and CI cache configuration.

What Actually Changes in pyproject.toml

Poetry uses its own non-standard [tool.poetry.dependencies] table. uv follows PEP 517 and PEP 735, using [project.dependencies] for runtime deps and [dependency-groups] for dev/test groups. If you’re on Poetry 1.x, your dev dependencies live under [tool.poetry.dev-dependencies] — that entire block moves to a [dependency-groups] table in uv syntax.

# Poetry format (before)
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.110"
httpx = "^0.27"

[tool.poetry.dev-dependencies]
pytest = "^8.0"
ruff = "^0.4"

# uv / PEP 735 format (after)
[project]
name = "myservice"
requires-python = ">=3.11"
dependencies = [
 "fastapi>=0.110",
 "httpx>=0.27",
]

[dependency-groups]
dev = [
 "pytest>=8.0",
 "ruff>=0.4",
]

The structural difference matters because [dependency-groups] is a standard (PEP 735) — not uv-specific. Tools that consume pyproject.toml as a source of truth, including build backends and SBOM generators, understand the standard format without needing uv installed. That’s a supply chain security improvement over Poetry’s proprietary schema.

Deep Dive
Python Async Gotchas Explained

Python asyncio pitfalls You’ve written async code in Python, it looks clean, tests run fast, and your logs show overlapping tasks. These are exactly the situations where Python asyncio pitfalls start to reveal themselves. It...

Handling Edge Cases — Private Indexes and Extras

Poetry handles private PyPI indexes with [[tool.poetry.source]]. uv uses [tool.uv.sources] for the same purpose. The syntax is different but the semantics map cleanly. One thing uv does better: it respects UV_INDEX_URL and UV_EXTRA_INDEX_URL environment variables from the start, which makes Kubernetes secret injection straightforward without modifying pyproject.toml per environment.

# uv private index configuration in pyproject.toml
[tool.uv.sources]
my-private-pkg = { index = "corporate" }

[[tool.uv.index]]
name = "corporate"
url = "https://pypi.internal.company.com/simple/"
explicit = true

The explicit = true flag tells uv to only use this index for packages explicitly mapped to it — not as a fallback for everything. This prevents accidental resolution against an internal mirror for public packages, which is a real dependency confusion attack vector.

Ruff Configuration for Production-Grade Python

Default Ruff with select = ["E", "F"] is pyflakes + pycodestyle — a minimal subset. Production-grade Python needs more. The rule categories that matter operationally are S (security, ported from bandit), UP (pyupgrade — modernizes syntax automatically), B (flake8-bugbear — catches real bugs), and ANN (annotation enforcement). Enabling all four without tuning will produce hundreds of violations on a legacy codebase, so the approach is incremental: enable per-file, fix by category, then make it global.

Security and Anti-Pattern Rules Worth Enabling

The S category is a direct port of bandit rules. S101 flags assert in non-test code — asserts are stripped by the Python optimizer when running with -O, so using them for input validation is a silent security hole. S106 catches hardcoded passwords. S311 flags use of random module for security-sensitive operations instead of secrets.

These aren’t style preferences — they’re bug classes. Running Ruff with S-rules enabled on a codebase that’s never seen bandit will surface real issues, not hypothetical ones. As experienced developers know, the first pass of bandit on a “clean” Django project almost always finds at least one S-category violation in test utilities that somehow ended up in production code.

The pyproject.toml Snippet You Can Copy Right Now

[tool.ruff]
line-length = 88
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "B", "S", "UP", "ANN"]
ignore = [
 "ANN101", # missing type annotation for self
 "ANN102", # missing type annotation for cls
 "S101", # allow assert in test files (see per-file-ignores)
]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "ANN"]
"scripts/*.py" = ["S", "ANN"]

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

The per-file-ignores block is load-bearing. Test files legitimately use assert — pytest depends on it. Suppressing S101 globally would hide the issue in application code; suppressing it only in tests/ keeps the check where it matters. ANN enforcement in test files produces noise without safety benefit, so that goes too.

Distroless Docker + uv: The Production Deploy Pattern

Python Docker images have a reputation for being large and vulnerable. The default python:3.11 base image is 900+ MB and includes gcc, curl, and a shell — none of which belong in a runtime container. A multi-stage build with uv in the builder stage and a distroless image as the final stage cuts the runtime footprint below 100 MB and eliminates the attack surface that comes with a full Linux userland.

Why Distroless Matters for Security and Size

Google’s distroless images contain only the application and its runtime dependencies — no package manager, no shell, no coreutils. The practical implication: even if an attacker achieves code execution inside a distroless container, they can’t easily enumerate the system, download tools, or execute shell commands. There’s no /bin/sh. This doesn’t make the container invulnerable, but it meaningfully raises the cost of post-exploitation.

The tradeoff is debuggability. You can’t docker exec -it container /bin/bash into a distroless container — there’s nothing to exec. Teams that adopt distroless usually pair it with structured logging and distributed tracing instead of interactive debugging. That’s the right architectural direction anyway for containerized services; distroless just enforces it.

The Exact Dockerfile — Builder Stage with uv

FROM python:3.11-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app
COPY pyproject.toml uv.lock ./

RUN uv sync --frozen --no-dev --no-editable

COPY src/ ./src/

FROM gcr.io/distroless/python3-debian12 AS runtime

COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONPATH="/app/src"

USER nonroot
ENTRYPOINT ["python", "-m", "myservice"]

The --frozen flag tells uv to use the lockfile exactly — no resolution, no updates. This is critical for reproducible builds: the same uv.lock produces byte-for-byte identical virtualenvs across environments. The USER nonroot line uses distroless nonroot user (UID 65532) — running as root inside a container is a compliance failure in most enterprise security audits, and it’s trivially avoidable.

Technical Reference
Python 3.14.4 JIT

Python 3.14.4 JIT: When It Actually Helps and When You're Wasting Your Time Every major Python release comes with a round of "we're finally fast" blog posts. Python 3.14.4 is different — the Copy-and-Patch JIT...

Pyright Strict Mode: Fixing the Red Wave

Enabling Pyright strict mode on an existing codebase produces a wall of errors. Most of them fall into three categories: missing return type annotations, untyped third-party libraries, and improperly typed decorators. The first category is mechanical — a script or IDE action can add annotations. The second requires stub packages or inline ignores. The third is the one that trips people up, because the fix requires understanding ParamSpec and Concatenate.

Typing Decorators Without Using Any

The common mistake is typing a decorator’s wrapper function as Callable[..., Any] — which silences Pyright but defeats the purpose of strict mode. The correct approach uses ParamSpec to preserve the wrapped function’s signature, so callers still get full type checking and autocomplete on the decorated function.

from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def retry(times: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
 def decorator(fn: Callable[P, R]) -> Callable[P, R]:
 @wraps(fn)
 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
 for attempt in range(times):
 try:
 return fn(*args, **kwargs)
 except Exception:
 if attempt == times - 1:
 raise
 raise RuntimeError("unreachable")
 return wrapper
 return decorator

With ParamSpec, Pyright knows that @retry(3) applied to a function def fetch(url: str) -> dict produces another function with the exact same signature. Without it, you get a generic callable that erases parameter types — type narrowing stops working downstream, and the strict mode investment loses most of its value.

Dealing with Untyped Third-Party Libraries

Pyright strict mode fails on imports from libraries without a py.typed marker or bundled stubs. The right fix depends on whether stub packages exist. For boto3, requests, redis — stub packages exist on PyPI (types-boto3, types-requests, types-redis). Install them as dev dependencies and Pyright picks them up automatically.

For libraries without stubs, the options are: write your own stubs in a stubs/ directory and point pyrightconfig.json at it with "stubPath": "stubs", or suppress with # type: ignore[import-untyped] — with the justification comment, not a bare ignore. Bare # type: ignore is a code smell in strict codebases; it hides the reason and makes future audits harder.

Zero-Dependency Toolchain for Python Microservices

The endgame of the modern Python toolchain isn’t just replacing old tools — it’s reducing the number of moving parts that can break. uv as a single binary covers environment creation, dependency installation, lockfile management, and tool running. Ruff covers linting and formatting. Pyright covers types. Three tools, all fast, all configurable through pyproject.toml as a single source of truth. For a microservice that needs to boot quickly in CI and deploy reliably, this is the architecture that makes sense in 2026.

One Binary to Rule Them All — uv as the Single Entry Point

uv can run tools without a global install via uv tool run — a direct replacement for pipx. Running uv tool run ruff check . downloads Ruff into a cached tool environment, runs it, and exits. No virtualenv pollution, no global package conflicts. The same pattern works for Pyright: uv tool run pyright src/. In CI, this means the only installed tool is uv itself — everything else is pulled on demand and cached by uv’s content-addressed store.

GitHub Actions cache integration is straightforward: cache ~/.cache/uv keyed on uv.lock hash. Cold CI runs install uv in under 3 seconds; warm runs skip network entirely. Combined with uv sync --frozen, the full dependency install step on a cached runner typically completes in 4–8 seconds for a medium-sized service.

The Minimal pyproject.toml Template for a Microservice

[project]
name = "my-microservice"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
 "fastapi>=0.110",
 "uvicorn[standard]>=0.29",
]

[project.scripts]
serve = "myservice.main:app"

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

[dependency-groups]
dev = [
 "pytest>=8.0",
 "pytest-asyncio>=0.23",
 "ruff>=0.4",
 "pyright>=1.1.350",
 "httpx>=0.27", # for TestClient
]

[tool.pyright]
pythonVersion = "3.11"
typeCheckingMode = "strict"
venvPath = "."
venv = ".venv"

No setup.py, no setup.cfg, no requirements.txt. The entire project configuration lives in one file. uv lock generates uv.lock from this; uv sync installs everything. Hatchling as the build backend is lightweight and PEP 517-compliant — it replaces setuptools without the historical baggage. This is what a zero-dependency toolchain for Python microservices actually looks like when you stop accumulating config files.

Worth Reading
Python Pitfalls: 10 Anti-Patterns

10 Python Pitfalls That Scream You Are a Junior Developer Writing Python code is remarkably easy to start with, but mastering the language requires dodging subtle pitfalls that hide beneath its simple syntax. Many developers...

FAQ

Is uv stable enough for production use in 2026?

uv hit 1.0 in late 2024 and has been adopted by major open-source projects including Ruff itself, Pydantic, and several FastAPI ecosystem libraries. The lockfile format (uv.lock) is stable and TOML-based. The resolver follows PEP 440 version specifiers and PEP 508 dependency syntax — standard semantics, not uv-specific. For production use, the main consideration is team familiarity and whether your CI infrastructure can cache uv’s dependency store efficiently. Both are solved problems with documented patterns.

Can Ruff fully replace flake8 plugins like flake8-bugbear or flake8-bandit?

Ruff has ported the most widely used flake8 plugins natively: bugbear rules are in the B category, bandit rules are in the S category, pydocstyle is in the D category, and comprehension rules from flake8-comprehensions are in C4. The porting is not always 1:1 — some edge-case rules from niche plugins aren’t covered. If you rely on a specific flake8 plugin for a domain-specific check, verify coverage in Ruff’s rule documentation before migrating. For the vast majority of codebases running standard flake8 + bugbear + bandit, Ruff covers it completely.

How does Python modern toolchain 2026 handle virtual environment isolation compared to Poetry?

uv creates a standard .venv directory using Python’s built-in venv module. There’s no proprietary environment format — it’s the same virtualenv that pip and any other tool expects. Poetry uses the same mechanism but adds a global cache and project-hash-based env naming that can make environment location non-obvious. With uv, the environment is always at .venv relative to the project root unless you configure otherwise. This predictability matters in Docker builds and editor integrations: Pyright, Pylance, and VS Code all find the environment without extra configuration.

What’s the right strategy for incrementally adopting Pyright strict mode on a large existing codebase?

Don’t enable strict globally on day one. Pyright supports per-file configuration through pyrightconfig.json with an exclude list, or inline via # pyright: basic at the top of a module. The practical approach: enable strict on new modules immediately, add a CI check that prevents # pyright: basic from appearing in new files, and work through legacy modules by category — start with reportMissingTypeArgument (mechanical, automatable) and leave reportUnknownMemberType for last (requires stub work). A codebase of 50 000 lines can typically reach full strict compliance in 4–6 focused sprints.

Does the distroless Python image support all packages with C extensions?

Google’s gcr.io/distroless/python3-debian12 includes the Python runtime and essential shared libraries (glibc, libssl) but not a full build toolchain. C extension packages must be compiled in the builder stage and copied into the final image as part of the virtualenv — which is exactly what the multi-stage Dockerfile pattern does. The key requirement is that the builder stage and the distroless base share the same Debian version and Python minor version. Mixing python:3.11-slim (Debian 12) with distroless python3-debian11 will produce silent runtime failures when shared library versions don’t match.

When should you use type: ignore versus creating proper stubs for untyped libraries?

The decision comes down to how much of the library’s API surface your code uses. If you call one or two functions from an untyped library, # type: ignore[import-untyped] at the import line is pragmatic — stubs for a handful of call sites are over-engineering. If you’re making the library a central part of your architecture and calling into 20+ functions with complex return types, stubs pay off: you get autocomplete, refactoring support, and Pyright catches misuse automatically. Always include the error code in the ignore comment ([import-untyped], not bare [ignore]) — this makes future audits grep-able and distinguishes intentional suppressions from lazy shortcuts.

Written by:

Source Category: Python Pitfalls