Skip to main content
Data Engineering Interviews advanced Lesson 10 of 10

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

WeightCriterionHow it is judged
HighDoes it run?git clone, follow the README, one command
HighIs the README good?can they understand it without reading code
HighIs it correct?do the numbers reconcile; are edge cases handled
MediumAre there tests?do they test logic, not assert True
MediumIs it idempotent?run it twice — do the numbers double
MediumIs the structure sensible?can a new feature be added without a rewrite
LowIs 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

MistakeWhy it costs you
No README, or three linesThe reviewer has to read code to understand anything
Does not run — missing dep, hard-coded pathOften scored without being run
Jupyter notebook onlySuggests you do not know how production code is structured
Zero testsThe most common single reason for rejection
Silently dropping rowsThe bug they are specifically checking for
Forty hours of work on a four-hour briefReads as poor judgement, not enthusiasm
Hard-coded run dateCannot be backfilled; shows a gap in pipeline thinking
print() instead of loggingSmall, 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:

  1. A two-minute walkthrough — the layers, the grain, the decisions. Do not read code aloud.
  2. The findings — the orphans, the duplicates, the undocumented status. Leading with what you found in their data changes the tone of the whole call.
  3. 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

BehaviourSignal
README explains decisions and findingssenior
Reconciliation check that blocks the runsenior
Idempotent, and says so with instructions to verifysenior
Reported data quality findings from their datasenior
Scoped to the stated time and documented the restsenior
Clean structure, meaningful testsmid-to-senior
Runs, correct, thin README, few testsmid
Notebook, no tests, silently drops rowsjunior

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.

Frequently Asked Questions

How much time should a take-home actually take?
The stated time, plus a README explaining what you would do with more. Reviewers compare submissions against each other, and one that says 'four hours, here is what I cut and why' reads better than an over-built one that ignored the brief.
What do reviewers actually look at first?
The README, then whether it runs, then the tests, then the code. A project that cannot be run in one command is often scored without being run at all — which means the code you spent the most time on is never read.
Should a take-home have tests?
Yes, and a small number of meaningful ones beats broad coverage. Test the transformation logic, one edge case such as empty input, and one data-quality assertion. Zero tests is the most common reason a technically correct submission is rejected.
How do I handle an unreasonable take-home?
Time-box it to what was stated, deliver a working subset, and document the rest in the README. If the brief needs twenty hours, say so politely and submit the four-hour version — reviewers respect a scoped answer, and a company that does not is telling you something useful.