The Take-Home Project
What is actually marked — a repo skeleton, the README that does most of the work, and the four hours' effort that beats forty.
Take-homes are marked by a tired reviewer with several submissions open. This lesson is what they check, in the order they check it, and where four focused hours beat forty scattered ones.
The brief
“Here are three CSV files — orders, customers, products. Build a pipeline that produces a daily revenue summary by country and product category. Include tests. Roughly four hours.”
What is actually marked
| Weight | Criterion | How it is judged |
|---|---|---|
| High | Does it run? | git clone, follow the README, one command |
| High | Is the README good? | can they understand it without reading code |
| High | Is it correct? | do the numbers reconcile; are edge cases handled |
| Medium | Are there tests? | do they test logic, not assert True |
| Medium | Is it idempotent? | run it twice — do the numbers double |
| Medium | Is the structure sensible? | can a new feature be added without a rewrite |
| Low | Is it clever? | genuinely low — clarity beats cleverness every time |
Two of the three high-weight items are not code. That is the thing to internalise.
The repository
bookshop-pipeline/
├── README.md ← read first, and does most of the work
├── Makefile ← one command to run, one to test
├── pyproject.toml
├── config.yml ← paths, thresholds, run date
├── src/
│ ├── __init__.py
│ ├── extract.py ← read + validate schema
│ ├── transform.py ← pure functions, no I/O
│ ├── load.py ← idempotent write
│ ├── quality.py ← checks as data, gates on severity
│ └── pipeline.py ← orchestration + CLI
├── tests/
│ ├── test_transform.py ← the logic
│ ├── test_quality.py ← the checks
│ └── fixtures/ ← small, hand-written, including bad rows
└── data/
├── raw/ ← the provided CSVs
└── output/ ← generated, gitignored
The split that matters: transform.py has no I/O. Pure functions taking a DataFrame and
returning a DataFrame are trivially testable, and a reviewer looking for tests will find them
immediately.
# src/transform.py
import pandas as pd
def clean_orders(orders: pd.DataFrame) -> pd.DataFrame:
"""One row per order. Deduplicates on order_id, keeping the latest by ordered_at."""
return (orders
.assign(status=lambda d: d["status"].str.strip().str.lower())
.sort_values(["order_id", "ordered_at"])
.drop_duplicates("order_id", keep="last")
.loc[lambda d: d["status"] != "pending"])
def daily_revenue(orders: pd.DataFrame, customers: pd.DataFrame,
products: pd.DataFrame) -> pd.DataFrame:
"""One row per (date, country, category). Unmatched dimensions become 'UNKNOWN'."""
enriched = (orders
.merge(customers[["customer_id", "country"]], on="customer_id", how="left")
.merge(products[["product_id", "category"]], on="product_id", how="left")
.fillna({"country": "UNKNOWN", "category": "UNKNOWN"}))
return (enriched.loc[enriched["status"] == "completed"]
.groupby(["ordered_at", "country", "category"], as_index=False)
.agg(orders=("order_id", "count"), revenue=("amount", "sum"))
.round({"revenue": 2})
.sort_values(["ordered_at", "country", "category"]))
Two decisions visible in six lines: how="left" with fillna rather than an inner join, so
no revenue disappears, and a docstring stating the grain of each output. A reviewer reads
both instantly.
Make it run in one command
.PHONY: install run test lint clean
install:
pip install -e ".[dev]"
run:
python -m src.pipeline --date 2026-01-04
test:
pytest -q
lint:
ruff check src tests && ruff format --check src tests
clean:
rm -rf data/output/* .pytest_cache
make install && make run
2026-09-10 09:14:02 INFO Reading data/raw/orders.csv (12,481 rows)
2026-09-10 09:14:02 INFO Reading data/raw/customers.csv (2,000 rows)
2026-09-10 09:14:02 INFO Reading data/raw/products.csv (450 rows)
2026-09-10 09:14:02 INFO Deduplicated orders: 12,481 -> 12,462 (19 duplicates removed)
2026-09-10 09:14:02 WARN 142 orders reference an unknown customer -> UNKNOWN
2026-09-10 09:14:02 INFO Quality checks: 5 passed, 1 warning, 0 errors
2026-09-10 09:14:02 INFO Wrote 186 rows to data/output/daily_revenue.parquet
2026-09-10 09:14:02 INFO Reconciliation: source 412,884.19 == output 412,884.19 OK
2026-09-10 09:14:02 INFO Done in 1.8s
That log is doing a lot of work. It shows the row counts, names the deduplication, surfaces the 142 orphans rather than hiding them, and reconciles. A reviewer who reads only this output already knows the submission is careful.
Prove idempotency in the log
make run && make run
... Wrote 186 rows to data/output/daily_revenue.parquet
... Reconciliation: source 412,884.19 == output 412,884.19 OK
... Replacing existing partition for 2026-01-04
... Wrote 186 rows to data/output/daily_revenue.parquet
... Reconciliation: source 412,884.19 == output 412,884.19 OK
Same 186 rows, same total. Put “run it twice” in the README as an explicit instruction — most reviewers will, and most submissions double.
# src/load.py
from pathlib import Path
def write_partition(df, output_dir: Path, run_date: str) -> Path:
"""Idempotent: replaces the partition for run_date, leaves others untouched."""
target = output_dir / f"date={run_date}"
tmp = output_dir / f".{run_date}.tmp"
tmp.mkdir(parents=True, exist_ok=True)
df.to_parquet(tmp / "part-0.parquet", index=False)
if target.exists():
logger.info("Replacing existing partition for %s", run_date)
shutil.rmtree(target)
tmp.rename(target) # atomic
return target
Write to a temp path and rename. A reviewer who has run pipelines will notice, and it takes four lines.
Tests that count
# tests/test_transform.py
import pandas as pd
import pytest
from src.transform import clean_orders, daily_revenue
def test_deduplicates_keeping_latest():
orders = pd.DataFrame({
"order_id": [1, 1, 2],
"ordered_at": ["2026-01-04", "2026-01-05", "2026-01-04"],
"status": ["completed", "returned", "completed"],
"amount": [25.5, 25.5, 12.0],
})
out = clean_orders(orders)
assert len(out) == 2
assert out.loc[out.order_id == 1, "status"].item() == "returned"
def test_unmatched_dimension_becomes_unknown_not_dropped():
orders = pd.DataFrame({"order_id": [1], "customer_id": [999], "product_id": [1],
"ordered_at": ["2026-01-04"], "status": ["completed"],
"amount": [25.5]})
customers = pd.DataFrame({"customer_id": [1], "country": ["GB"]})
products = pd.DataFrame({"product_id": [1], "category": ["fiction"]})
out = daily_revenue(orders, customers, products)
assert out["revenue"].sum() == 25.5 # revenue preserved
assert out["country"].item() == "UNKNOWN"
def test_empty_input_returns_empty_not_error():
empty = pd.DataFrame(columns=["order_id", "customer_id", "product_id",
"ordered_at", "status", "amount"])
out = daily_revenue(empty, pd.DataFrame(columns=["customer_id", "country"]),
pd.DataFrame(columns=["product_id", "category"]))
assert len(out) == 0
def test_status_matching_is_case_and_whitespace_insensitive():
orders = pd.DataFrame({"order_id": [1], "ordered_at": ["2026-01-04"],
"status": [" COMPLETED "], "amount": [25.5]})
assert clean_orders(orders)["status"].item() == "completed"
make test
.... [100%]
4 passed in 0.42s
Four tests, each testing a decision rather than a line of code. The second one is the most valuable: it asserts that a broken foreign key does not lose revenue, which is the bug from lesson 1 caught permanently.
The empty-input test is the one nobody writes and reviewers notice, because it is the case that reaches production untested.
Quality checks as data
# src/quality.py
from dataclasses import dataclass
@dataclass
class Check:
name: str
severity: str # "error" blocks, "warn" reports
passed: bool
detail: str = ""
def run_checks(orders, output, source_total) -> list[Check]:
return [
Check("unique_order_id", "error",
orders["order_id"].is_unique,
f"{orders['order_id'].duplicated().sum()} duplicates"),
Check("no_negative_amounts", "error",
(orders["amount"] >= 0).all(),
f"{(orders['amount'] < 0).sum()} negative"),
Check("known_statuses", "warn",
orders["status"].isin({"completed", "returned", "refunded"}).all(),
f"unexpected: {sorted(set(orders['status']) - {'completed','returned','refunded'})}"),
Check("output_not_empty", "error", len(output) > 0),
Check("reconciles_to_source", "error",
abs(output["revenue"].sum() - source_total) < 0.01,
f"diff {output['revenue'].sum() - source_total:.2f}"),
]
Quality checks
PASS unique_order_id
PASS no_negative_amounts
WARN known_statuses unexpected: ['cancelled']
PASS output_not_empty
PASS reconciles_to_source
5 checks: 4 passed, 1 warning, 0 errors
Checks with severity, so errors block and warnings report. The cancelled status is a real
finding from the provided data — surfacing it, rather than silently letting it fall through
the completed filter, is the kind of thing that gets mentioned in the debrief.
The README does most of the work
This is the highest-return hour of the whole exercise.
# Bookshop daily revenue pipeline
Produces a daily revenue summary by country and product category.
## Run it
$ make install
$ make run # writes data/output/date=2026-01-04/
$ make test
Run `make run` twice — the output is unchanged. The load replaces the partition for
the run date rather than appending.
## Approach
Three layers: **extract** validates the schema, **transform** is pure functions with
no I/O, **load** writes one partition atomically. The transform layer has no file
access so it is testable without fixtures on disk.
Grain of the output: **one row per (date, country, category)**.
## Decisions and why
| Decision | Reason |
| --- | --- |
| Left join to dimensions, `UNKNOWN` for misses | 142 orders reference a customer not in the file. An inner join would silently drop £3,847 of revenue. |
| Deduplicate on `order_id`, keep latest by `ordered_at` | 19 duplicates in the source. Kept the latest because the status differs, so the later row is the current state. |
| Excluded `pending` | Not yet revenue. `cancelled` also appears (undocumented) — currently excluded, flagged as a warning. |
| Parquet output, partitioned by date | Typed and ~8x smaller than CSV; the date partition makes the load idempotent per run. |
| Reconciliation check blocks the run | Catches rows lost to a join, which no per-column check can see. |
## Data quality findings
- **142 orders (1.1%)** reference a `customer_id` not present in `customers.csv`
- **19 duplicate `order_id`s**, all with differing status — treated as updates
- **`cancelled` status** appears in the data but is not in the provided spec
- `amount` is null on 3 rows — excluded, counted, and reported
## Given more time
1. **Incremental loads** — currently reprocesses the whole file. Would add a watermark
with a 3-day lookback and merge on `order_id` for late-arriving updates.
2. **A proper orchestrator** — the CLI takes `--date` and is idempotent, so it drops into
Airflow or cron unchanged; I did not add a scheduler for a four-hour exercise.
3. **More quality checks** — volume against a trailing average, which is what catches a
truncated input file where every row is individually valid.
4. **Scale** — at ~12k rows pandas is right. Past a few GB I would move the transform to
DuckDB or Spark; the pure-function structure means only the engine changes.
## Time spent
About four hours: 1h exploring the data, 1.5h pipeline, 1h tests, 0.5h README.
Every section is doing a job. Decisions and why shows judgement. Data quality findings
proves you looked at the data rather than just processing it — and the cancelled status is
something the reviewer may not know is there. Given more time pre-empts every “why didn’t
you…” and converts a gap into a demonstration of what you would do at scale.
What sinks submissions
| Mistake | Why it costs you |
|---|---|
| No README, or three lines | The reviewer has to read code to understand anything |
| Does not run — missing dep, hard-coded path | Often scored without being run |
| Jupyter notebook only | Suggests you do not know how production code is structured |
| Zero tests | The most common single reason for rejection |
| Silently dropping rows | The bug they are specifically checking for |
| Forty hours of work on a four-hour brief | Reads as poor judgement, not enthusiasm |
| Hard-coded run date | Cannot be backfilled; shows a gap in pipeline thinking |
print() instead of logging | Small, but reviewers notice |
The over-built submission is worth dwelling on. A reviewer seeing Airflow, Docker Compose, Terraform and a Streamlit dashboard for a four-hour exercise does not think “thorough” — they think “cannot scope work”, which is a harder objection to overcome than a missing feature.
Presenting it
Many take-homes have a follow-up call. Prepare three things:
- A two-minute walkthrough — the layers, the grain, the decisions. Do not read code aloud.
- The findings — the orphans, the duplicates, the undocumented status. Leading with what you found in their data changes the tone of the whole call.
- What you would change — have a real answer. “Nothing” reads as no critical distance.
Expect the extension question: “How would this handle a hundred times the data?” The answer is already in the README, which is why that section is there.
The scoring
| Behaviour | Signal |
|---|---|
| README explains decisions and findings | senior |
| Reconciliation check that blocks the run | senior |
| Idempotent, and says so with instructions to verify | senior |
| Reported data quality findings from their data | senior |
| Scoped to the stated time and documented the rest | senior |
| Clean structure, meaningful tests | mid-to-senior |
| Runs, correct, thin README, few tests | mid |
| Notebook, no tests, silently drops rows | junior |
Practice
1. Run your submission twice and compare the output.
run 1: 186 rows, total 412,884.19
run 2: 186 rows, total 412,884.19
If the second run doubles, the load appends. Fix it, then put “run it twice” in the README as an instruction — it converts a property into a demonstration.
2. Write a test that a broken foreign key does not lose revenue.
out = daily_revenue(orders_with_orphan, customers, products)
assert out["revenue"].sum() == 25.5
assert out["country"].item() == "UNKNOWN"
1 passed
This is the single most valuable test in a data take-home, because silent row loss is the specific failure reviewers probe for.
3. Feed the pipeline an empty input file.
ValueError: No objects to concatenate
Then fix it to return an empty result and log a warning. Empty input is normal in production — a source that had no rows today — and handling it is a mid-to-senior signal for one test.
4. Write the "Given more time" section before writing the code.
1. Incremental loads with a 3-day lookback
2. Orchestrator integration (CLI is already idempotent and parameterised)
3. Volume check against a trailing average
4. DuckDB/Spark past a few GB — only the engine changes
Writing it first tells you what to cut. It is also the section reviewers quote back to you in the follow-up call.
That closes the data engineering interview track. The thread through all ten lessons: the technical answer is table stakes, and the score comes from what you check, what you report, and what you say about the second run.