How-tos
Python in Docker: What Actually Changes When Your Code Runs in a Container
Understand the key differences between running Python on bare metal versus inside a Docker container, including layer caching, signal handling, logging buffers, and multiprocessing pitfalls. Practical patterns for production-ready Python Docker images.
June 2026 · 9 min read · 2 views · 0 hearts
Advertisement
Python doesn't care where it runs. The same interpreted bytecode executes just as happily on bare metal as inside a Docker container. But the experience of running Python in Docker versus directly on your host machine is radically different — and if you don't understand the key differences, you'll waste hours debugging things that shouldn't be issues.
Here's what actually changes when Python lives inside a container.
The Layer Cake: It's Not "Your Environment" Anymore
When you pip install something locally, it's gone forever when you nuke your virtualenv. Inside Docker, that pip install happens during the image build step, and the result is frozen into a layer. Once you run a container, you cannot modify the underlying image layers — any files you change disappear when the container stops (unless you use volumes).
This means:
- Your Python code updates faster: Rebuilding only the layers that changed. If you add a dependency, only that layer (and everything after) rebuilds.
- No "works on my machine": The
python:3.12-slimimage is identical on every developer's laptop and in production. - But debugging gets trickier: If your container crashes on import, you can't just open a REPL inside the running process — you need to exec into the container or add debug code.
Python's Global Interpreter Lock Doesn't Care About Containers
A common misconception: "Docker gives me isolation, so maybe Python's GIL works differently?" No. The GIL is a CPython implementation detail, not a container feature.
Each container gets its own OS-level process space, but inside that container, Python still runs with one GIL-bound thread per process. If you run multiprocessing, each child process gets its own Python interpreter and its own GIL — just like on bare metal.
What does change: CPU affinity and cgroups. Docker limits CPU cores using Linux control groups. Your Python multiprocessing.Pool might naively spawn as many workers as os.cpu_count() returns — but Docker may only allocate 2 cores while the host has 16. Hard-code your worker counts, or at least respect environment variables.
Real Python Developers Do This
1. Use .dockerignore like your life depends on it
A full Python project typically includes __pycache__, .venv, node_modules (yes, sometimes), and .git. If you don't ignore them, Docker sends all that junk to the daemon every build. Multiply that by a team of five and you've got minutes of wasted bandwidth per day.
__pycache__/
*.pyc
.venv/
.git/
*.egg-info/
2. Layer optimization is actual science
# BAD — rebuilds entire image on any code change
COPY . /app
RUN pip install -r requirements.txt
# GOOD — uses Docker cache
COPY requirements.txt /app/
RUN pip install -r requirements.txt
COPY . /app
The second version only re-runs pip install when requirements.txt changes — not when you fix a typo in views.py.
3. Base images matter more than you think
python:3.12 is 900MB+ because it includes build tools and documentation. python:3.12-slim is ~130MB — no compilers, but still has apt. python:3.12-alpine is ~50MB but uses musl libc instead of glibc, which can break packages like psycopg2 or numpy (though most now provide wheels for Alpine).
If you're shipping to production, stick with slim. Alpine savings often get eaten by debugging time when a C extension won't compile.
Logging Changes Completely
On your local machine, Python's print() goes to stdout. That works. Inside Docker, stdout is captured by the container runtime and forwarded to the Docker daemon. But here's the trap: Python's output is line-buffered when connected to a terminal, but fully buffered when not.
In Docker, stdout is a pipe, not a terminal — so Python buffers your prints. If your container crashes, those last ten log messages vanish into the void.
Fix it: Set PYTHONUNBUFFERED=1 in your Dockerfile or run with python -u. Or use a logging library that flushes appropriately.
ENV PYTHONUNBUFFERED=1
Do this in every single Python Docker image you build. Future you will thank present you.
Signal Handling Is Weird
When Docker sends a SIGTERM to your container, it goes to the main process (PID 1). If your Python app uses subprocess.Popen or multiprocessing.Process, those are child processes. They don't get the signal unless you explicitly forward it.
This manifests as: you docker stop the container, it waits 10 seconds, then Docker sends SIGKILL. Your workers never got the chance to finish the database transaction.
Solution: Use a simple init system like tini (Docker includes it as --init) or handle signals properly in your Python code.
import signal
import sys
def shutdown(signum, frame):
# Tell workers to stop gracefully
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
Or just add --init to your docker run command. It's the easiest win.
Health Checks Keep You Sane
Python apps crash silently. A container restart policy only helps if Docker knows the process is unhealthy. Python processes don't announce "I'm in an infinite loop" — they just consume CPU.
Add a health check endpoint to your app, then tell Docker about it:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
Or install curl into your image. But be honest with yourself: you're probably going to remember the health check after something breaks in production.
The Unspoken Rule: One Process Per Container
Docker convention says one process per container. Python developers often want to run Celery, Gunicorn, and a scheduler in one container. Don't. Use docker-compose with three services. It's more files, but it means:
- You can scale workers independently
- A memory leak in the scheduler doesn't kill your web server
- Logs from each service are separate
If you must run more than one Python process in a container, use supervisord or tini as your PID 1 and forward signals properly. But you'll regret it.
The Bottom Line
Running Python in Docker is the same Python you know — same classes, same GIL, same gotchas. But the deployment context changes everything: how you build, how you log, how you handle signals, and how you manage dependencies.
Master these patterns and your containers will be fast, debuggable, and production-ready. Ignore them and you'll be the person asking "why does my container take 10 seconds to start?" on Stack Overflow at 2 AM.
Don't be that person.
Advertisement
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.