Data Engineering
Hands-onIntermediate 45 min· Pipelines · 6 of 18

Batch pipelines with Python

Build a complete batch pipeline in plain Python: read a dirty CSV, validate rows, save rejects with reasons, and load into a database idempotently. You will run it, re-run it safely, test it and learn how to make it production-ready.

You will build a small but realistic batch pipeline in plain Python. It reads a day's raw order file (a CSV with some bad rows), validates and cleans it, saves the rejected rows for review, and loads the good rows into a SQLite database so that running it twice never creates duplicates. At the end you will have seen every pattern from *ETL vs ELT and pipeline design* in working code.

Prerequisites: Python 3.9 or newer and basic Python (functions, dictionaries, files). No packages to install: we use only the standard library (csv, sqlite3, argparse, logging). SQLite stands in for a real warehouse; the same structure works with PostgreSQL or BigQuery by changing the load step.

What we are building

The pipeline

  1. 1Extract: read data/orders_<date>.csv. Every value is text at this point.
  2. 2Validate and transform: convert types, check the item exists on the menu, check the quantity is positive, work out the line total in ₹, and drop exact duplicates.
  3. 3Reject: write bad rows, with a reason, to data/rejects_<date>.csv. Never silently throw data away.
  4. 4Load: replace that day's rows in the order_lines table inside one transaction (idempotent).
  5. 5Guard: refuse to load if more than half the rows were bad, which usually means the source file is broken.

Step 1: project layout and sample data

Make a folder orders-pipeline with a data folder inside it. Create data/orders_2026-10-01.csv with the content below. It is deliberately dirty: an item that is not on the menu, a quantity that is not a number, and a duplicated line.

data/orders_2026-10-01.csv
order_id,customer_name,item_id,qty,created_at
1001,Asha,cappuccino,2,2026-10-01 08:15:00
1001,Asha,croissant,1,2026-10-01 08:15:00
1002,Ravi,filter-coffee,1,2026-10-01 09:02:00
1003,Meena,cold-brew,1,2026-10-01 11:40:00
1004,Kiran,pizza,1,2026-10-01 12:10:00
1005,Divya,masala-chai,abc,2026-10-01 13:00:00
1006,Arjun,espresso,3,2026-10-01 18:30:00
1006,Arjun,espresso,3,2026-10-01 18:30:00

Rows 1004, 1005 and the second 1006 line are bad on purpose. Brew & Bean's menu does not sell pizza.

Why test with dirty data? Real source files are always dirty. A pipeline you have only run on clean data has not been tested.

Step 2: write the pipeline

Create pipeline.py in the orders-pipeline folder. Read it once from top to bottom before running it. The notes after the code explain each function.

pipeline.py
"""Batch pipeline: raw order CSV -> validated rows -> SQLite warehouse."""
import argparse
import csv
import logging
import sqlite3
from datetime import datetime
from pathlib import Path

MENU = {
    "espresso": 120, "cappuccino": 180, "cold-brew": 220, "filter-coffee": 80,
    "masala-chai": 90, "croissant": 150, "banana-bread": 130, "veg-sandwich": 160,
}

log = logging.getLogger("pipeline")


def extract(path):
    """Read the raw CSV. Everything is still text at this point."""
    with open(path, newline="", encoding="utf-8") as f:
        return list(csv.DictReader(f))


def validate(row):
    """Return (clean_row, None) or (None, reason)."""
    try:
        order_id = int(row["order_id"])
        qty = int(row["qty"])
        created_at = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S")
    except (ValueError, KeyError) as exc:
        return None, f"bad value: {exc}"
    item_id = row["item_id"].strip()
    if item_id not in MENU:
        return None, f"unknown item_id: {item_id}"
    if qty <= 0:
        return None, "qty must be positive"
    clean = {
        "order_id": order_id,
        "item_id": item_id,
        "qty": qty,
        "line_total": qty * MENU[item_id],
        "order_date": created_at.date().isoformat(),
    }
    return clean, None


def transform(rows):
    """Split rows into good and rejected, and drop exact duplicates."""
    good, rejected, seen = [], [], set()
    for row in rows:
        clean, reason = validate(row)
        if clean is None:
            rejected.append({**row, "reason": reason})
            continue
        key = (clean["order_id"], clean["item_id"])
        if key in seen:
            rejected.append({**row, "reason": "duplicate line"})
            continue
        seen.add(key)
        good.append(clean)
    return good, rejected


def load(conn, rows, run_date):
    """Idempotent load: replace the whole day inside one transaction."""
    with conn:  # commits on success, rolls back on error
        conn.execute(
            """CREATE TABLE IF NOT EXISTS order_lines (
                   order_id INTEGER, item_id TEXT, qty INTEGER,
                   line_total INTEGER, order_date TEXT,
                   PRIMARY KEY (order_id, item_id))"""
        )
        conn.execute("DELETE FROM order_lines WHERE order_date = ?", (run_date,))
        conn.executemany(
            """INSERT INTO order_lines
               VALUES (:order_id, :item_id, :qty, :line_total, :order_date)""",
            rows,
        )


def write_rejects(rejected, path):
    if not rejected:
        return
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=list(rejected[0].keys()))
        writer.writeheader()
        writer.writerows(rejected)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--date", required=True, help="YYYY-MM-DD")
    parser.add_argument("--db", default="warehouse.db")
    args = parser.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")

    path = Path("data") / f"orders_{args.date}.csv"
    raw = extract(path)
    good, rejected = transform(raw)
    log.info("read=%d good=%d rejected=%d", len(raw), len(good), len(rejected))

    if raw and len(rejected) / len(raw) > 0.5:
        raise SystemExit("More than half the rows were rejected; not loading.")

    write_rejects(rejected, Path("data") / f"rejects_{args.date}.csv")
    conn = sqlite3.connect(args.db)
    try:
        load(conn, good, args.date)
    finally:
        conn.close()
    log.info("loaded %d rows for %s", len(good), args.date)


if __name__ == "__main__":
    main()

The complete pipeline in about 100 lines.

How each part works

Walkthrough

PartWhat it doesWhy it is written this way
MENUA dictionary of item id to price in ₹Our rule for valid items and for working out the line total. In a real project this would be a table, not hard-coded
extract()Reads the CSV into a list of dictionariescsv.DictReader uses the header row for column names. Keep extract dumb: no cleaning here
validate()Returns (clean_row, None) or (None, reason)A clear result for every row. Returning a reason makes rejects easy to explain and debug
transform()Splits rows into good and rejected, drops duplicatesDuplicates are tracked with a set of (order_id, item_id) keys, matching the table's primary key
load()Deletes the day's rows and inserts the new ones inside with conn:The with conn: block is one transaction: it commits if everything works and rolls back on any error, so a crash never leaves half a day loaded
write_rejects()Saves bad rows with a reason columnSomeone must be able to look at what was dropped and why
main()Reads --date, runs the steps, logs counts, applies the guardThe date is a parameter, so any past day can be re-run or backfilled

Notice the shape: small functions that each do one thing, and one main() that wires them together. This makes each part testable on its own, which you will do in Step 5.

Step 3: run it

Terminal (run inside the orders-pipeline folder)
python pipeline.py --date 2026-10-01

On Windows you may need py pipeline.py --date 2026-10-01 instead of python.

Terminal (your timestamps will differ)
2026-10-01 09:30:12,401 INFO read=8 good=5 rejected=3
2026-10-01 09:30:12,412 INFO loaded 5 rows for 2026-10-01

Open data/rejects_2026-10-01.csv. You should see exactly the three bad rows, each with a reason.

data/rejects_2026-10-01.csv
order_id,customer_name,item_id,qty,created_at,reason
1004,Kiran,pizza,1,2026-10-01 12:10:00,unknown item_id: pizza
1005,Divya,masala-chai,abc,2026-10-01 13:00:00,bad value: invalid literal for int() with base 10: 'abc'
1006,Arjun,espresso,3,2026-10-01 18:30:00,duplicate line

Step 4: check the result and prove it is idempotent

A pipeline run is not finished until you have looked at the output. Create check.py to query the warehouse, then run the pipeline a second time.

check.py
import sqlite3

conn = sqlite3.connect("warehouse.db")
print("rows:", conn.execute("SELECT COUNT(*) FROM order_lines").fetchone()[0])
for row in conn.execute(
    "SELECT order_date, SUM(line_total) FROM order_lines GROUP BY order_date"
):
    print(row)
conn.close()

Counts rows and shows revenue per day from the warehouse.

Terminal
python check.py
python pipeline.py --date 2026-10-01
python check.py

Run the check, re-run the pipeline for the same date, and check again.

Terminal
rows: 5
('2026-10-01', 1170)
2026-10-01 09:31:40,100 INFO read=8 good=5 rejected=3
2026-10-01 09:31:40,110 INFO loaded 5 rows for 2026-10-01
rows: 5
('2026-10-01', 1170)

The row count and revenue are identical after the second run. That is idempotency, and it comes from the DELETE then INSERT inside one transaction. You can verify the revenue by hand: 2 cappuccinos (₹360) + 1 croissant (₹150) + 1 filter coffee (₹80) + 1 cold brew (₹220) + 3 espressos (₹360) = ₹1,170.

Try breaking it on purpose

If you change load() to a plain INSERT without the DELETE, the second run fails because of the primary key (order_id, item_id). If you remove the primary key too, it silently doubles every row. Experiment with this on a copy to see why both protections matter.

Step 5: add tests

Because validate() and transform() are plain functions, you can test them without files or databases. Create test_pipeline.py. You can run it with pytest (install with pip install pytest, then run pytest) or just import and call the functions.

test_pipeline.py
from pipeline import transform, validate


def test_validate_accepts_good_row():
    row = {"order_id": "1", "customer_name": "Asha", "item_id": "espresso",
           "qty": "2", "created_at": "2026-10-01 08:15:00"}
    clean, reason = validate(row)
    assert reason is None
    assert clean["line_total"] == 240
    assert clean["order_date"] == "2026-10-01"


def test_validate_rejects_unknown_item():
    row = {"order_id": "1", "customer_name": "Asha", "item_id": "pizza",
           "qty": "1", "created_at": "2026-10-01 08:15:00"}
    clean, reason = validate(row)
    assert clean is None
    assert "unknown item_id" in reason


def test_transform_drops_duplicates():
    row = {"order_id": "1", "customer_name": "Asha", "item_id": "espresso",
           "qty": "1", "created_at": "2026-10-01 08:15:00"}
    good, rejected = transform([row, dict(row)])
    assert len(good) == 1
    assert rejected[0]["reason"] == "duplicate line"

Three small tests: a good row, an unknown item, and duplicate handling.

Terminal after running pytest (the timing line is omitted)
...                                                                      [100%]
3 passed

Step 6: make it production-friendly

The script works. Before you rely on it every night, a data engineer would add the following. You will do several of them in later lessons.

From script to pipeline

ImprovementWhat to do
RetriesWrap network calls (API downloads, cloud storage reads) in a retry loop with a short, growing wait. Do not retry validation errors; they will fail again
ConfigurationRead the database path and folders from environment variables or a config file, not from code. Never put passwords in the script
SecretsUse environment variables or your platform's secret manager for credentials
Bigger filescsv.DictReader reads row by row, but our list() keeps all rows in memory. For files that do not fit in memory, process in chunks or use pandas' chunksize or Spark (next lesson)
SchedulingRun it daily with cron (Linux/macOS), Task Scheduler (Windows) or an orchestrator. Apache Airflow adds retries, dependencies and a history of runs (see *Orchestration with Apache Airflow*)
Real warehouseReplace SQLite with PostgreSQL, BigQuery or Snowflake. The DELETE-then-INSERT pattern becomes a partition replace or MERGE
MonitoringAlert on a failed run, zero rows loaded, or a large change in reject rate

Use pandas when your data fits in memory and you want convenient table operations (read_csv, groupby, merge). Use plain Python when you need strict control over each row and rejected data, as here. Both are common in interviews; be ready to explain the trade-off.

Prompt · Review my Python pipeline
You are a senior data engineer. Review this Python batch pipeline for production readiness. Look for: non-idempotent loads, missing error handling, silent data loss, memory problems on large files, hard-coded values and secrets, and missing tests. Give a prioritised list of fixes with short code examples.

[paste your pipeline code here]

What to expect: A useful review names specific lines and problems. Check each suggested fix by running it; do not paste code you do not understand.

How this comes up in interviews

Common questions

  1. "Write a Python function that reads a CSV, cleans it and loads it into a database."
  2. "How do you make the script safe to re-run?"
  3. "What do you do with bad rows?" (Reject with a reason; do not drop silently; alert if the rate is high.)
  4. "How would you process a 20 GB file with limited memory?"
  5. "How would you test this pipeline?"

Try it: Add a new validation rule and a summary

1. Reject any row whose created_at date does not match the --date argument (a wrong-day row in the file). 2. Add a log line after loading that shows total revenue loaded for the day. 3. Add a test for your new rule.

Try it: Backfill three days

Create orders_2026-10-02.csv and orders_2026-10-03.csv (copy the first and change dates and order ids). Write a short shell loop or Python loop that runs the pipeline for all three dates. Then run it again for all three and confirm the totals do not change.

Try it: Stretch: a reject-rate alert

The guard currently stops when more than half the rows are rejected. Make the threshold configurable through an environment variable MAX_REJECT_RATE (default 0.5) and exit with a non-zero status when it is exceeded.

Key takeaways

  1. A batch pipeline has clear stages: extract, validate and transform, reject, load.
  2. Validate every row and save rejects with a reason. Never silently drop data.
  3. Make the load idempotent: delete the day's rows and insert the new ones in one transaction (with conn:).
  4. Pass the run date as an argument so you can re-run and backfill.
  5. Log counts at each step and stop when the reject rate suggests a broken source.
  6. Small single-purpose functions are easy to test; always check the output after a run.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why does load() delete the day's rows and insert the new ones inside one transaction?

2. A row has quantity 'abc'. What should this pipeline do?

3. What is the purpose of the check that stops loading if more than half the rows are rejected?

4. Which design makes it easiest to backfill last month's data?

5. Your CSV is 20 GB and your laptop has 8 GB of RAM. Which change matters most?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free