Python packaging spent a decade as the punchline of an otherwise excellent language. The state of things now is genuinely different, and the change is not cosmetic: dependency resolution that used to take minutes takes under a second, environments are disposable, and there is a lockfile that actually locks.
The practical win is not developer comfort — it is that your build becomes reproducible and your images get smaller, which shows up as cold start latency and CI minutes. Here is the setup I now use on every service, and the three places teams fail to collect the benefit.
One file, one lock, one tool
Declare dependencies in pyproject.toml, commit the lockfile, and let the tool own the virtualenv and the interpreter version. The important discipline is that nothing installs anything ad hoc: if a dependency is not in the project file, it does not exist. The lockfile records resolved versions and hashes for every platform you target, so CI, your laptop and production resolve identically rather than approximately.
uv init service-api && cd service-api
uv add fastapi 'uvicorn[standard]' asyncpg
uv add --dev pytest ruff mypy
# Reproduce exactly what the lockfile says, nothing more:
uv sync --frozen
# Run without activating anything
uv run pytest
uv run ruff check .
# Pin the interpreter alongside the dependencies
uv python pin 3.13Docker: cache the dependencies, not the source
The single highest-leverage change is splitting the dependency install from the application copy so that editing a source file does not invalidate the dependency layer. Most Python Dockerfiles I review get this wrong, and pay for it on every commit.
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# Dependencies first: this layer only rebuilds when the lockfile changes.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# Source last: the expensive layer above stays cached.
COPY src/ ./src/
RUN uv sync --frozen --no-dev
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER nobody
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0"]Two-stage plus a dependency-first copy took one of our API images from 1.1 GB to 240 MB and cut the average CI build from roughly four minutes to under one. That is not a micro-optimisation; on a service that autoscales, image size is pull time, and pull time is how long a new pod takes to absorb traffic.
The three places the benefit leaks away
- Unfrozen installs in CI. Without --frozen, CI can silently resolve something different from your lockfile, which is exactly the class of drift the lock exists to prevent.
- Dev dependencies in the runtime image. Test and lint tooling has no business in production; it is attack surface and megabytes.
- Compiling wheels at install time because a base image lacks a prebuilt match. Check that your platform gets binary wheels before you accept a slow build as normal.
A lockfile that CI is free to ignore is documentation, not a guarantee.
None of this is exciting work, and that is rather the point. Reproducible builds are the floor under everything else — you cannot debug a production-only failure with confidence if you cannot prove the environment matches. The tooling finally makes that floor cheap enough that there is no reason left to skip it.