Optimizing Python Docker Image Size for Faster, Cheaper Deploys
A Python API image that weighs 1.2GB isn't just untidy — it pulls slowly on every cold start, inflates your registry bill, and stretches every CI run. The fix is rarely one trick; it's a stack of decisions about base image, build strategy, and layer ordering, and each one is independently shippable. This page is part of the Containerizing Python APIs with Docker guide, which sits inside the wider work of scaling and operating production Python APIs. It focuses on one question: how do you get a FastAPI image as small as it can reasonably be without breaking compatibility or wasting a weekend fighting musl libc?
The honest answer for most commercial APIs is short — python:3.x-slim plus a multi-stage build gets you to roughly 180MB — but the reasoning behind that answer is where the money is. Get the base image or the layer ordering wrong and you pay for it on every deploy, forever.
The base image decision
Most of your image weight is decided by the very first FROM line. Everything downstream — build speed, wheel compatibility, how easily you can debug a broken container at 2am — flows from that one choice. Here's how the common Python bases compare for a FastAPI service.
| Base image | Approx. size | Build speed | Compatibility gotchas |
|---|---|---|---|
python:3.11 | ~1 GB | Fast | Huge; ships compilers and headers you never run |
python:3.11-slim | ~180 MB | Fast | Debian glibc; add build deps only in a builder stage |
python:3.11-alpine | ~120 MB | Slow | musl libc: many wheels missing, so packages compile |
gcr.io/distroless/python3 | ~90 MB | Fast | No shell or package manager; harder to debug |
The headline number is tempting, but build speed and compatibility are where alpine quietly costs you. The chart below shows the compressed image size for a typical FastAPI app with a database driver and Pydantic — the gap between full and slim is enormous, while the gap between slim, alpine, and distroless is small enough that other factors should decide it.
The alpine trap: musl and the wheel problem
Alpine's small base is real, but it hides a compatibility cost that bites the moment your dependency tree includes a C extension — which, for a real API, it always does. PyPI ships prebuilt manylinux wheels that install in seconds on glibc systems like slim and the full base. Alpine uses musl libc, and most projects don't publish musl wheels, so pip falls back to compiling from source. That pulls in gcc, musl-dev, and a pile of headers, turning a 20-second install into a multi-minute one and partly erasing the size win you came for.
This is not a rare edge case. The Pydantic v2 core is a compiled Rust extension, asyncpg is compiled C, and cryptography, numpy, orjson, and uvloop all ship native code. Any one of them forces a source build on musl. The diagram below traces the same pip install asyncpg down both paths.
Before and after: a runnable multi-stage build
Start with the naive Dockerfile most people write first:
# BEFORE — ~1.1 GB
FROM python:3.11
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
It works, but it ships the full Debian toolchain, pip's download cache, the entire build context including .git, and uncompiled source. Now the optimized version, which is the one you should actually ship:
# AFTER — ~180 MB
# syntax=docker/dockerfile:1
FROM python:3.11-slim AS builder
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /build
COPY requirements.txt .
# Install into an isolated prefix we can copy out cleanly.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --prefix=/install -r requirements.txt
FROM python:3.11-slim AS runtime
RUN useradd --system --create-home app
COPY --from=builder /install /usr/local
WORKDIR /app
COPY --chown=app:app ./app ./app
USER app
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["sh", "-c", "exec gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
-w ${WEB_CONCURRENCY:-4} -b 0.0.0.0:${PORT:-8000}"]
The trick that moves the number most is the two-stage split. The builder stage does all the heavy work — pulling wheels, running any source compiles — and the runtime stage copies only the finished /install prefix. No compiler, no build-essential, no pip cache ever reaches the final image. The diagram shows what crosses the boundary and what stays behind.
A tight .dockerignore keeps .git, .venv, and test fixtures out of the build context, and the --mount=type=cache cache mount reuses downloaded wheels across builds so repeat CI runs stay fast. The WEB_CONCURRENCY and PORT values read from the environment rather than being baked in — matching the worker count to your vCPUs is its own decision, covered in Uvicorn vs Gunicorn worker configuration. Measure the result:
docker build -t api:optimized .
docker images api:optimized # confirm it lands near ~180MB
Where the megabytes actually go
To optimize with intent rather than superstition, you need to know what's inside that 1.1GB. Run docker history --no-trunc your-image:tag or a tool like dive and the same four culprits show up almost every time: the fat base OS, the build toolchain, pip's download cache, and the source context you accidentally copied in. The optimized image keeps only the base and the installed packages. The comparison below is drawn from a real FastAPI service with asyncpg and Pydantic.
The lesson is that two segments — the toolchain and the pip cache — are pure waste in production, and multi-stage plus --no-cache-dir deletes both. The base shrinks from full to slim, and the source segment collapses once .dockerignore stops you shipping .git. What's left is the irreducible core: the interpreter and your actual dependencies.
When to choose each base
Use python:3.11-slim (the default). For almost every FastAPI API, slim plus multi-stage is the right answer. You get glibc wheel compatibility, fast builds, easy debugging with a real shell, and a final image small enough that the next 100MB of savings isn't worth an engineer's afternoon.
Reach for alpine only when image size is a hard constraint and your dependency tree is pure-Python or has musl wheels available. Verify every compiled dependency before committing — otherwise you trade size for slow, fragile builds. Many teams that start on alpine migrate back to slim once a new dependency forces a source compile.
Use distroless when you want the smallest attack surface for a hardened deploy and you've already nailed your build. Distroless has no shell and no package manager, so you debug with a :debug variant or docker cp. Pair it with a slim builder that produces the prefix, then copy that into the distroless runtime. It's an excellent end state, not a starting point.
Migration path from a fat image
You don't have to do this all at once. A safe order that keeps every step independently shippable and reversible:
- Add
.dockerignore— instant context shrink, zero risk. - Switch the base to
-slim, addingbuild-essentialonly in a builder stage if anything compiles. - Split into multi-stage, copying the installed prefix into a clean runtime.
- Add a BuildKit cache mount to claw back build speed on repeat builds.
- Measure with
docker imagesanddocker history, then consider alpine or distroless only if you still need more.
Because each step stands alone, you can stop the moment the size is good enough — and for most builders that's after step three.
What image size costs you in production
The runtime CPU and memory of your container don't change with image size, but the pull time does, and on autoscaling or serverless platforms the image is pulled and extracted before the first request can be served. A 1.1GB image can add ten seconds of cold-start latency that your customer feels as a timeout; a 180MB image cuts that to a couple of seconds. If you run on a platform where cold starts are frequent — see Render vs Railway vs Fly.io for how each behaves — a lean image is the difference between graceful autoscale and a queue of stalled requests.
There's a registry cost too. Storing and transferring gigabyte images across dozens of deploys a day adds up on managed registries, and every CI pipeline pushes and pulls the full thing. Fold that into your unit economics the same way you would any fixed overhead — the method is in calculating cost per API request, and the margin it protects is what the API pricing tiers are built on. A lean image also makes zero-downtime deploys faster, because new replicas come up before the old ones drain.
Builder verdict
For the overwhelming majority of commercial Python APIs, the answer is python:3.x-slim with a multi-stage build, a tight .dockerignore, and a BuildKit cache mount. It gets you to roughly 150–200MB — small enough that cold starts and registry costs stop mattering — while keeping builds fast and debugging sane. Alpine's smaller number is a trap the moment a compiled dependency forces a source build, and distroless is worth it only after you've optimized and want to harden. Optimize for total operating cost — build minutes, cold-start latency, and the engineering hours you'd otherwise burn on musl edge cases — not for the smallest possible megabyte count.
FAQ
Is alpine always smaller than slim?
The base layer is smaller, but the final image often isn't. If pip has to compile C extensions from source on musl, it pulls in build-base and friends, and the toolchain plus compiled artifacts can erase the saving. For a FastAPI app with Pydantic and a database driver, a slim multi-stage image is frequently competitive with — and far faster to build than — alpine.
How much does a fat image actually cost me a month? It's mostly indirect: longer cold starts that hurt conversion, slower CI so you ship less often, and registry storage plus egress on every deploy. At a few deploys a day a gigabyte image can push tens of dollars of transfer and minutes of pipeline time per week. Dropping to 180MB roughly sixes that overhead and shortens cold starts from ten seconds to two — a direct win for both margin and customer experience.
How do I find what's taking up space in my image?
Run docker history --no-trunc your-image:tag to see per-layer sizes, or use dive. The usual culprits are the base image choice, leftover build dependencies, and pip's cache. Each has a clean fix: slim base, multi-stage copy, and --no-cache-dir or a cache mount.
Should I use --no-cache-dir or a BuildKit cache mount?
Both, for different reasons. --no-cache-dir (or copying out of an isolated prefix) keeps pip's download cache out of the final image so it stays small. A --mount=type=cache keeps that cache on the build host between builds so rebuilds are fast. They're complementary, not alternatives.
Is it worth migrating an existing service to distroless? Only if you've already done multi-stage on slim and still want a smaller attack surface for compliance or hardening. The migration risk is real — no shell means your existing debug and health-check tooling may break — so treat it as a deliberate hardening step, not a routine size optimization. Most teams get 90% of the benefit from slim and never need it.
Related
Same track:
- Containerizing Python APIs with Docker — the parent guide covering
.dockerignore, healthchecks, and the full build. - Caching Python API responses with Redis — the next lever once your image and cold starts are lean.
- asyncpg vs psycopg3 for FastAPI — a compiled driver that decides whether alpine is viable for you.
Other tracks:
- Render vs Railway vs Fly.io — how cold-start behavior differs by host, and why image size matters there.
- Zero-downtime deploys for Python APIs — a lean image makes rolling replicas come up faster.
- Calculating cost per API request — fold registry and build overhead into your real unit economics.