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. This is the same group-and-sum you did with the shell's cut in Chapter 02, now 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

  • Can you explain, in one sentence, what problem a virtual environment solves?
  • Do you know the difference between pyproject.toml and uv.lock — and which one makes installs reproducible?
  • Could you start a fresh project, add a package, and run a script with uv run without looking it up?
  • Which files do you commit to git, and which do you never commit?
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.

Next

You can build reproducible Python projects. Now let's give your pipelines real databases to talk to — spun up identically on any machine with containers. → Docker & Local Databases