Lesson 01 Python ETL Fundamentals
Theme:

Lab 01 — Implement the 50-line gharchive ETL

Lesson 01 · Data Engineering with Python · ~45 min

Goal: end this lab with a working command python -m extract.gharchive --hour 2024-01-01-15 && python -m transform.events --input data/raw/2024-01-01-15.json.gz --output data/out/2024-01-01-15.csv that downloads one hour of gharchive data and writes a CSV counting every event type.

What you need

Step 1 — Set up the venv

Open a terminal and navigate to the sample-pipeline directory:

cd data-engineering/sample-pipeline
python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -r requirements.txt

The install step pulls in requests, pandas, pyarrow, and other dependencies for the whole course. It takes a minute or two on the first run.

✓ Self-check: python -c "import requests, pandas, pyarrow" runs without errors.

Step 2 — Implement fetch_hour

Open extract/gharchive.py. Find the fetch_hour function — it currently raises NotImplementedError. Replace the body with the implementation from Slide 5:

import requests, pathlib

URL = "https://data.gharchive.org/{hour}.json.gz"

def fetch_hour(hour: str, out_dir: pathlib.Path) -> pathlib.Path:
    out_dir.mkdir(parents=True, exist_ok=True)
    dest = out_dir / f"{hour}.json.gz"
    if dest.exists():
        return dest                      # poor man's caching
    resp = requests.get(URL.format(hour=hour), timeout=30)
    resp.raise_for_status()
    dest.write_bytes(resp.content)
    return dest

Also update the module-level main() function to accept an --hour argument and call fetch_hour:

def main() -> None:
    import argparse
    parser = argparse.ArgumentParser(description="Download one gharchive hour")
    parser.add_argument("--hour", required=True, help='e.g. "2024-01-01-15"')
    args = parser.parse_args()
    path = fetch_hour(args.hour, pathlib.Path("data/raw"))
    print(f"downloaded: {path}")

Test from the terminal (still inside data-engineering/sample-pipeline):

python -m extract.gharchive --hour 2024-01-01-15
# expected: prints data/raw/2024-01-01-15.json.gz, file is roughly 50 MB
ls -lh data/raw/

✓ Self-check: data/raw/2024-01-01-15.json.gz exists and is between 20 MB and 200 MB.

Step 3 — Implement count_by_type

Open transform/events.py. Replace the NotImplementedError body of count_by_type with the implementation from Slide 6:

import gzip, json, pathlib
from collections import Counter

def count_by_type(path: pathlib.Path) -> Counter:
    counts = Counter()
    with gzip.open(path, "rt") as f:
        for line in f:
            ev = json.loads(line)
            counts[ev["type"]] += 1
    return counts

Try it in a Python REPL:

python -c "
from transform.events import count_by_type
import pathlib
result = count_by_type(pathlib.Path('data/raw/2024-01-01-15.json.gz'))
print(dict(list(result.items())[:5]))
print('total events:', sum(result.values()))
"

✓ Self-check: output is a dict like {'PushEvent': 45212, 'CreateEvent': 12103, ...} and the total is in the tens of thousands.

Step 4 — Add write_csv and wire __main__

Add the write_csv function (Slide 7) to transform/events.py:

import csv

def write_csv(counts: Counter, path: pathlib.Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["type", "count"])
        for evtype, n in counts.most_common():
            w.writerow([evtype, n])

Update the module-level main() to accept --input and --output arguments:

def main() -> None:
    import argparse
    parser = argparse.ArgumentParser(description="Transform gharchive events to CSV")
    parser.add_argument("--input", required=True, help="Path to .json.gz input file")
    parser.add_argument("--output", required=True, help="Path to .csv output file")
    args = parser.parse_args()
    counts = count_by_type(pathlib.Path(args.input))
    write_csv(counts, pathlib.Path(args.output))
    print(f"wrote {sum(counts.values())} events across {len(counts)} types")

Run the full pipeline:

python -m transform.events \
  --input  data/raw/2024-01-01-15.json.gz \
  --output data/out/2024-01-01-15.csv
head -5 data/out/2024-01-01-15.csv

✓ Self-check: the CSV starts with a type,count header and the first data row is PushEvent,<some large number>.

Step 5 — Re-run and notice the cache

Run the extract step a second time:

python -m extract.gharchive --hour 2024-01-01-15

It should return instantly — the file is already on disk, so fetch_hour skips the download and returns the cached path. This is your first taste of idempotency: running the same operation twice produces the same result without repeating expensive work.

✓ Self-check: the second run completes in under 1 second.

Step 6 — Try a different hour

Pick any hour from 2024 — but choose one that is at least three hours in the past (gharchive publishes with roughly a two-hour lag). For example:

python -m extract.gharchive --hour 2024-06-15-10
python -m transform.events \
  --input  data/raw/2024-06-15-10.json.gz \
  --output data/out/2024-06-15-10.csv
head -3 data/out/2024-06-15-10.csv

✓ Self-check: the new CSV is non-empty and PushEvent is the top row.

Step 7 — Look at the file sizes

Compare the raw archive, the compressed input, and the CSV output:

du -h data/raw/*.json.gz data/out/*.csv

The CSV is far smaller than the .json.gz — that's expected. We threw away 99% of the data: only the type field survived into the CSV, and we aggregated from hundreds of thousands of events down to roughly 30 event-type rows. Lesson 02 will keep more columns and still produce a smaller file — that's the power of columnar compression.

Troubleshooting

HTTP 404 on a recent hour: gharchive publishes with roughly a 2-hour lag. If you request the current hour or the previous hour, you'll get a 404. Pick an hour at least three hours in the past.

ModuleNotFoundError: No module named 'extract': you forgot to cd data-engineering/sample-pipeline before running python -m extract.gharchive. The -m flag resolves modules from the current working directory, so you must be inside the sample-pipeline folder.

Hung download: if python -m extract.gharchive hangs for more than a minute, your requests.get call is missing the timeout=30 argument. Add it and re-run. A stalled connection without a timeout will block indefinitely.