Python #
Python is famous as a language that’s “tricky to containerize”. Unlike Go, which produces static binaries, or Java, which has a clear JRE, Python images often bloat due to the combination of interpreter, dependencies, and native extensions. A simple Django image can end up at 500-800 MB, even when its logic is just a CRUD API.
But this isn’t Python’s fate. Slim, production-grade Python images are very possible — just like Go and Java. What makes the difference is build boundary discipline: strictly separating what’s needed to compile dependencies from what’s needed at runtime. This article discusses in detail, realistically, and engineering-driven how to build slim, secure, production-grade Docker images for Python (Django, Flask, FastAPI).
1. The Reality of Python Image Sizes #
Let’s look at realistic production numbers, from the most problematic to the optimal.
| Setup | Image Size |
|---|---|
python:latest + pip install (everything) | 600-900 MB |
python:slim without cleanup | 350-500 MB |
python:slim + multi-stage + cleanup | 180-250 MB |
python:slim + multi-stage + distroless | 90-140 MB |
| PyInstaller bundled + distroless | 50-80 MB |
Insight: The difference between 800 MB and 50 MB is one order of magnitude. If your Python image is > 300 MB, dependencies or OS packages are almost certainly leaking into runtime.
Audit method:
docker history myapp:latest
Look at which layer is the biggest. Usually the culprit is pip install without multi-stage, or an apt-get install whose caches weren’t cleaned.
2. Why Python Images Bloat Easily #
Python has several characteristics that make its images prone to bloating when the Dockerfile isn’t disciplined.
Python Doesn’t Separate Build vs Runtime #
Many Python packages:
- Need compilers to install from source (e.g.
gcc,make,g++). - Need OS headers to link against native libraries (e.g.
libpq-dev,libssl-dev,libxml2-dev). - Compile-on-install via
pip installwhen no wheel is available for the platform.
If these dependencies are installed in the same stage as runtime, everything ends up in the final image. Compilers and header files needed for the build aren’t needed at runtime — but they get carried along.
C-Extension Dependencies #
Several Python packages common in production have C extensions:
psycopg2/psycopg2-binary— PostgreSQL driver.Pillow— image processing.cryptography— cryptography.lxml— XML parsing.numpy/pandas— scientific computing (often).
These packages need native libraries at install time, and sometimes at runtime too. Handled carelessly, their libraries and headers pile up in the image.
pip Doesn’t Prune Automatically #
Unlike npm, which has separate devDependencies and dependencies, pip has a looser concept. There’s no automatic requirements.txt separating dev vs prod — you must manage it manually.
Plus, pip stores download caches in the image (/root/.cache/pip/ or /tmp/pip-*). These caches can be tens of MB and aren’t removed automatically.
The Full python:* Default Images
#
The python:latest image (or python:3.12) carries a full Debian with many OS utilities. For production, almost nobody needs all of that. python:slim is a slimmer alternative, and python:alpine is even smaller (with the musl libc trade-off).
3. The Main Principle: Build Tools Die at Runtime #
An ideal Python runtime image contains only: the Python interpreter + a virtualenv (or site-packages) + runtime dependencies + application source code.
Full stop. No compilers, no OS header files, no pip cache, no test runners (unless used in production), no package documentation.
Contrast with the “good enough to run” image:
// ✗ ANTI-PATTERN: everything in one stage
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "manage.py runserver"]
Images like this have pip (a tool runtime doesn’t need), pip caches (temporary files), and all compiled packages (including compilers possibly installed automatically). Size bloats unnecessarily.
4. Multi-Stage Strategy with Virtualenv #
The most common and most recommended pattern for Python: multi-stage build with a virtualenv in the build stage, then copy the virtualenv into a slim runtime stage.
4.1 The Basic Pattern (Slim Runtime) #
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Set up the virtualenv
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Runtime stage
FROM python:3.12-slim
WORKDIR /app
# Copy the virtualenv from the build stage
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy source code
COPY . .
# Non-root user
RUN useradd --create-home --shell /bin/bash app
USER app
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
Typical size: 180-250 MB.
Key explanations:
- The build stage has
build-essentialandlibpq-dev(PostgreSQL headers). These are needed to compilepsycopg2and other packages with C extensions. - The virtualenv at
/opt/venv— all Python packages are isolated here, separate from the system Python. This is what gets copied to the runtime stage. pipcache removed with--no-cache-dironpip install. Without this flag,/root/.cache/pip/adds tens of MB to the image.- Apt cache removed in the same layer as the installation (
rm -rf /var/lib/apt/lists/*). - The runtime stage has no
build-essentialorlibpq-dev. It only has the Python interpreter, the virtualenv with installed Python packages, and the source code. - Non-root user for security.
When to use: The default for production Python services. The size vs debugging capability trade-off is still balanced — slim has a shell and package manager.
4.2 Distroless Runtime — Mature Production #
For even smaller images, use distroless in the runtime stage. Distroless only carries glibc, ca-certificates, and Python runtime essentials.
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Runtime stage — distroless Python
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
EXPOSE 8000
USER nonroot:nonroot
CMD ["/opt/venv/bin/gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
Typical size: 90-140 MB.
Important notes:
gcr.io/distroless/python3-debian12:nonrootalready includes the Python interpreter +pip/setuptools+ca-certificates.- The
:nonroottag means the image is already configured for thenonrootuser (UID 65532). Thenonrootimage hasWORKDIRat/home/nonroot. - The
/opt/venvpath must be absolute, andCMDmust also use absolute paths because distroless has no shell to resolvePATH. - No shell — interactive debugging isn’t possible. Invest in observability.
- No apt — OS packages forgotten in the build stage will be missing.
When to use: High-maturity production, security-first. Observability must be solid.
5. Separating Production and Dev Dependencies #
Python doesn’t automatically separate dev vs prod dependencies. You must manage it explicitly.
Pattern 1: Two Requirements Files #
requirements/
├── base.txt # Dependencies needed at runtime
├── prod.txt # base + production-only
└── dev.txt # base + prod + development
# requirements/base.txt
django==5.0.6
gunicorn==22.0.0
psycopg[binary]==3.1.18
# requirements/prod.txt
-r base.txt
gunicorn==22.0.0
psycopg[binary]==3.1.18
# requirements/dev.txt
-r prod.txt
django-debug-toolbar==4.4.6
pytest==8.2.0
factory-boy==3.3.0
In the production Dockerfile:
COPY requirements/prod.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
Pattern 2: Multi-Stage with Separate Dev Tools #
FROM python:3.12-slim AS builder-base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Base: mandatory runtime dependencies
COPY requirements/base.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/base.txt
# Dev target: add dev tools
FROM builder-base AS dev
COPY requirements/dev.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/dev.txt
# Prod target: without dev tools
FROM builder-base AS prod
COPY requirements/prod.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/prod.txt
# Runtime stage
FROM python:3.12-slim
COPY --from=prod /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
Build:
docker build --target dev -t myapp:dev .
docker build --target prod -t myapp:prod .
6. C Extensions and Binary Wheels #
Python packages with C extensions (psycopg2, Pillow, cryptography) are often a source of Docker problems. There are two main approaches.
Use Binary Wheels #
A wheel is a pre-compiled distribution format for Python. Packages providing wheels install without a compiler in the build stage.
# requirements.txt
# Instead of psycopg2 (needs compiling), use psycopg[binary]
psycopg[binary]==3.1.18
cryptography==42.0.5
Pillow==10.3.0
For packages with a -binary suffix (e.g. psycopg2-binary), the wheel already includes the native library. For packages without a binary suffix, check PyPI for wheel availability on your target platform.
When wheels aren’t enough: If the package you need doesn’t provide a wheel for your target platform. The solution: still compile, but make sure the compiler doesn’t reach the runtime image.
Avoid *-dev Packages in Runtime
#
Packages with a -dev suffix in Debian/Ubuntu are header files for compilation. Examples: libpq-dev, libssl-dev, libxml2-dev.
At build:
RUN apt-get install -y libpq-dev # to compile psycopg
But at runtime, the needed package is usually the library only (without -dev):
# Runtime: just libpq5, without -dev
RUN apt-get install -y libpq5
Practical rules:
- Build stage: install
libpq-dev(headers + library). - Runtime stage: install
libpq5(library only) or copy from the build stage.
How to copy the library from the build stage:
# Build stage: install -dev
RUN apt-get install -y libpq-dev
# Runtime stage: copy the library from the build stage
COPY --from=builder /usr/lib/x86_64-linux-gnu/libpq.so* /usr/lib/x86_64-linux-gnu/
This is more advanced, but the result is a slimmer image because -dev packages are usually bigger than the library alone.
7. Dependency Audits #
Python projects often carry dependencies that aren’t actually used, or that only matter in development. Regular audits are important for keeping images slim.
How to check for unused dependencies:
# For Python projects
pip install pip-check
pip-check
Or manually:
# Check the imports present in the source code
grep -rh "^from \|^import " src/ | sort -u
# Compare with requirements.txt
diff <(cat requirements.txt | cut -d'=' -f1 | sort) \
<(grep -rh "^from \|^import " src/ | awk '{print $2}' | sort -u)
Audits to perform:
- Remove packages that are imported but unused.
- Remove packages imported only for type checking (unless in production).
- Remove
django-debug-toolbar,pytest,factory-boyfrom production images. - Check whether any library has a slimmer alternative.
8. Production-Grade Logging #
Production Python applications must log to STDOUT, not files. Logging configuration belongs in the application code, not the Dockerfile.
Example Django logging setup:
# settings.py
import os
import sys
import logging.config
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'json': {
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
'format': '%(asctime)s %(name)s %(levelname)s %(message)s'
},
},
'handlers': {
'stdout': {
'class': 'logging.StreamHandler',
'stream': sys.stdout,
'formatter': 'json',
},
},
'root': {
'handlers': ['stdout'],
'level': os.getenv('LOG_LEVEL', 'INFO'),
},
}
Important principles:
- Log to
sys.stdout(and errors tosys.stderr). - JSON format for production (easy for aggregators to parse).
- Level via env var, not hardcoded.
- No file logging — let the orchestrator collect logs from STDOUT.
9. Healthchecks and Signal Handling #
Healthchecks #
For Python web applications, healthchecks should be HTTP-based:
# Example Django view
from django.http import JsonResponse
from django.db import connection
def health(request):
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return JsonResponse({"status": "ok"})
except Exception:
return JsonResponse({"status": "error"}, status=503)
In the Dockerfile (for slim, not distroless):
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
Or use curl if present in the image (the python -c alternative is more portable).
Signal Handling #
Gunicorn (and uvicorn for ASGI) handle SIGTERM correctly by default. But make sure:
- Exec form in
CMD, not shell form. - Graceful timeout set adequately:
gunicorn --graceful-timeout 30. - Worker timeout fits the application’s needs.
CMD ["gunicorn", "config.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--timeout", "60", \
"--graceful-timeout", "30"]
10. Security Hardening #
Non-Root Users #
Mandatory for production:
# In the runtime stage
RUN groupadd -r app && useradd -r -g app -d /home/app -s /bin/bash app
USER app
A note on distroless: The gcr.io/distroless/python3-debian12:nonroot image already includes the nonroot user (UID 65532). Just add USER nonroot:nonroot.
No Secrets in Images #
SECRET_KEY, DATABASE_PASSWORD, API_KEY — all must be injected at runtime, never baked into the image.
# ✓ In settings.py
SECRET_KEY = os.environ['SECRET_KEY']
DATABASE_PASSWORD = os.environ['DB_PASSWORD']
Common anti-patterns:
# ✗ Hardcoded in settings.py
SECRET_KEY = "django-insecure-abc123..."
# ✗ Hardcoded in the Dockerfile (even worse)
ENV SECRET_KEY=django-insecure-abc123...
Vulnerability Scanning #
Integrate scanning into CI:
- name: Build
run: docker build -t myapp:${{ github.sha }} .
- name: Scan
run: trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:${{ github.sha }}
11. Anti-Patterns to Avoid #
✗ Single-Stage Builds #
// ✗ Compilers, pip, caches all in the final image
FROM python:3.12
RUN apt-get install -y build-essential libpq-dev
RUN pip install -r requirements.txt
Solution: Always use multi-stage.
✗ Not Removing the pip Cache #
// ✗ /root/.cache/pip adds tens of MB
RUN pip install -r requirements.txt
Solution: Use pip install --no-cache-dir.
✗ Copying the Whole Repo Unfiltered #
// ✗ .git, .env, venv/ end up in the image
COPY . /app
Solution: A strict .dockerignore, and explicit file copies (especially for requirements.txt, which must be cached separately from the source code).
✗ Using python:latest
#
// ✗ Non-deterministic builds, large images
FROM python:latest
Solution: Pin the tag: python:3.12.3-slim-bookworm.
✗ Not Using a Virtualenv #
// ✗ Site-packages mixed with the system Python
RUN pip install -r requirements.txt
Solution: Always use a virtualenv in the build stage, copy it to the runtime stage.
✗ Building with a C Compiler in the Runtime Image #
// ✗ The runtime image has gcc, make, header files
FROM python:3.12-slim
RUN apt-get install -y build-essential libpq-dev
RUN pip install -r requirements.txt
Solution: Multi-stage with the compiler in the build stage, slim runtime stage.
12. A Production-Grade Dockerfile Example #
Here’s a complete Dockerfile example combining all the best practices above:
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build ====
FROM python:3.12.3-slim-bookworm AS builder
WORKDIR /app
# Build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Set up the virtualenv
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PIP_NO_CACHE_DIR=1 \
PYTHONDONTWRITEBYTECODE=1
# Install dependencies (separate from source code for caching)
COPY requirements/prod.txt /tmp/requirements.txt
RUN pip install --upgrade pip \
&& pip install -r /tmp/requirements.txt
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
# Copy the virtualenv and source code
COPY --from=builder /opt/venv /opt/venv
COPY . /app
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
EXPOSE 8000
USER nonroot:nonroot
ENTRYPOINT ["/opt/venv/bin/gunicorn", \
"config.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--access-logfile", "-", \
"--error-logfile", "-"]
Important notes:
distroless/python3-debian12:nonrootalready includes the Python interpreter. No need forFROM python:3.12-slimat runtime.PYTHONDONTWRITEBYTECODE=1prevents Python from creating.pycfiles in the container.PYTHONUNBUFFERED=1ensures logs are flushed to STDOUT immediately (not buffered).gunicorn --access-logfile -means logs go to STDOUT.ENTRYPOINTin exec form — signals reach gunicorn directly.
Final size: ~100-150 MB for a Django API with standard dependencies.
13. When to Use Which Strategy #
| Condition | Choice | Reason |
|---|---|---|
| Standard production API | slim + multi-stage | Reasonable size, debugging capability |
| High-maturity production | distroless | Small size, solid observability |
| Apps with many native deps | slim + audit | Needs libraries, audit dependencies |
| Serverless (Lambda containers) | distroless or alpine | Cold-start time matters |
| CLI tools / scripts | alpine or slim | Needs tools for execution |
| Small microservices | distroless | Small image, minimal attack surface |
14. Python Dockerfile Review Checklist #
BASE IMAGE:
□ Explicit tag (python:3.12.3-slim-bookworm, not latest)
□ Runtime stage uses a slim image (slim, alpine, or distroless)
□ Not python:latest or python:3.12 (too large)
BUILD:
□ Multi-stage build (build stage vs runtime stage)
□ Virtualenv (python -m venv) in the build stage
□ pip install --no-cache-dir (remove cache)
□ apt-get caches removed (rm -rf /var/lib/apt/lists/*)
□ Build dependencies (build-essential, *-dev) only in the build stage
□ requirements.txt copied separately from source code (for caching)
RUNTIME:
□ USER nonroot
□ PYTHONUNBUFFERED=1
□ PYTHONDONTWRITEBYTECODE=1
□ Logs to STDOUT (not files)
□ Healthcheck (if a shell exists)
□ ENTRYPOINT/CMD in exec form
SIZE:
□ < 250 MB for slim runtime
□ < 150 MB for distroless runtime
□ docker history shows no oddly large layers
SECURITY:
□ No secrets in the image
□ Strict .dockerignore
□ Image scanned with trivy/grype
□ Non-root user
□ Base image up to date
DEPENDENCY:
□ Production vs dev dependencies separated
□ Regular dependency audits
□ Binary wheels used for native extensions
□ Runtime libraries (without -dev) in the runtime stage
Summary #
- Slim Python images are very possible — it’s not fate. What separates an 800 MB image from a 100 MB image is build boundary discipline.
- Size reality: 50-80 MB (bundled), 90-140 MB (distroless), 180-250 MB (slim multi-stage), 350-500 MB (slim without optimization), 600-900 MB (
python:latest— anti-pattern).- Multi-stage builds with a virtualenv are the foundation. The build stage has compilers and headers; the runtime stage only has the interpreter + virtualenv.
- Use binary wheels for C-extension packages (
psycopg[binary], notpsycopg2which needs compiling).- Separate dev vs prod dependencies —
requirements/prod.txtvsrequirements/dev.txt. Production images must not carry test runners and debug tools.- Remove caches —
pip install --no-cache-dirandrm -rf /var/lib/apt/lists/*in the same layer as the installation.- Distroless for mature production — smaller images, minimal attack surface. But observability must be solid because there’s no shell for debugging.
- Slim as the default —
python:3.12-slimis a good compromise between size and debugging capability. Fits most production services.- Explicit tags, not
latest—python:3.12.3-slim-bookworm. Build reproducibility matters.- Log to STDOUT, not files — logging configuration in the application code, not the Dockerfile.
- Strict
.dockerignore— keep__pycache__,.git,.env,venv/,*.pycout of the build context.- Slim images need good observability — structured logs, metrics endpoints, healthchecks, and signal handling. A small image + solid observability beats a large image + manual debugging.