◐ ai-teacher

🐍 Python for Data Engineers · Chapter 14

Packaging: venv, dependencies, Docker

"It works on my machine" is not a joke — it is a description of an unpinned dependency tree.

Study this lesson free in the app →

The basics

Packaging and Docker are how you turn "it works on my laptop" into a job that runs identically on a scheduler every night: pin exactly which software it uses, then ship that software sealed in a container image.

The problem it solves: a pipeline depends on a Python version, a dozen libraries and the libraries they depend on, system packages, timezone data. pip install pandas with no version resolves to whatever was published that morning. One night a rebuild pulls a new pyarrow, an argument disappears, and a job nobody touched crashes at 2 AM.

The mental model: a recipe, a lock file and a shipping container. The recipe (pyproject.toml) says what you want, loosely. The lock file records the exact versions you actually got, down to the last transitive dependency. The container image ships the whole kitchen — interpreter, libraries, OS — so dev, staging and prod run the same thing.

Words you must know 5

Lock file
exact pinned versions of the full dependency tree
Transitive dependency
something you never imported that your libraries pulled in (numpy under pandas)
Image layer
each Dockerfile step is a cached layer; changing one rebuilds it and everything after
Digest
the immutable sha256 of an image; tags like latest move, digests do not
Runtime injection
passing config and secrets as environment at run time, never baking them in

Explained

Your job ran perfectly for four months. Nobody touched the code. This morning it crashes on pd.read_parquet.

What changed? The build reinstalled pandas, which pulled a new minor version, which needed a newer pyarrow, which dropped the argument your code passes.

You did not change anything. The world under you did — because you never pinned it.

The recipe and the shipping container

A recipe says "flour, 200 g". Whose flour? Which mill? It usually works, until one day it doesn't and nobody can reproduce the failure.

A lock file says "flour, 200 g, brand X, batch 2026-07-14". Anyone, anywhere, gets the same result.

A container goes further: it ships the flour, the oven, the altitude and the tap water. Not just the ingredient list — the entire kitchen, sealed. The recipient does not need to own an oven or agree with you about which one is best.

That is why data jobs are shipped as containers: a pipeline depends on a Python version, a dozen libraries, a JVM for Spark, system codecs, timezone data and a locale. Listing those is hope. Shipping them is delivery.

From laptop to scheduled job

The step that fails silently is the third. Without a lock, every later step is running slightly different software from the one before it.

Declared vs locked — the distinction that matters

Declared dependencies are what you import, with the loosest constraint you can live with:

# pyproject.toml
[project]
name = "rideflow-pipeline"
requires-python = ">=3.12"
dependencies = [
  "pandas>=2.2,<3.0",
  "pyarrow>=16",
  "requests>=2.32",
]

Locked dependencies are the exact resolved tree — including things you never named:

pandas==2.2.3
pyarrow==17.0.0
numpy==2.1.1          # you never asked for this; pandas did
python-dateutil==2.9.0.post0
...

Commit both. The first says what you meant; the second guarantees what you got. Upgrading is then a deliberate act — regenerate the lock, run the tests, review the diff — rather than something that happens to you at 2 AM.

Modern toolchain: uv (fast, does venv + resolve + lock), or Poetry, or plain pip-tools. Any of them beats a hand-maintained requirements.txt.

A Dockerfile worth copying

# pin the digest, not just the tag — 'python:3.12-slim' moves
FROM python:3.12-slim@sha256:...

# 1. system deps first — changes rarely, so this layer stays cached
RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# 2. dependencies BEFORE source — the cache trick that matters
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock

# 3. source last — changes every commit, only this layer rebuilds
COPY src/ ./src/

# 4. don't run as root
RUN useradd --create-home runner
USER runner

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

ENTRYPOINT ["python", "-m", "src.pipeline"]

Step 2 is the one people get wrong. Copy the source before installing and every one-character code change reinstalls the entire dependency tree — turning a 10-second build into four minutes.

Find the three problems

FROM python:latest
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
ENV DB_PASSWORD=Sup3rSecret!
CMD python pipeline.py

It builds. It runs. It passed review.

Configuration: environment in, nothing hard-coded

import os
from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    db_url: str
    api_token: str
    batch_size: int = 10_000
    dry_run: bool = False

    @classmethod
    def from_env(cls) -> "Config":
        try:
            return cls(
                db_url=os.environ["DB_URL"],            # required: crash at startup
                api_token=os.environ["API_TOKEN"],
                batch_size=int(os.getenv("BATCH_SIZE", 10_000)),
                dry_run=os.getenv("DRY_RUN", "").lower() == "true",
            )
        except KeyError as missing:
            raise SystemExit(f"missing required env var: {missing}")

Two principles doing all the work:

What production adds

Beyond "it runs in a container", a scheduled job needs five things:

  1. Image tags that mean something — rideflow:1.4.2 or the git SHA, never latest. When something breaks at 2 AM you must be able to say exactly what was running, and roll back to a specific thing
  2. Structured logs to stdout — JSON lines with run_id, partition, rows, duration. Containers log to stdout; the platform collects it. Never write log files inside a container
  3. Resource limits — memory and CPU declared. Without a memory limit, one runaway job takes down a shared node instead of just itself
  4. A health signal — exit code 0 only on genuine success (remember the silent partial pull), plus emitted metrics for rows written and duration
  5. Small images — slim bases, multi-stage builds, .dockerignore. A 3 GB image pulled onto 50 workers is minutes of pure network before any work starts

And the discipline that ties it together: the artifact you tested is the artifact you deploy. Build once, promote the same digest through environments.

Words to know

Virtual environmentAn isolated Python installation per project, so dependencies never collide.
Declared dependencyA library you import, listed with a version range in pyproject.toml.
Transitive dependencySomething your dependencies depend on — the usual cause of surprise breakage.
Lock fileThe exact resolved version of every package, making builds reproducible.
Layer cacheDocker reuses unchanged layers; ordering the Dockerfile controls what gets rebuilt.
.dockerignoreExcludes files from the build context — keeps .git, .env and data out of the image.
Image digestA content hash identifying an exact image, unlike a tag which can move.
Twelve-factor configConfiguration supplied by the environment, so one artifact runs everywhere.
Ephemeral containerA container whose filesystem is discarded on restart — never store state in it.

In two minutes

Test yourself

1. Your code hasn't changed but the nightly job now crashes. Most likely cause?

  1. The data changed
  2. An unpinned dependency resolved to a new version at build time
  3. Docker is broken
  4. The scheduler misfired
Show the answer

An unpinned dependency resolved to a new version at build time — Without a lock file, every rebuild re-resolves the tree — including transitive packages you never named. The repo is identical; the build isn't.

2. Why copy requirements before the source in a Dockerfile?

  1. It is required by Docker
  2. So a code change doesn't invalidate the cached dependency layer
  3. To make the image smaller
  4. For security
Show the answer

So a code change doesn't invalidate the cached dependency layer — Layers rebuild from the first change onward. Source last means a one-line edit rebuilds one small layer instead of reinstalling everything.

3. A password was set with ENV in a Dockerfile and the image was pushed. What now?

  1. Remove the line and rebuild — fine
  2. Rotate the credential; it is permanently in the layer history and the registry
  3. Add it to .dockerignore
  4. Only a problem if the image is public
Show the answer

Rotate the credential; it is permanently in the layer history and the registry — Layers are immutable and distributed. Anyone who pulled the image, and docker history, still has it. Removing the line does not un-publish it.

4. Why read required config at startup rather than when first needed?

  1. It is faster
  2. So a missing variable fails in the first second, not halfway through a write
  3. Environment variables expire
  4. It reduces memory
Show the answer

So a missing variable fails in the first second, not halfway through a write — Failing fast keeps failures clean. Discovering a missing credential after writing half a partition leaves inconsistent output to clean up.

5. What does 'build once, promote the same digest' protect against?

  1. Slow builds
  2. Deploying an artifact different from the one you tested
  3. Registry costs
  4. Secret leakage
Show the answer

Deploying an artifact different from the one you tested — Rebuilding per environment re-resolves dependencies, so staging and production can differ. Promoting one digest means what passed tests is what runs.

6. What does a lock file contain that pyproject.toml does not?

  1. Your source code's hash
  2. The Python interpreter binary
  3. Exact versions of every package in the resolved tree, including transitive dependencies
  4. The Docker base image
Show the answer

Exact versions of every package in the resolved tree, including transitive dependencies — pyproject declares direct dependencies with ranges (pandas>=2.2,<3.0). The lock pins every resolved package to one version — numpy, python-dateutil and the rest you never named — which is what makes a rebuild reproducible.

There are 9 questions, 3 puzzles and 7 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →