How-tos

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.

August 2026 8 min read 13 views 0 hearts

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 alpine for the runtime stage
  • Using python:3.11-alpine directly with --no-cache-dir and no extra build tools
  • Experimenting with distroless images like gcr.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:

  1. [ ] Used the smallest feasible base image (slim or alpine)
  2. [ ] Implemented multi-stage build if any compiled packages exist
  3. [ ] Installed only production requirements (no dev tools)
  4. [ ] Combined all apt/pip installs into single RUN commands
  5. [ ] Purged apt lists, pip cache, and temp files in the same layer
  6. [ ] Added a thorough .dockerignore file
  7. [ ] Ran docker images and 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.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.