Python Docker images are often much larger than they need to be, which slows down builds, deployments, and local development.

Let’s see how a multi-stage build can drastically shrink a Python Docker image.

First, consider a simple Dockerfile for a Python application:

FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

If we build this with a requirements.txt containing requests and a dummy app.py, the image size might be around 150MB. This includes the entire Python interpreter, all installed packages, and potentially build tools that were used but are no longer necessary.

Now, let’s introduce a multi-stage build. The idea is to use one "stage" (a FROM instruction) to build or prepare our application, and then copy only the necessary artifacts into a final, minimal "runtime" stage.

# Stage 1: Builder
FROM python:3.11 as builder

WORKDIR /app
COPY requirements.txt .
# Install build dependencies if any, and then application dependencies
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Final runtime image
FROM python:3.11-slim

WORKDIR /app
# Copy only the installed packages from the builder stage
COPY --from=builder /install /usr/local/lib/python3.11/site-packages/
COPY . .
CMD ["python", "app.py"]

When we build this multi-stage Dockerfile, the builder stage installs the Python packages into a specific directory (/install). The second stage, python:3.11-slim, starts fresh with a minimal Python environment. Crucially, it then copies only the contents of /install from the builder stage into its own site-packages directory. The intermediate build tools and potentially larger base image from the builder stage are discarded.

The result? The final image size can often be reduced by 50-80%. For our example, it might shrink to under 70MB. This is because the final image doesn’t contain the build environment, only the runtime Python interpreter and the installed application dependencies.

The key to multi-stage builds is the COPY --from=<stage_name> instruction. You can refer to previous stages by name (using AS <name> in the FROM instruction) or by their index (e.g., COPY --from=0). This allows you to move specific files or directories between stages.

Here’s how the internal mechanics work: Docker builds each FROM instruction as a separate image layer. When you use COPY --from, Docker essentially takes the specified layer(s) from the source image and adds them as a new layer in the destination image. However, only the final image resulting from the last FROM instruction is retained and pushed to a registry. Intermediate stages are ephemeral and not stored persistently unless you explicitly tag them.

This pattern is incredibly powerful for compiled languages (like Go or Java, where you build an executable and then copy only that executable into a tiny scratch or alpine image) but also highly effective for Python. Instead of installing packages directly into the final image, you install them into a temporary build environment and then selectively copy the installed packages into your lean runtime image.

A common pitfall is forgetting to copy all necessary components. While we copied the site-packages here, you might also need to copy compiled extensions or other application-specific artifacts generated during the build phase. Always inspect the contents of your builder stage and ensure you’re copying everything required for the application to run.

The next step after optimizing image size is often to look into how these images are built and run, which leads to concepts like build caching and container orchestration.

Want structured learning?

Take the full Python course →