Course 2 · Hands-on

The Python Toolchain

Python is the lingua franca of data engineering — the glue between databases, APIs, and pipelines. But writing Python professionally is more than opening a notebook: it means reproducible projects, isolated environments, and pinned dependencies so your code runs the same on your laptop, a teammate's, and a server. This lab sets up the modern toolchain and gets you running a real script.

Setup — do this first

You'll install Python 3.12+ and uv (a fast, modern package and environment manager) — and you'll do both through uv, so there's almost nothing to fumble. You also want the data/sales.csv file you made in Chapter 02; we'll read it in the script section. Keep your terminal open beside this page and run each command as you read.

Why a real toolchain, not just notebooks

Notebooks are wonderful for exploring — you'll use them forever. But production data work is code that runs unattended, on a schedule, on a machine that isn't yours. That code has to be reproducible: anyone who checks it out of git should be able to run it and get the same result. Notebooks alone don't give you that. A real project does.

The core problem a toolchain solves is dependency hell. Imagine two projects on your laptop:

ProjectNeeds
sales-pipelinepandas 1.5 (an older API a legacy report depends on)
ml-featurespandas 2.2 (a new feature only in the latest version)

If Python installs packages globally — one shared pile for the whole machine — these two projects fight. Installing pandas 2.2 for one breaks the other. You can only ever have one version, so something is always wrong.

The fix is to give each project its own private, isolated set of packages — a virtual environment. Each project carries its exact dependencies; they never collide:

one global Python (no isolation) per-project isolation (the fix) ────────────────────────────── ────────────────────────────────── sales-pipeline/ ┌─────────────────────────┐ └── .venv/ ← pandas 1.5 (private) │ global packages │ │ pandas 2.2 ⚔ 1.5 │ ml-features/ │ (only one can win) │ └── .venv/ ← pandas 2.2 (private) └─────────────────────────┘ both projects break both projects work, independently
The reproducibility principle, continued

This is the same theme from Chapter 00: everything is code, everything is reproducible. A project that declares its exact dependencies in a file — and locks their versions — is one you can rebuild from scratch anywhere. "It works on my machine" dies here.

Install uv

uv is a single, very fast tool that manages Python versions, virtual environments, and packages — replacing the old tangle of pip, venv, pyenv, and friends. We install it once, then let it install Python for us.

terminal
# macOS / Linux / WSL2 — one line
curl -LsSf https://astral.sh/uv/install.sh | sh

# restart your shell (or open a new terminal), then confirm
uv --version

# let uv install a modern Python for you
uv python install 3.12
✓ You should see

uv --version prints something like uv 0.5.x. uv python install 3.12 downloads and reports an installed CPython 3.12.x. If uv isn't found, close and reopen your terminal so it picks up the updated PATH — the installer adds uv to your shell config.

Create a project

A "project" is just a folder with a few files that declare what it is and what it needs. uv init scaffolds one for you:

terminal
cd ~
uv init mini-griddp-pipeline
cd mini-griddp-pipeline
ls -a

You get a small, conventional layout. Here's what each piece is:

mini-griddp-pipeline/ ├── pyproject.toml ← the project's identity card: name, Python version, dependencies ├── .python-version ← pins WHICH Python (3.12) this project uses ├── README.md ← project docs (yours to fill in) ├── main.py ← a starter script uv creates for you └── .git/ ← uv initializes git too (your work is already trackable) │ └─ after you add packages you'll also see: .venv/ ← the private virtual environment (NOT committed to git) uv.lock ← exact pinned versions of every package (committed to git)

The heart of it is pyproject.toml — a plain text config file (in TOML format) that describes your project. Open it and you'll see something like:

pyproject.toml
[project]
name = "mini-griddp-pipeline"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.12"
dependencies = []          # packages get listed here as you add them
✓ You should see

ls -a shows pyproject.toml, .python-version, README.md, main.py, and a .git folder. This whole folder is your project — copy it, share it, commit it, and someone else can rebuild it exactly.

Virtual environments, explained

A virtual environment (or "venv") is a private folder — .venv/ — that holds a copy of Python and only this project's packages. When a venv is active, python means "the Python in .venv" and its installed packages, nothing global. That's the isolation that makes the dependency-hell problem disappear.

The mental model: each project is a sealed box. What you install in one box never leaks into another. Two projects can hold different pandas versions because each has its own box.

terminal
# uv creates the venv for you (it also does this automatically on first 'uv add')
uv venv

# the magic command: 'uv run' runs anything INSIDE this project's venv,
# creating/syncing it first if needed — no manual "activate" step to remember
uv run python --version
✓ You should see

uv run python --version prints Python 3.12.x — the version pinned in .python-version, not whatever Python your system happens to have. The big habit to form: prefix project commands with uv run, and you're always inside the right box automatically.

Why this matters

Old tutorials tell you to source .venv/bin/activate before working and deactivate after. uv run makes that bookkeeping mostly unnecessary — it picks the project's environment for you, every time. Less to forget, fewer "why is this package missing?" mysteries.

Adding dependencies

To use a package, you uv add it. This installs it into the venv and records it in pyproject.toml so the project remembers it needs it. Let's add the core data tools:

terminal
uv add pandas duckdb polars

# look at what changed
cat pyproject.toml      # dependencies now lists pandas, duckdb, polars
ls                       # a new uv.lock file appeared

Two files now describe your dependencies, and the difference between them matters:

FileWhat it records
pyproject.tomlThe packages you asked for, loosely (e.g. "pandas, any recent version"). Human-edited.
uv.lockThe exact versions actually installed — pandas and every package pandas itself depends on, down to the digit. Machine-generated.

The lockfile is what makes installs reproducible across machines. When a teammate clones your repo and runs uv sync, uv reads uv.lock and installs the identical versions you had — not "whatever's newest today." Same inputs, same environment, every time. Commit pyproject.toml and uv.lock to git; never commit .venv/ (it's rebuildable and large).

A starter map of packages you'll meet constantly:

PackageWhat it's for
pandasThe classic dataframe library — tables in memory, the workhorse of data wrangling
polarsA newer, much faster dataframe library; great on bigger data
duckdbA fast in-process OLAP database — your local "data warehouse," queries CSV/Parquet with SQL
requestsCalling HTTP APIs to pull data from external services
pytestWriting and running tests so your pipeline code stays correct
✓ You should see

After uv add pandas duckdb polars, the dependencies = [...] line in pyproject.toml now lists all three, and a uv.lock file exists. uv installs them in seconds.

Write a real script

Time to do something useful. We'll read the data/sales.csv from Chapter 02, count the rows, and total the amount per region. First make sure the data is here:

terminal
# bring the CSV into this project (adjust the path if yours lives elsewhere)
mkdir -p data
cp ~/cli-practice/data/sales.csv data/sales.csv   # from Chapter 02

# if you skipped Chapter 02, recreate it:
cat > data/sales.csv <<'EOF'
date,region,amount
2026-06-01,us,120
2026-06-01,eu,80
2026-06-02,us,200
2026-06-02,eu,95
EOF

Now create summarize.py in the project folder with your editor (VS Code, nano, whatever you like):

summarize.py
import pandas as pd

# read the CSV into a dataframe (a table in memory)
df = pd.read_csv("data/sales.csv")

print(f"rows: {len(df)}")

# group by region, sum the amount column, and print each region's total
totals = df.groupby("region")["amount"].sum()
for region, total in totals.items():
    print(f"{region}: {total}")

Run it inside the project's environment:

terminal
uv run python summarize.py
✓ You should see

Output like:

output
rows: 4
eu: 175
us: 320

Four data rows; EU totals 80 + 95 = 175 and US totals 120 + 200 = 320. Notice you didn't activate anything — uv run used the project venv (with pandas installed) automatically. It's the same sales data you filtered and sliced with grep and cut in Chapter 02 — now grouped and summed, which the shell tools couldn't do cleanly, with a real dataframe.

A taste of good practice

You don't need to master these yet — Courses 3 and 4 cover them fully — but a small taste now builds the right habits. Two cheap upgrades make code easier to read and trust: a docstring (what does this do?) and type hints (what goes in, what comes out?). Refactor the summary into a function:

summarize.py
import pandas as pd


def region_totals(path: str) -> pd.Series:
    """Read a sales CSV and return total amount per region."""
    df = pd.read_csv(path)
    return df.groupby("region")["amount"].sum()


if __name__ == "__main__":
    totals = region_totals("data/sales.csv")
    print(totals)

The -> pd.Series and path: str are type hints: they don't change how the code runs, but they document intent and let tools catch mistakes. Two tools you'll meet soon:

ToolWhat it doesTry later
ruffA fast linter/formatter — flags bugs and style issues, auto-fixes formattinguv add --dev ruff then uv run ruff check .
pytestRuns your tests so changes don't silently break thingsuv add --dev pytest then uv run pytest

The --dev flag marks these as development dependencies — needed to work on the project, but not to run it in production. Keep it light for now; just know these exist and what they're for.

✓ Check yourself

Answer from memory before opening each one.

  1. sales-pipeline needs pandas 1.5 and ml-features needs pandas 2.2. Why can't one global Python serve both, and what exactly does a virtual environment change?

    Show answer

    A global install is one shared pile of packages, and it can hold only one version of pandas — installing 2.2 for one project breaks the other. A virtual environment gives each project its own .venv/ with a private set of packages, so each "sealed box" holds its own version and nothing leaks between them. See Why a real toolchain and Virtual environments.

  2. Inside mini-griddp-pipeline, uv add pandas succeeded. You then type python3 summarize.py and get ModuleNotFoundError: No module named 'pandas'. What went wrong, and what is the fix?

    Show answer

    Nothing is broken — pandas was installed into the project's .venv/, and plain python3 is the system Python, which can't see inside that box. Run uv run python summarize.py, which picks the project's environment automatically. The tempting fix is to install pandas globally, which brings back the problem environments exist to solve. See Virtual environments.

  3. Six months from now a teammate clones the repo and runs uv sync. pyproject.toml just says pandas, and newer releases have shipped since. Which version do they get, and what would have happened if uv.lock had never been committed?

    Show answer

    They get the identical version you had, because uv sync installs from uv.lock, which records the exact version of pandas and of everything pandas depends on. Without the lockfile, only the loose request in pyproject.toml remains, so they'd get whatever is newest that day — a different environment from yours, and "works on my machine" is back. See Adding dependencies.

  4. A teammate commits .venv/ "so nobody has to install anything." Why is that the wrong way to share an environment, and what does the job properly?

    Show answer

    .venv/ is large, machine-specific generated output — it is built for one operating system and one folder location, so it bloats the repo and may not even work elsewhere. The environment is already fully described by two small text files, pyproject.toml and uv.lock; commit those and anyone rebuilds the identical environment with uv sync in seconds. See Adding dependencies and the project layout.

  5. In the first version of summarize.py, change groupby("region") to groupby("date") (and nothing else that matters). What does uv run python summarize.py print?

    Show answer

    rows: 4, then 2026-06-01: 200 and 2026-06-02: 295. The row count doesn't depend on the grouping; the sums are now per day — 120 + 80 and 200 + 95. If you got 515 on one line you summed without grouping. See Write a real script.

  6. An analyst has a notebook that produces the weekly GPU revenue numbers and asks you to "just schedule it." When is a notebook the right home for that code, and what tells you it has outgrown one?

    Show answer

    A notebook is right for exploring — a person at the keyboard, looking at results, changing their mind. The deciding question is whether the code must run unattended, on a schedule, on a machine that isn't theirs: then anyone who checks it out of git has to get the same result, which needs a project with declared and locked dependencies and a script you can run with one command. Exploration stays in the notebook; the scheduled job becomes a project. See Why a real toolchain.

Exercise — Add a dependency and find the top sale

Write a short script top_sale.py that reads data/sales.csv and prints the single row with the highest amount. Use any dataframe library — try adding polars (if you haven't already) and using it, so you've added a dependency as part of the exercise.

terminal
uv add polars        # already added earlier? uv just confirms it's present
top_sale.py
import polars as pl

df = pl.read_csv("data/sales.csv")

# sort by amount descending, take the first row
top = df.sort("amount", descending=True).head(1)
print(top)
terminal
uv run python top_sale.py

You should see the 2026-06-02, us, 200 row — the biggest sale. (Prefer pandas? df.loc[df["amount"].idxmax()] does the same thing.) If you reached for uv add and uv run without thinking, the toolchain habits have landed.

Takeaway

  • A virtual environment gives each project its own packages, so two projects can need different versions of pandas without breaking each other.
  • pyproject.toml records the packages you asked for, and uv.lock records the exact versions installed, which is what makes a rebuild identical on another machine.
  • Commit pyproject.toml and uv.lock, and never commit .venv, because it can be rebuilt from the lock.
  • uv run executes inside the project's environment and syncs it first, so you do not activate anything by hand.
  • Type hints and docstrings change nothing at runtime. They document intent and let tools like ruff and a type checker catch mistakes.
  • Development tools such as pytest and ruff go in dev dependencies, because the project needs them to be worked on but not to run.

Your code now runs the same anywhere. Next you give it real databases to talk to. → Docker & Local Databases

Further reading