The myth that pip install -r requirements.txt creates a reproducible environment is the single most common cause of "it works on my machine" bugs in data engineering pipelines.

Why this matters: In regulated data environments, non-deterministic builds are a compliance risk. If you cannot guarantee exactly which sub-dependency versions were installed in your production container, you cannot guarantee the integrity of your data processing logic.

The problem with loose requirements

When you define dependencies in a requirements.txt file using loose versioning (e.g., pandas>=2.0.0), pip resolves the environment at build time. If you rebuild that same image two weeks later, pip may fetch newer versions of sub-dependencies, resulting in a different runtime environment than the one you tested. To achieve true reproducibility, you need a "lock file"—a static snapshot of the entire dependency graph, including hashes for security.

a computer screen with a bunch of text on it
Photo by FONG on Unsplash

Setup

We will compare a standard, non-deterministic requirements.txt approach with a locked environment using uv (a modern, high-performance Python package manager).

Ensure you have Docker installed. We will use python:3.11-slim as our base image.

Create a directory with the following files:

requirements.in

pandas>=2.0.0 requests 

Dockerfile.loose

FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt 

The experiment

In this experiment, we will generate a lock file and compare the build behavior.

  1. Initialize the locked environment: Using uv, we generate a requirements.txt that is fully pinned.
 # Install uv first pip install uv # Generate the lock file uv pip compile requirements.in -o requirements.txt 
  1. Examine the output:
    Open the generated requirements.txt. You will notice it includes every sub-dependency of pandas and requests, each pinned to an exact version with a corresponding hash.

  2. Build the hardened image:
    We use a multi-stage Dockerfile to ensure our final image is minimal and secure.

Dockerfile

# Stage 1: Build dependencies FROM python:3.11-slim AS builder WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends gcc python3-dev && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --user --no-cache-dir -r requirements.txt # Stage 2: Final runtime FROM python:3.11-slim # Create a non-root user for security RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app # Copy only the installed packages from builder COPY --from=builder /root/.local /home/appuser/.local ENV PATH=/home/appuser/.local/bin:$PATH USER appuser # Add a healthcheck HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import pandas; print('ok')" || exit 1 

What to measure

You are looking for two specific outcomes:

  1. Determinism: If you run docker build today and again in six months, the layer containing your dependencies should remain identical because the requirements.txt file has not changed its hash-verified contents.
  2. Security: Because you are using fixed versions and hashes, you have eliminated the risk of a malicious actor publishing a compromised "new" version of a sub-dependency that your loose requirement would have automatically pulled in.

Reproduce it

Run the following commands to confirm the build process:

# 1. Prepare requirements pip install uv uv pip compile requirements.in -o requirements.txt # 2. Build the image docker build -t my-hardened-app:latest . # 3. Verify the image layers docker history my-hardened-app:latest # 4. Run the container as non-root docker run --rm --read-only --tmpfs /tmp --tmpfs /home/appuser/.local/lib/python3.11/site-packages/my_app my-hardened-app:latest 

Why this is the standard

By using uv pip compile (or pip-compile from pip-tools), you move the "resolution" step outside of your Docker build. The Dockerfile now only performs "installation."

If you do not lock your files, you are effectively letting the pip resolver perform a network call to PyPI to determine the latest available versions every time you build. This introduces a non-deterministic variable into your pipeline. In regulated industries, where "reproducibility" is often a requirement for audit trails, this is unacceptable.

Security hardening notes

  • Multi-stage builds: By separating the build environment (which needs compilers like gcc) from the runtime environment, you drastically reduce the attack surface of your final image.
  • Non-root users: The useradd command creates a dedicated user. Running as root inside a container is a vulnerability that should be avoided by default, especially in shared Kubernetes clusters.
  • Read-only filesystems: The --read-only flag in docker run ensures that your application cannot modify its own source code or install new packages at runtime, which is a critical defense-in-depth practice.

By treating your dependencies as a static, locked artifact, you align your Python environment management with the same rigor you apply to your infrastructure-as-code or database migrations. If the environment is not pinned, it is not production-ready.

Cover photo by Growtika on Unsplash.

This article was drafted with AI assistance and is intended for technical review and fact-checking before publication.