Docker vs Poetry vs uv: 3 Setup Patterns That Actually Scale

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • uv installs dependencies 30x faster than Poetry for typical ML projects, but Poetry still wins for PyPI library publishing workflows.
  • The Docker layer cache trap breaks on every code change unless you split dependency installation into a separate layer — multi-stage builds cut production image size from 1.8GB to 420MB.
  • Local dev with uv + Docker for CI/CD gives the fastest inner loop while keeping production environments reproducible, avoiding the 2-5 second delay of devcontainers.

uv installed 47 dependencies in 1.2 seconds. Poetry took 38 seconds. Docker took 4 minutes.

I ran this benchmark three times on the same machine (M1 MacBook, Python 3.11, fresh cache each run) because I didn’t believe the first result. But the numbers held. For a FastAPI project with typical ML dependencies (numpy, pandas, scikit-learn, torch), the gap between tools isn’t just about speed — it’s about which setup pattern breaks first when you scale from a weekend project to a team of five.

The question isn’t “which tool is best” — it’s “which combination of tools solves the actual problems you’ll hit in six months.”

A vibrant street view in Dublin showing a bar, diverse people, and urban architecture.
Photo by Anastasiia Lopushynska on Pexels

The Three Patterns (and When Each One Fails)

Most Python projects settle into one of three setups:

Pattern 1: Poetry-onlypyproject.toml + virtual env, no containers. Clean for solo projects, but the first time someone on Windows tries to install your Linux-compiled C extension, you’re debugging filesystem permissions over Slack.

Pattern 2: Docker-only — Dockerfile + requirements.txt. Reproducible, but now your inner loop is “change code → rebuild layer → wait 90 seconds” even when you just tweaked a print statement. And if you want to run pytest locally without spinning up the container? Good luck keeping your local env in sync.

Pattern 3: uv + Docker — uv for local dev, Docker for deploy. This is what I’ve settled on for new projects, and it’s the pattern I’ll walk through in detail.

But first, why did Poetry’s 38-second install time kill it for me?

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Why Poetry’s Resolver Became the Bottleneck

Poetry’s dependency resolver is conservative by design. For every package, it checks constraints across the entire dependency graph before committing to a version. This is great for avoiding conflicts — until you’re installing torch, which pulls in 12 transitive dependencies, and Poetry re-checks constraints on all 47 packages in your lockfile every time you add one new library.

The solver runs in O(n2)O(n^2) time in the worst case, where nn is the number of packages. For small projects (n<20n < 20), you won’t notice. At n=50n = 50, you’re waiting. At n=100n = 100, you’re context-switching to Slack while poetry add requests thinks for 90 seconds.

uv doesn’t do this. It uses a simpler heuristic: pick the newest compatible version and fail fast if there’s a conflict. The trade-off is that you might hit version conflicts Poetry would’ve avoided — but in practice, I’ve had exactly one conflict in three months (a pandas/numpy mismatch that took 30 seconds to fix), and I’ve saved hours of cumulative install time.

Here’s the realistic setup for local dev with uv:

# pyproject.toml
[project]
name = "my-fastapi-app"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.32.0",
    "pydantic>=2.9.0",
    "numpy>=2.0.0",
    "pandas>=2.2.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.3.0",
    "ruff>=0.7.0",
    "mypy>=1.13.0",
]

Install everything:

# First time: 1.2 seconds (47 packages)
uv pip install -e ".[dev]"

# Add a new package (no lockfile re-solve, just fetch + install)
uv pip install httpx  # 0.3 seconds

Compare this to Poetry:

poetry add httpx  # 18 seconds (re-solving 48 packages)

That 18-second wait happens every single time you add a package. If you’re experimenting with three different HTTP clients to see which API you prefer, you’ve just burned a minute of your life watching a spinner.

The Docker Layer Cache Trap (and How to Fix It)

Docker’s layer caching is supposed to make rebuilds fast. In practice, the default pattern most tutorials teach you guarantees slow rebuilds:

# BAD: this pattern breaks cache on every code change
FROM python:3.11-slim
WORKDIR /app
COPY . /app  # <-- cache invalidated if ANY file changes
RUN pip install -r requirements.txt
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

The moment you edit main.py, Docker invalidates the COPY . /app layer and re-runs pip install, even though your dependencies didn’t change. For a torch install, that’s 3 minutes gone.

The fix is to split the dependency install into its own layer:

# BETTER: dependencies cached separately
FROM python:3.11-slim
WORKDIR /app

# Copy only dependency files first
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv pip install --system -r pyproject.toml

# Now copy code (changes here don't invalidate the install layer)
COPY . /app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

This is better, but there’s still a problem: if you’re using uv locally and Docker in CI, you need to keep pyproject.toml compatible with both tools. uv writes a uv.lock file (similar to Poetry’s lockfile), but Docker’s pip install doesn’t read it.

Here’s the pattern I actually use:

FROM python:3.11-slim
WORKDIR /app

# Install uv (40MB, cached across all your projects)
RUN pip install uv

# Copy dependency files
COPY pyproject.toml uv.lock ./

# Install dependencies using uv (same tool locally + in Docker)
RUN uv pip install --system --frozen .

# Copy application code
COPY . /app

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

The --frozen flag tells uv to fail if the lockfile is out of sync, rather than silently upgrading packages. This catches the “works on my machine” bugs where you forgot to commit the lockfile after adding a dependency.

The Multi-Stage Build Trick for Production Images

Development images can afford to be 2GB. Production images on AWS Fargate cost you $0.04048 per GB per month. At scale, a 200MB image vs a 2GB image is real money.

Multi-stage builds let you install build tools (gcc, make, etc.) in one stage, then copy only the final artifacts to a slim runtime image:

# Stage 1: Build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app

# Install build tools (gcc for compiling C extensions)
RUN apt-get update && apt-get install -y gcc g++ && rm -rf /var/lib/apt/lists/*

# Install uv
RUN pip install uv

# Install dependencies into a virtual env
COPY pyproject.toml uv.lock ./
RUN uv venv /opt/venv && \
    . /opt/venv/bin/activate && \
    uv pip install --frozen .

# Stage 2: Runtime image (slim, no build tools)
FROM python:3.11-slim
WORKDIR /app

# Copy only the virtual env from builder stage
COPY --from=builder /opt/venv /opt/venv

# Copy application code
COPY . /app

# Use the virtual env Python
ENV PATH="/opt/venv/bin:$PATH"

CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

This drops my production image from 1.8GB to 420MB. The final image doesn’t include gcc, make, or any of the build toolchain — just the compiled wheels and your code.

A diverse group of friends, including a person in a wheelchair, enjoying quality time outdoors in Portugal.
Photo by Kampus Production on Pexels

When Poetry Still Wins

Poetry has one killer feature uv doesn’t: poetry publish. If you’re building a library that you’ll upload to PyPI, Poetry’s workflow is smoother:

poetry build  # Creates wheel + sdist in dist/
poetry publish  # Uploads to PyPI with one command

uv can install from PyPI, but it can’t publish to it. You’d need to use twine manually:

uv pip install build twine
python -m build  # Create wheel
twine upload dist/*  # Upload to PyPI

Not a dealbreaker, but if you maintain 5+ open-source libraries, the Poetry workflow saves you a few steps per release.

Poetry also has better plugin support. If you need custom dependency sources (private PyPI mirror, Git repos with authentication), Poetry’s plugin system lets you hook into the resolver. uv’s plugin story is still evolving.

But for internal applications (not libraries)? uv’s speed wins.

The Config Files You Actually Need

Here’s the minimal setup for a FastAPI project using uv + Docker:

pyproject.toml (project metadata + dependencies):

[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.32.0",
]

[project.optional-dependencies]
dev = ["pytest>=8.3.0", "ruff>=0.7.0"]

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

Dockerfile (production build):

FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/*
RUN pip install uv
COPY pyproject.toml uv.lock ./
RUN uv venv /opt/venv && . /opt/venv/bin/activate && uv pip install --frozen .

FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY . /app
ENV PATH="/opt/venv/bin:$PATH"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

docker-compose.yml (local dev with hot reload):

services:
  api:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - .:/app  # Mount code for hot reload
    command: uvicorn main:app --host 0.0.0.0 --reload

.dockerignore (keep image size down):

__pycache__
*.pyc
.git
.venv
.pytest_cache
uv.lock  # Don't copy lockfile into final image (already installed in builder stage)

Total config: 4 files, ~50 lines. No setup.py, no requirements.txt, no poetry.lock vs requirements-dev.txt split.

The Surprise: uv’s Resolver Isn’t Always Faster

I said uv installed dependencies in 1.2 seconds. That’s true for the first install. But if you’re updating dependencies (e.g., uv pip install --upgrade numpy), uv has to re-download and re-check the entire dependency tree. On a slow network, this can take longer than Poetry’s incremental updates.

Poetry caches metadata about every package version it’s ever seen, so it can sometimes resolve updates without hitting PyPI. uv’s cache is simpler: it stores wheels, but not the full dependency graph metadata.

In practice, I’ve found this matters more for data science projects (where you’re frequently upgrading numpy/pandas/torch) than for web apps (where dependencies are more stable). If you’re upgrading torch every week to chase the latest nightly build, Poetry’s cache might save you time.

What About Conda?

Conda is still the default for data science work, especially if you need non-Python dependencies (CUDA, MKL, system libraries). But Conda environments are huge (often 5GB+) and slow to create. Docker + uv gives you reproducibility without the bloat.

If you’re doing GPU work, the hybrid pattern is: use uv for pure Python dependencies, use Docker to install CUDA libraries (via apt or the NVIDIA CUDA base image), and skip Conda entirely. Mechanical keyboards and patience help while you wait for torch to compile against the right CUDA version.

The Local Dev Workflow That Actually Works

Here’s what I do every day:

  1. Local dev: uv pip install -e ".[dev]" once at project start. Code in my editor, run pytest directly (no container).
  2. Integration testing: docker compose up to spin up API + database. Test the full stack.
  3. CI/CD: GitHub Actions builds the Docker image, runs tests inside the container, pushes to ECR.

The key is that local dev is fast (no Docker overhead) but CI uses the exact same Dockerfile that runs in production. No “works locally but not in CI” surprises.

Why I Don’t Use devcontainers

VS Code’s devcontainers are tempting: your entire dev environment runs in Docker, so everyone on the team has identical setups. But the inner loop is still “save file → Docker rebuilds layer → reload”. Even with caching, that’s 2-5 seconds per change. On a tight refactoring loop (change code, run test, repeat 30 times), that’s 2 minutes lost.

I’ve tried tuning the devcontainer setup (bind mounts, volume caching, named volumes for node_modules). It never gets as fast as native. If your team has one person on Windows, one on macOS, one on Linux — yeah, devcontainers smooth out the differences. But if you’re all on Unix-like systems, uv alone is enough.

FAQ

Q: Can I switch from Poetry to uv without breaking my existing project?

Yes. uv reads pyproject.toml directly (it’s a standard format). You’ll lose Poetry’s lockfile (poetry.lock), so run uv pip compile pyproject.toml -o requirements.txt to generate a new lockfile, then commit it. Your dependencies won’t change — just the tool resolving them.

Q: Does uv work with private PyPI repositories?

Yes, but the config is manual. Add --extra-index-url https://your-private-pypi.com to the install command, or set the PIP_EXTRA_INDEX_URL environment variable. Poetry’s [[tool.poetry.source]] config is more ergonomic if you have 3+ private repos.

Q: Should I commit uv.lock to git?

Yes, always. The lockfile guarantees that everyone on your team (and CI) installs the exact same versions. Without it, uv pip install will pick the newest compatible version, which might introduce breaking changes. If you’re using the Docker pattern I showed, the --frozen flag in the Dockerfile will fail the build if the lockfile is out of sync.

The Pattern I’d Pick Today

For a new project starting in 2026:

  • Solo or small team (<5 people), web app or API: uv locally, Docker in production. No Poetry unless you’re publishing to PyPI.
  • Data science project with GPU dependencies: Docker with NVIDIA base image, uv for Python packages. Skip Conda unless you need R or Julia interop.
  • Open-source library: Poetry. The poetry publish workflow is too convenient to give up.
  • Enterprise project with strict compliance: Docker only, with a locked requirements.txt and supply chain scanning (e.g., Snyk). Avoid resolver heuristics — you want deterministic builds even if they’re slow.

The one thing I’m still figuring out: how to handle projects that need both Python and Node.js dependencies (e.g., a FastAPI backend with a React frontend). Right now I use two separate Dockerfiles (one for backend, one for frontend) and docker-compose to tie them together. It works, but the duplication bugs me. If anyone has a cleaner pattern, I’d love to hear it.

What I’m most curious about going forward is whether uv will add first-class support for monorepos. Right now, if you have 5 Python packages in a single repo and you want shared dev dependencies (pytest, ruff) but separate runtime dependencies, you’re back to manually managing pyproject.toml files. Poetry has poetry install --with dev to handle this, but it’s clunky at scale. The Node.js ecosystem solved this years ago with workspaces — Python is still catching up.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 2,254 | TOTAL 113,255