Optimize Python Docker Images for Size
Learn practical techniques to shrink Python Docker images from 500 MB to under 200 MB using base image selection, multi-stage builds, pip cache cleanup, and .dockerignore strategies.
Ever pushed a Python Docker image to a registry and watched it chug along at 500 MB, wondering where all that space went? You're not alone. The typical Python Docker image, if you just grab the default python:slim and install a few libraries, can balloon fast. At PythonSkillset, we've seen projects where a simple API service ends up being 700 MB. That's bad for deployment speed, storage costs, and CI/CD times.
Let's change that. I'll walk you through practical methods to shrink your images without cutting corners on reliability.
Why size matters (beyond storage)
A smaller image isn't just about saving disk space. It's about: - Faster deploys: Pushing 100 MB vs 500 MB means minutes saved on every release - Better cold starts: Especially in Kubernetes or serverless setups, a lightweight image starts in seconds - Lower bandwidth costs: If you're paying for data transfer, every megabyte counts - Reduced attack surface: Fewer packages means fewer vulnerabilities to patch
Start with the right base image
Your choice of base image is the biggest lever you can pull. Most developers grab python:3.11-slim (around 120 MB) or python:3.11 (around 330 MB). But we can go much smaller.
Best options (from smallest to largest):
| Image | Size | Use case |
|---|---|---|
python:3.11-alpine |
~45 MB | Minimal systems, shared libraries minimal |
python:3.11-slim |
~120 MB | Good balance, Debian-based with only essentials |
python:3.11-bullseye |
~330 MB | Full Debian, for complex system dependencies |
For most production apps at PythonSkillset, we go with slim. Alpine can save 75 MB vs slim, but you'll hit edge cases with compiled extensions (like numpy, pandas, or psycopg2) needing extra build steps.
Multi-stage builds are your secret weapon
This is the single most effective technique. Use one build stage to compile and install dependencies, then copy only what's needed into a clean final image.
# Stage 1: Builder
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy only requirements first (caching magic)
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app
# Copy user-installed packages from builder
COPY --from=builder /root/.local /root/.local
# Copy application code
COPY . .
# Make sure scripts in .local are in PATH
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
This turns a 500 MB image into about 140 MB. The builder stage includes gcc and all build tools, but the final image gets none of that bloat.
Keep requirements minimal and layered
Here's a trick we use at PythonSkillset for every Python Docker project: split requirements into base.txt, dev.txt, and prod.txt.
# base.txt (shared core)
fastapi==0.104.0
uvicorn==0.24.0
pydantic>=2.0.0
# prod.txt (includes base)
-r base.txt
redis==5.0.0
# dev.txt (includes prod, don't install in Docker)
-r prod.txt
pytest==7.4.0
black==23.11.0
Then in your Dockerfile, only install prod.txt. This alone can shave 50 MB off by skipping dev dependencies like testing frameworks.
Clean up at every layer
Docker caches layers. That's good for build speed, bad if you leave junk in intermediary layers. Every RUN command creates a permanent snapshot.
Bad:
RUN apt-get update && apt-get install -y gcc
RUN pip install -r requirements.txt
RUN apt-get remove -y gcc
The apt-get remove creates a new layer, but the gcc files still exist in previous layers. The image grows with each step.
Good:
RUN apt-get update && apt-get install -y gcc && \
pip install -r requirements.txt && \
apt-get remove -y gcc && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
Now everything happens in one layer. The final image has no trace of gcc or apt cache.
Remove pip cache manually
Pip caches downloaded wheels in /root/.cache/pip by default. Even with --no-cache-dir, some older installers still leave artifacts. Be explicit:
RUN pip install --no-cache-dir -r requirements.txt && \
rm -rf /root/.cache/pip
That can reclaim 10-30 MB depending on package count.
Use .dockerignore aggressively
You'd be surprised how many Docker images include __pycache__, .git, venv, or local test files. These aren't normally copied via .dockerignore, but if your COPY . . is in the final stage, they sneak in.
Create a minimal .dockerignore for each project:
__pycache__
*.pyc
*.pyo
.DS_Store
.git
.gitignore
.env
venv
.venv
tests/
*.md
Makefile
docker-compose*.yml
This prevents accidental inclusion of local environments and reduces the context sent to the Docker daemon.
Real-world example: Before and after
Here's a FastAPI application we optimized at PythonSkillset. Before any changes:
- Base image:
python:3.11(330 MB) - With dependencies: 475 MB
- With application code: 510 MB
After applying the above techniques:
- Base image:
python:3.11-slim(120 MB) - Multi-stage build + cleanup: 190 MB total
- With
.dockerignore: 185 MB
Total reduction: 64% smaller. That's 325 MB saved per deploy. For a team deploying 10 times daily, that's over 3 GB per day less bandwidth.
When you can go even smaller
If you're building microservices with zero compiled dependencies (pure Python like requests, httpx, simple Flask apps), consider:
- Using
alpinefor the runtime stage - Using
python:3.11-alpinedirectly with--no-cache-dirand no extra build tools - Experimenting with
distrolessimages likegcr.io/distroless/python3-debian11(around 50 MB base)
But test thoroughly. Alpine uses musl libc instead of glibc, which can cause subtle bugs with certain packages.
Final checklist before you push
Before building your next image, run through this list:
- [ ] Used the smallest feasible base image (
slimoralpine) - [ ] Implemented multi-stage build if any compiled packages exist
- [ ] Installed only production requirements (no dev tools)
- [ ] Combined all apt/pip installs into single RUN commands
- [ ] Purged apt lists, pip cache, and temp files in the same layer
- [ ] Added a thorough
.dockerignorefile - [ ] Ran
docker imagesand checked the final size
Smaller images mean faster pipelines, lower costs, and happier ops teams. And honestly, it's satisfying to see a Python API service weigh in under 200 MB.
Give these methods a try with your own project on PythonSkillset. You might be surprised at how much space you've been wasting without realizing it.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.