Data Engineering Module 1 – Foundations: what data engineers build
Pipeline requirements: SLAs, freshness, sizing
Translate a vague request into freshness, completeness and recovery targets with owners, size storage and peak load from measurements, and fit it on one page.
What you will learn
- Translate a vague request into freshness, completeness and correctness targets, each with an owner
- Set recovery point and recovery time objectives that count noticing, restoring and rerunning
- Estimate daily volume, storage growth and peak events a second with a small script
- Recognise when a requirement is really a quality, security or legal duty
- Write a one-page requirements document with readers, owners and an escalation path
Before you start
What you’ll learn · Before you start
What you will learn
- Translate a vague request into freshness, completeness and correctness targets, each with an owner
- Set recovery point and recovery time objectives that count noticing, restoring and rerunning
- Estimate daily volume, storage growth and peak events a second with a small script
- Recognise when a requirement is really a quality, security or legal duty
- Write a one-page requirements document with readers, owners and an escalation path
Before you start
On this page
The canteen operators of Campus Bite, this track’s invented campus food-ordering platform, ask for one thing: “yesterday’s payouts, early and complete”. Over 30 nights in the first program below, the payout job finished without an error every night, yet the file was both on time and complete on only 24 of them, and nobody noticed, because nobody had written down what early and complete meant.
A pipeline requirement is a promise you can measure: how fresh, complete and correct the data is when someone reads it, how much a failure may lose and how soon the data is back, how long it is kept, and what storage and throughput it needs in its busiest minute. The sections below turn the operators’ sentence into targets with owners, size the storage and the peak from measurements, and put it all on one page.
A vague request becomes targets you can measure
“Early” and “complete” cannot fail a test. Is a file published at 07:10 early? Is a file missing 30 of 40,000 orders complete? It depends on who reads the file and what they do with it, so a requirement starts with its readers. Step through how the operators’ sentence becomes targets, each with someone who answers for it:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The ask. The canteen operators ask for "yesterday's payouts, early and complete". Nobody can test that sentence: is a file at 07:10 early, and is a file missing 30 orders complete?
- Find the readers first. Ask who reads the file and what they do with it. The operators download it at 06:30 to check the night's payout, and finance pays them at 09:00. Their times, not the job's, decide every target.
- Early becomes a time. Early becomes a freshness target: the file is published by 06:00, half an hour before the first reader, on at least 29 of 30 days. The margin covers a slow night; the 29 says how often a miss is acceptable.
- Complete needs a count. Complete becomes a share of a count the pipeline did not produce: at least 99.9 % of the day's settled orders, counted from the payment provider's settlement report. A job that compares its output with its own input always looks complete.
- Right is a check too. Nobody asked for the payouts to be right, but a wrong payout costs more than a late one. The file is held back when its total differs from the settlement total, so a wrong file never reaches a canteen.
- Someone answers for it. Each target gets an owner who is told when it is missed, and an escalation path with times: the on-call engineer at 05:30 if nothing is published, the payments data lead at 06:00, finance operations at 06:30.
- Objectives, not yet an agreement. The freshness, completeness and correctness lines are service-level objectives, targets the team sets and measures. If Campus Bite promised the canteens a refund for every late file, the same targets would sit inside an SLA, an agreement with a consequence.
The scene ends with three words that Google’s SRE book keeps apart (Service Level Objectives). A service level indicator (SLI) is a measurement, such as the time the file appeared. A service-level objective: A target for a measured property of a service, with how often it must hold, such as a file published by 06:00 on at least 29 of 30 days. A promise to customers with a consequence for missing it is a service level agreement. (SLO) sets the target an SLI must meet, a single value or a range, together with how often it must hold: published by 06:00 on at least 29 of 30 days. A service level agreement (SLA) is a promise to someone outside the team with a consequence, such as a refund, when its objectives are missed.
Pipeline targets fall into a few kinds, most with examples in Google’s SRE workbook (Data Processing Pipelines):
- data freshness: How old data is when someone reads it, or how soon after an event it can be read. Written as a target, for example that the oldest record is no older than a stated time.: how old the data may be when it is read, such as “the oldest data is no older than Y”.
- completeness: The share of the expected records that a dataset holds by a stated time, counted against a source the pipeline does not produce, such as a provider's own report.: the share of the expected records present by a time, counted against a source the pipeline does not produce.
- Correctness: how much wrong data is acceptable; the workbook’s example allows at most 0.1 % of invoices to be wrong in a quarter.
- End-to-end latency: from the event to the moment a reader can use it, summed over every stage.
- retention period: How long data is kept before it is deleted. It can be a minimum that a business or legal duty sets, and it ends with a job that deletes the older data., recovery and size, which the later sections take one at a time.
Every target needs an owner who is told when it is missed, and an escalation path with times. A target that pages nobody is a wish.
Checkpoint
Check: Targets with owners
Freshness and completeness are measured where the data is read
A target is only as good as its measurement. This program replays 30 nights of the payout job against the scene’s two targets: published by 06:00, and at least 99.9 % of the day’s settled orders as the payment provider’s settlement report counts them, each on 29 of 30 days. Run it and read the last five lines:
"""Thirty days of Campus Bite's daily payout file, checked against its two targets.
Each night a job starts at 04:30, reads the payment provider's settled orders of the day before
and publishes the payout file for the canteens. The targets are measured where the file is read:
fresh: the file is published by 06:00
complete: it holds at least 99.9 % of the day's settled orders, counted from the provider's
own settlement report, not from what the job happened to read
The runs are synthetic and seeded, so every run of this program prints the same 30 days.
"""
import random
SEED = 20261017
DAYS = 30
START = 4 * 60 + 30 # the job starts at 04:30 (minutes after midnight)
DEADLINE = 6 * 60 # published by 06:00
COMPLETE = 99.9 # per cent of the settled orders
TARGET_DAYS = 29 # each target must hold on at least 29 of the 30 days
def clock(minutes):
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def simulate(rng):
days = []
for day in range(1, DAYS + 1):
settled = 38_000 + int(rng.random() * 6_000)
# Most nights every settled order has arrived before 04:30; on some, part of the
# provider's file comes later and the job never sees it.
late = int(settled * (0.002 + rng.random() * 0.006)) if rng.random() < 0.08 else 0
# Most runs take 28 to 52 minutes; a slow night takes 85 to 110.
minutes = 85 + int(rng.random() * 26) if rng.random() < 0.02 else 28 + int(rng.random() * 25)
days.append({"day": day, "settled": settled, "in_file": settled - late, "published": START + minutes})
return days
def main():
days = simulate(random.Random(SEED))
print(f"Campus Bite payout file: {DAYS} nightly runs (seeded)")
print(f"Targets: published by {clock(DEADLINE)}, holding at least {COMPLETE} % of the day's settled orders")
print()
print("Days that missed a target:")
on_time = complete = both = 0
shares = []
for d in days:
share = 100 * d["in_file"] / d["settled"]
shares.append(share)
fresh = d["published"] <= DEADLINE
full = share >= COMPLETE
on_time += fresh
complete += full
both += fresh and full
if not (fresh and full):
missing = d["settled"] - d["in_file"]
what = f"{missing:,} of {d['settled']:,} orders missing" if missing else "nothing missing"
print(f" day {d['day']:>2}: published {clock(d['published'])}, {share:6.2f} % complete ({what})")
print()
print(f"The job finished without an error: {DAYS} of {DAYS} days")
print(f"Published by {clock(DEADLINE)}: {on_time} of {DAYS} days, target {TARGET_DAYS}: {'met' if on_time >= TARGET_DAYS else 'missed'}")
print(f"At least {COMPLETE} % complete: {complete} of {DAYS} days, target {TARGET_DAYS}: {'met' if complete >= TARGET_DAYS else 'missed'}")
print(f"Fresh and complete, as a reader saw it: {both} of {DAYS} days")
print(f"Completeness averaged over the month: {sum(shares) / DAYS:.2f} %")
main() Output
Campus Bite payout file: 30 nightly runs (seeded) Targets: published by 06:00, holding at least 99.9 % of the day's settled orders Days that missed a target: day 6: published 05:11, 99.21 % complete (332 of 42,255 orders missing) day 18: published 05:10, 99.69 % complete (126 of 41,304 orders missing) day 21: published 05:21, 99.27 % complete (288 of 39,270 orders missing) day 24: published 06:07, 100.00 % complete (nothing missing) day 26: published 05:16, 99.45 % complete (235 of 42,590 orders missing) day 28: published 05:11, 99.72 % complete (114 of 40,406 orders missing) The job finished without an error: 30 of 30 days Published by 06:00: 29 of 30 days, target 29: met At least 99.9 % complete: 25 of 30 days, target 29: missed Fresh and complete, as a reader saw it: 24 of 30 days Completeness averaged over the month: 99.91 %
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 slo_check.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The job finished without an error every night, and the freshness target was met: only day 24’s file was late, at 06:07. The completeness target was missed. On days 6, 18, 21, 26 and 28, part of the provider’s file arrived after the job had read it, so between 114 and 332 orders were missing from files that looked finished, and a reader who needed both saw a good file on 24 days. Averaged over the month, completeness was 99.91 %, above the target: the average hid all five short days.
Change the promise, not the data
Press Edit, set COMPLETE to 99.0 and run it again: every day passes the completeness target, and 29 files are
fresh and complete. The same orders are still missing; only the promise changed. Targets come from what readers
need, not from what the pipeline already does.
Three habits follow from the run:
- Measure at the reader’s end: when the file appeared where the operators download it, not when the job logged its last step.
- Count against a source the pipeline does not produce: compared with what the job read, a file always looks complete.
- Count the days that meet the target, as “29 of 30” does, instead of averaging over a month.
A green job is not a fresh, complete file
Wrong: alert when the payout job fails. On day 6 it succeeded at 05:11 with 332 orders missing, and nothing fired. Right: alert on the targets where the file is read: no file by 06:00, or a file holding less than 99.9 % of the settlement report’s count.
The freshness target also chooses the delivery: a nightly batch can meet “by 06:00”, while “no older than five minutes” needs a micro-batch or a stream, with the costs that the data lifecycle lesson compares.
Key idea
Measure each target where the reader meets the data, against a count the pipeline did not produce, day by day: a successful job and a good monthly average can both hide the bad days.
Checkpoint
Check: Measuring where the data is read
Recovery objectives say how much may be lost and for how long
Freshness and completeness describe an ordinary night; recovery objectives describe the worst one. NIST’s contingency planning guide defines both (SP 800-34 Rev. 1). The recovery point objective: The most data a failure may lose, measured as the time between the last point the data can be recovered to and the failure. Input kept for processing again moves that point forward. (RPO) is the point in time to which data can be recovered from the most recent backup, so it limits how much a failure may lose. The recovery time objective: The longest a system or dataset may stay unavailable after a failure before the harm is unacceptable, counting the time to notice, to restore and to process data again. (RTO) is the longest a system may stay unavailable before the harm to the business is unacceptable. Walk through one bad night of the payout tables:
- 00:00
The nightly snapshot is taken restore point
The warehouse copies the payout tables to a second location. This is the newest copy a restore can start from, so a restore can bring back the tables only as they were at midnight.
- 04:30
The payout job writes the new day writes after the copy
The job reads the day's settled orders, which arrive as files that are kept for 30 days, and writes the new payout rows into the warehouse tables.
- 05:05
The volume holding the tables is lost up to 5 h 5 min lost
Every row written since midnight was on the lost volume. Restoring the snapshot alone would lose 5 hours and 5 minutes of writes: that gap is the data loss a recovery point objective limits.
- 05:20
The on-call engineer is paged detection
Fifteen minutes pass before anyone knows. Detection is part of the recovery time: readers cannot tell a failure nobody has noticed from a slow night.
- 06:35
The tables are back as they were at midnight restore done
The restore takes 1 hour and 15 minutes. The day's payout rows are still missing, so the file cannot be published yet, and the 06:00 freshness target has already passed.
- 07:20
The job runs again and the file is published recovered after 2 h 15 m
The settled-order files were kept, so the job runs again on the same input and rebuilds every lost row: in the end no data is lost. Readers waited from 05:05 to 07:20, 2 hours and 15 minutes, because the rerun counts as recovery time too.
The timeline has 6 events, from 00:00 to 07:20.
Read the night twice. As a backup question, the newest copy was from midnight, so a restore alone would have lost 5 hours and 5 minutes of writes. As a pipeline question, nothing was lost: the settled-order files were kept for 30 days, and running the job again rebuilt every row. Kept input is a second backup when it is kept longer than the longest gap you may have to rebuild, and when a run on the same input gives the same rows, an idempotent run: A pipeline run that produces the same output for the same input, no matter how many times it executes. More in “What data engineering is, and what it is not”.
Recovering takes longer than restoring. NIST says that time spent reprocessing data must be added to the RTO, and the minutes before anyone notices count too. The readers waited 2 hours and 15 minutes, and the 06:00 target was missed, which its one-in-30 allowance covers.
Set the objectives from the readers’ deadlines. The job ends by about 05:20 and finance pays at 09:00, so an RTO of 3 hours recovers a failure at 05:20 by 08:20. With the input files kept for 30 days, the RPO is no lost settled order.
Key idea
For a pipeline, kept input meets the RPO as surely as a backup of its output, and the RTO counts every minute until readers have the data again: noticing, restoring and running the job again.
Checkpoint
Check: Recovery point and recovery time
Storage is arithmetic: events, bytes, compression and days
How much space does a year of order events take? Events a day times bytes per event, divided by how many times smaller the stored layout is, times the days you keep. The events and the days come from the business; the bytes and the ratio depend on your rows, so measure them. This program writes the same 200,000 synthetic orders in three layouts of its own: CSV, JSON Lines, and a layout packed by column that stores each column apart, numbers in binary, each repeated name once and each time as the seconds since the previous order. It uses no compressor:
"""Bytes per order in three layouts: CSV, JSON Lines and a column-wise packed binary.
The program makes 200,000 synthetic orders of the invented Campus Bite platform (seeded, so
every run prints the same numbers), writes them in three layouts into memory and counts the
bytes. No general-purpose compressor is used: the packed layout only stores each column on
its own, numbers as binary, repeated names once, and times as the seconds since the previous
order.
"""
import csv
import io
import json
import random
from bisect import bisect
from itertools import accumulate
SEED = 20261017
ORDERS = 200_000
DAYS = 5
FIRST_DAY = "2031-03-04" # the synthetic data's first day (UTC); the next four follow it
CAMPUSES = ["north", "south", "east", "west", "river", "hill", "lake", "park", "city", "valley", "garden", "harbour"]
STATUSES = ["delivered", "cancelled", "refunded"]
STATUS_SHARE = list(accumulate([92, 5, 3]))
# Share of the day's orders placed in each hour (breakfast, a lunch rush, dinner).
HOUR_SHARE = list(accumulate([1, 1, 0, 0, 0, 1, 2, 4, 7, 6, 5, 8, 14, 13, 6, 4, 4, 5, 7, 9, 8, 5, 3, 2]))
COLUMNS = ["order_id", "placed_at", "campus", "canteen", "rider", "status", "items", "amount"]
def arrival(r):
"""When one order is placed, in seconds from the start of the first day."""
day = int(r() * DAYS)
hour = bisect(HOUR_SHARE, r() * HOUR_SHARE[-1])
# Lectures end at 12:00 and 13:00: half of those two hours' orders come in their first 10 minutes.
if hour in (12, 13) and r() < 0.5:
second = int(r() * 600)
else:
second = int(r() * 3_600)
return day * 86_400 + hour * 3_600 + second
def make_orders(rng):
"""200,000 orders in time order. placed_at is in seconds from the start of the first day;
amounts are whole numbers of the smallest currency unit."""
r = rng.random
times = sorted(arrival(r) for _ in range(ORDERS))
orders = []
for n, t in enumerate(times):
status = STATUSES[bisect(STATUS_SHARE, r() * STATUS_SHARE[-1])]
orders.append({
"order_id": 1_000_001 + n,
"placed_at": t,
"campus": CAMPUSES[int(r() * len(CAMPUSES))],
"canteen": 1 + int(r() * 60),
"rider": 0 if status == "cancelled" else 1 + int(r() * 2_400),
"status": status,
"items": 1 + int(r() * 6),
"amount": (30 + int(r() * 571)) * 100,
})
return orders
def stamp(t):
"""An ISO 8601 UTC time for t seconds after the start of the first day."""
day, rest = divmod(t, 86_400)
year, month, first = FIRST_DAY.split("-")
return f"{year}-{month}-{int(first) + day:02d}T{rest // 3600:02d}:{rest // 60 % 60:02d}:{rest % 60:02d}Z"
def as_csv(orders, stamps):
out = io.StringIO()
w = csv.writer(out, lineterminator="\n")
w.writerow(COLUMNS)
w.writerows([o["order_id"], s, o["campus"], o["canteen"], o["rider"], o["status"], o["items"], o["amount"]] for o, s in zip(orders, stamps))
return out.getvalue().encode("utf-8")
def as_json_lines(orders, stamps):
encode = json.JSONEncoder(separators=(",", ":")).encode
lines = [encode(dict(o, placed_at=s)) for o, s in zip(orders, stamps)]
return ("\n".join(lines) + "\n").encode("utf-8")
def put_varint(out, n):
"""An unsigned number in 7-bit groups: 0-127 take one byte, 128-16,383 two, and so on."""
while n >= 0x80:
out.append((n & 0x7F) | 0x80)
n >>= 7
out.append(n)
def deltas(values):
"""The first value, then each value minus the one before it, every one as a varint."""
out = bytearray()
put_varint(out, values[0])
for before, now in zip(values, values[1:]):
put_varint(out, now - before)
return bytes(out)
def dictionary(values):
"""The distinct names once, then one byte per row that says which name it is."""
names = sorted(set(values))
index = {name: i for i, name in enumerate(names)}
head = json.dumps(names).encode("utf-8")
out = bytearray()
put_varint(out, len(head))
return bytes(out) + head + bytes(index[v] for v in values)
def varints(values):
out = bytearray()
for v in values:
put_varint(out, v)
return bytes(out)
def as_packed(orders):
"""Column by column, each column a block of bytes with its length in front."""
col = {name: [o[name] for o in orders] for name in COLUMNS}
blocks = {
"order_id": deltas(col["order_id"]),
"placed_at": deltas(col["placed_at"]),
"campus": dictionary(col["campus"]),
"canteen": bytes(col["canteen"]),
"rider": b"".join(r.to_bytes(2, "little") for r in col["rider"]),
"status": dictionary(col["status"]),
"items": bytes(col["items"]),
"amount": varints(col["amount"]),
}
out = bytearray()
head = json.dumps({"rows": len(orders), "columns": COLUMNS}).encode("utf-8")
put_varint(out, len(head))
out += head
for block in blocks.values():
put_varint(out, len(block))
out += block
return bytes(out), blocks
def main():
orders = make_orders(random.Random(SEED))
stamps = [stamp(o["placed_at"]) for o in orders]
csv_bytes = as_csv(orders, stamps)
json_bytes = as_json_lines(orders, stamps)
packed, blocks = as_packed(orders)
print(f"{ORDERS:,} synthetic Campus Bite orders over {DAYS} days (seeded)")
print()
print("The first order in the two text layouts:")
print(" CSV " + csv_bytes.split(b"\n")[1].decode())
print(" JSON Lines " + json_bytes.split(b"\n")[0].decode())
print()
print(f"{'layout':<18}{'bytes':>12}{'per order':>11}{'vs CSV':>8}")
for name, data in [("CSV", csv_bytes), ("JSON Lines", json_bytes), ("packed by column", packed)]:
print(f"{name:<18}{len(data):>12,}{len(data) / ORDERS:>11.1f}{len(data) / len(csv_bytes):>8.2f}")
print()
print("Bytes per order in the packed layout, column by column:")
for name, block in blocks.items():
print(f" {name:<10}{len(block) / ORDERS:>5.2f}")
print()
print(f"CSV takes {len(csv_bytes) / len(packed):.1f} times the bytes of the packed layout,")
print(f"and JSON Lines {len(json_bytes) / len(packed):.1f} times.")
main() Output
200,000 synthetic Campus Bite orders over 5 days (seeded)
The first order in the two text layouts:
CSV 1000001,2031-03-04T00:00:07Z,harbour,17,0,cancelled,4,17900
JSON Lines {"order_id":1000001,"placed_at":"2031-03-04T00:00:07Z","campus":"harbour","canteen":17,"rider":0,"status":"cancelled","items":4,"amount":17900}
layout bytes per order vs CSV
CSV 11,989,127 59.9 1.00
JSON Lines 28,789,067 143.9 2.40
packed by column 2,153,376 10.8 0.18
Bytes per order in the packed layout, column by column:
order_id 1.00
placed_at 1.00
campus 1.00
canteen 1.00
rider 2.00
status 1.00
items 1.00
amount 2.77
CSV takes 5.6 times the bytes of the packed layout,
and JSON Lines 13.4 times.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 layout_sizes.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
One order took 59.9 bytes as CSV, 143.9 as JSON Lines, which repeats every field name on every line, and 10.8 packed by column: CSV needed 5.6 times the bytes of the packed layout, JSON Lines 13.4 times. The packed layout uses two encodings that the Parquet format defines, a dictionary for repeated values and deltas for integers (Parquet encodings). Parquet writers usually add a compression codec on top, and DuckDB’s documentation notes that lightweight codecs such as Snappy, LZ4 and zstd decompress faster than gzip (DuckDB: file formats).
Now Campus Bite’s arithmetic. A term day has about 40,000 orders, and each sends six events (placed, paid, accepted, ready, picked up, delivered) that carry the whole order row: 240,000 events of 144 bytes, or 34.6 MB a day as JSON Lines. Kept 400 days, a year of payouts for disputes plus a month to spare, they fill 13.82 GB, or 1.03 GB packed. After that, storage stops growing if a job deletes each day once it is 400 days old.
A compression ratio from someone else's data
Wrong: read that one format is ten times smaller than another and divide by 10. Right: write one real day of your own rows in the layout you will store, divide the sizes, and record the ratio and its source. Field names, repeated values and sort order change it from one dataset to the next.
These estimates use decimal units, as storage is sold (1 GB is 1,000,000,000 bytes); a tool that counts in binary units shows the same 13.82 GB as 12.87 GiB. Two tools help with the inputs:
Data Storage Converter Convert an estimate between GB and GiB, or bytes and terabytes, before you compare it with a disk or a bill. Mock Data Generator Make a day of synthetic rows in your own schema, then count their bytes as the layout program does.Checkpoint
Check: Storage arithmetic
Size for the busiest minute, not the average
Storage follows the days you keep; throughput follows the busiest minute. Students order when lectures end, so Campus Bite’s orders bunch at lunch. This program makes the arrival times of the same 200,000 orders, prints an average minute of each hour and finds the busiest minute:
"""When Campus Bite's orders arrive: the hour-by-hour profile, the busiest minute and the peak factor.
The arrival times are made exactly as in layout_sizes.py (the same seed, the same draws), so these
are the same 200,000 synthetic orders. The peak factor is the busiest minute's orders divided by
the orders of an average minute: the number a sizing estimate multiplies the average by.
"""
import random
from bisect import bisect
from collections import Counter
from itertools import accumulate
SEED = 20261017
ORDERS = 200_000
DAYS = 5
EVENTS_PER_ORDER = 6 # placed, paid, accepted, ready, picked up, delivered
HOUR_SHARE = list(accumulate([1, 1, 0, 0, 0, 1, 2, 4, 7, 6, 5, 8, 14, 13, 6, 4, 4, 5, 7, 9, 8, 5, 3, 2]))
PER_HASH = 5 # orders a minute that one # stands for
def arrival(r):
"""When one order is placed, in seconds from the start of the first day."""
day = int(r() * DAYS)
hour = bisect(HOUR_SHARE, r() * HOUR_SHARE[-1])
# Lectures end at 12:00 and 13:00: half of those two hours' orders come in their first 10 minutes.
if hour in (12, 13) and r() < 0.5:
second = int(r() * 600)
else:
second = int(r() * 3_600)
return day * 86_400 + hour * 3_600 + second
def main():
r = random.Random(SEED).random
times = sorted(arrival(r) for _ in range(ORDERS))
minutes = DAYS * 24 * 60
average = ORDERS / minutes # orders in an average minute
by_hour = Counter(t // 3_600 % 24 for t in times)
by_minute = Counter(t // 60 for t in times)
busiest, most = max(sorted(by_minute.items()), key=lambda kv: kv[1])
day, rest = divmod(busiest, 24 * 60)
print(f"When {ORDERS:,} synthetic orders are placed ({DAYS} days, seeded)")
print()
print(f"Orders a minute, hour by hour, averaged over the {DAYS} days (each # is {PER_HASH} orders a minute):")
for hour in range(24):
rate = by_hour[hour] / (DAYS * 60)
print(f" {hour:02d}:00 {rate:6.1f} {'#' * round(rate / PER_HASH)}")
top_hour = max(range(24), key=lambda h: by_hour[h])
top_rate = by_hour[top_hour] / (DAYS * 60)
factor = most / average
print()
print(f"Average minute: {average:5.1f} orders")
print(f"Busiest hour, a minute: {top_rate:5.1f} orders ({top_hour:02d}:00 to {top_hour + 1:02d}:00), {top_rate / average:.1f} times the average")
print(f"Busiest minute: {most:5d} orders (day {day + 1} at {rest // 60:02d}:{rest % 60:02d}), {factor:.1f} times the average")
print()
print(f"Peak factor for sizing: {factor:.1f}")
print(f"With {EVENTS_PER_ORDER} events an order: {average * EVENTS_PER_ORDER / 60:.1f} events a second on average, "
f"at most {most * EVENTS_PER_ORDER / 60:.1f} in the busiest minute")
main() Output
When 200,000 synthetic orders are placed (5 days, seeded) Orders a minute, hour by hour, averaged over the 5 days (each # is 5 orders a minute): 00:00 5.8 # 01:00 6.0 # 02:00 0.0 03:00 0.0 04:00 0.0 05:00 5.6 # 06:00 11.2 ## 07:00 22.8 ##### 08:00 40.4 ######## 09:00 35.5 ####### 10:00 28.4 ###### 11:00 46.5 ######### 12:00 80.7 ################ 13:00 76.1 ############### 14:00 34.7 ####### 15:00 23.4 ##### 16:00 23.3 ##### 17:00 28.6 ###### 18:00 40.9 ######## 19:00 52.3 ########## 20:00 46.0 ######### 21:00 28.9 ###### 22:00 17.6 #### 23:00 11.9 ## Average minute: 27.8 orders Busiest hour, a minute: 80.7 orders (12:00 to 13:00), 2.9 times the average Busiest minute: 311 orders (day 4 at 12:03), 11.2 times the average Peak factor for sizing: 11.2 With 6 events an order: 2.8 events a second on average, at most 31.1 in the busiest minute
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 peaks.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
An average minute holds 27.8 orders and a minute of the busiest hour 80.7, but the busiest minute, 12:03 on day 4, holds 311: 11.2 times the average. That ratio is the peak factor: The busiest period's rate divided by the average rate, such as the busiest minute's events against an average minute's. Multiplying the average by it sizes a system for the peak., and an estimate multiplies the average by it; sizing from the busiest hour would plan for only 2.9 times the average. Peaks have causes you can name, such as a lecture break, a sale launch, a festival evening or a payday, and a requirement says which one it was sized for.
The estimator combines these measurements with the 400 days. Change any input in SCENARIOS and run it again:
"""Storage and peak load for Campus Bite's order events, from inputs you can change.
Change the numbers in SCENARIOS and run it again. Every figure it prints is a multiplication
or a division of those inputs:
events a day = orders a day x events per order
raw bytes a day = events a day x bytes per event
stored bytes a day = raw bytes a day / compression ratio
stored at the end = stored bytes a day x retention days (older days are deleted)
average per second = events a day / 86,400
peak per second = average per second x peak factor
"""
SECONDS_A_DAY = 86_400
MB = 1_000_000 # decimal units, as storage is sold: 1 GB is 1,000,000,000 bytes
GB = 1_000_000_000
# Where the inputs come from:
# 40,000 orders a day: the synthetic term days of layout_sizes.py and peaks.py (200,000 orders in 5 days).
# 6 events an order (placed, paid, accepted, ready, picked up, delivered), each carrying the whole order
# row, so an event is the size of one row as JSON Lines: 144 bytes in layout_sizes.py.
# 13.4: how many times smaller layout_sizes.py measured the same rows packed by column.
# 400 days: a year of payouts for disputes, plus a month to spare.
# 11.2: the busiest minute against the average minute, measured by peaks.py. Using it for all six
# events of an order is on the safe side: an order's later events come minutes after it is placed.
SCENARIOS = [
# name, orders a day, events per order, bytes per event, compression, days, peak factor
("This year, as JSON Lines", 40_000, 6, 144, 1.0, 400, 11.2),
("This year, packed by column", 40_000, 6, 144, 13.4, 400, 11.2),
("Next year: twice the orders", 80_000, 6, 144, 13.4, 400, 11.2),
]
def size(value):
"""Bytes as MB below a gigabyte and as GB above it."""
return f"{value / GB:,.2f} GB" if value >= GB else f"{value / MB:,.1f} MB"
for name, orders, per_order, nbytes, ratio, days, peak_factor in SCENARIOS:
events = orders * per_order
raw_a_day = events * nbytes
stored_a_day = raw_a_day / ratio
average = events / SECONDS_A_DAY
print(name)
print(f" {orders:,} orders x {per_order} events x {nbytes} bytes, compression {ratio}x, kept {days} days")
print(f" events a day {events:>10,}")
print(f" raw data a day {size(raw_a_day):>10}")
print(f" stored a day {size(stored_a_day):>10}")
print(f" stored after 30 days {size(stored_a_day * 30):>10}")
print(f" stored after {days} days {size(stored_a_day * days):>10}")
print(f" average per second {average:>7.1f} events")
print(f" peak per second ({peak_factor}x) {average * peak_factor:>7.1f} events")
print()
print("Storage grows until the oldest day is deleted; the consumers must keep up with the peak.") Output
This year, as JSON Lines 40,000 orders x 6 events x 144 bytes, compression 1.0x, kept 400 days events a day 240,000 raw data a day 34.6 MB stored a day 34.6 MB stored after 30 days 1.04 GB stored after 400 days 13.82 GB average per second 2.8 events peak per second (11.2x) 31.1 events This year, packed by column 40,000 orders x 6 events x 144 bytes, compression 13.4x, kept 400 days events a day 240,000 raw data a day 34.6 MB stored a day 2.6 MB stored after 30 days 77.4 MB stored after 400 days 1.03 GB average per second 2.8 events peak per second (11.2x) 31.1 events Next year: twice the orders 80,000 orders x 6 events x 144 bytes, compression 13.4x, kept 400 days events a day 480,000 raw data a day 69.1 MB stored a day 5.2 MB stored after 30 days 154.7 MB stored after 400 days 2.06 GB average per second 5.6 events peak per second (11.2x) 62.2 events Storage grows until the oldest day is deleted; the consumers must keep up with the peak.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sizing.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
This year the events arrive at 2.8 a second on average and at up to 31.1 a second in the busiest minute; next year, with twice the orders, the peak is 62.2. Suppose one consumer writes an event in 20 ms, so it handles 50 a second. Start the model at this year’s peak, 31 a second, then raise the arrivals to next year’s 62:
At 62 % utilisation a request spends 52.6 ms in the system on average, 2.6 times its 20 ms of service: the rest is waiting. Little's law: L = λW = 1.63 requests in the system.
The numbers as a table
| Utilisation | Mean time in the system |
|---|---|
| 50 % | 40 ms |
| 70 % | 66.7 ms |
| 80 % | 100 ms |
| 90 % | 200 ms |
| 95 % | 400 ms |
| 99 % | 2,000 ms |
At 31 a second the consumer is busy 62 % of the time, and an event spends 52.6 ms in the system on average, 2.6 times its 20 ms of work. At 62 it gets more than it can serve, and the queue grows for as long as the burst lasts, although next year’s average would keep it busy only 5.6 ÷ 50, or 11 %, of the time. The model is simple, one consumer and random arrivals, but its warning holds for any consumer whose peak is above its capacity.
A generous margin over the average
Wrong: size for the average with a tenfold margin. Ten times this year’s 2.8 events a second is 28, below the 31.1 of the busiest minute. Right: measure the busiest minute of a real day, or of last year’s busiest day, multiply, and record the peak factor and its source.
Checkpoint
Check: Sizing for the busiest minute
Some requirements are quality, security or legal duties
Not every sentence of a request is about time or size. Three things the operators, finance and the security team said about the payout file are other kinds of requirement, each with its own owner and check:
| What someone said | The kind of requirement | What the pipeline must do |
|---|---|---|
| “The payouts must be right.” | Quality: correctness | Compare the file’s total with the settlement total, and hold the file back when they differ |
| “Only finance may see the canteens’ bank accounts.” | Security: access | Keep account numbers in a column only finance’s role can read, masked everywhere else |
| “Keep a year of payout records.” | Retention, perhaps a legal duty | Ask who requires it and why; keep the records that long, then delete them with a job |
Each of these shapes the design more than a freshness target does: a correctness rule needs a second count from outside the pipeline, an access rule decides which tables may hold which columns, and a retention rule needs two jobs, one that keeps the data for the required period and one that deletes it afterwards. Exam guides list such duties beside the technical ones: Google’s Professional Data Engineer guide names legal and regulatory compliance, and AWS’s Data Engineer Associate guide includes managing data lifecycles.
A retention duty in India's data protection rules
India’s Digital Personal Data Protection Rules, 2025 (G.S.R. 846(E), Ministry of Electronics and Information Technology) contain such a duty. Rule 8(3) says that a Data Fiduciary shall retain personal data, associated traffic data and other logs of the processing for at least one year from the processing, for the purposes in the rules’ Seventh Schedule, and then erase them unless another law requires longer. Rule 6(1)(e) asks for such logs and personal data to be kept for one year to detect and investigate unauthorised access. Rule 1(4) says that rules 6 and 8, among others, come into force eighteen months after the rules’ publication in the Official Gazette. That is what the text says, not legal advice: whether it applies to a pipeline is for the organisation’s legal team, and the requirements page records their answer as its source.
A sentence that names a person, a duty or a law has an owner outside the data team: record who it is and where the duty comes from.
Checkpoint
Check: Quality, security and legal duties
One page holds every requirement, its owner and its reader
Everything above fits on one page, and it should: a requirement kept in a chat thread is lost by the next incident. Here is the payout file’s page, in the order an on-call engineer would read it:
| Line | The payout file |
|---|---|
| Readers | Canteen operators download it from 06:30; finance pays from it at 09:00 |
| Owner | The payments data team |
| Freshness | Published by 06:00, measured where the operators download it, on at least 29 of 30 days |
| Completeness | At least 99.9 % of the day’s settled orders by the settlement report’s count, on at least 29 of 30 days |
| Correctness | The file’s total equals the settlement total, or the file is held back |
| Recovery | RPO: no settled order lost, as the input files are kept 30 days. RTO: 3 hours from failure to published file |
| Retention | Payout rows kept 400 days, then deleted by a job; the source of the period recorded |
| Size | 240,000 order events a day; 1.03 GB packed after 400 days; up to 31.1 events a second (peak factor 11.2) |
| Sensitive data | The canteens’ bank accounts: finance’s role only, masked elsewhere |
| Escalation | 05:30 on-call engineer if nothing is published; 06:00 payments data lead; 06:30 finance operations |
Every line says what is promised, how it is measured and who hears when it is missed; a line without a measurement or an owner is unfinished. Keep the page beside the pipeline’s code, and change it before the pipeline when a reader’s needs change.
Checkpoint
Check: The one-page requirements document
Practise: requirements for the rider payouts
Campus Bite also pays its 2,400 riders every Monday for the week before, and that pipeline has no page yet. The lesson’s programs run in your local lab too, with numbers of your own. The exercise gives the pipeline’s readers, volumes and deadlines; you write the targets and the estimates, and the sample tests recompute your storage and peak figures from your own inputs:
Exercise · Easy · Python
Write the requirements page of the rider payouts
Campus Bite pays its 2,400 riders every Monday for the deliveries of the week before. Write the pipeline's one-page requirements document as the dictionary that rider_payout_doc() in payout_doc.py returns: replace every None and fill the two lists. Times are "HH:MM" strings, percentages are numbers from 0 to 100, and sizes are in decimal GB (1 GB = 1,000,000,000 bytes).
What the team knows:
- Readers. Riders read their weekly statement in the app from 08:00 on Monday. The bank transfer file goes to the bank at 10:00 on Monday.
- The job. It starts at 05:00 on Monday and usually ends by 06:00. It may have to rebuild a whole week, so it needs the week's delivery events.
- Volume. About 36,000 deliveries a day. Each delivery arrives as one event, as it happens, and becomes one payout line of about 120 bytes as JSON Lines. One sample day of lines was 10 times smaller packed by column, and its busiest minute held 11 times the deliveries of an average minute.
- Retention. Finance keeps payout lines for three years (1,095 days), for disputes and tax questions.
Fill in:
readersandescalation: lists of(time, who)pairs, in time order. The escalation must reach someone before the bank file goes at 10:00.freshness: the latest publication time and on how many of 30 runs it must hold. The file must be ready before its first reader.completeness: the smallest share of the week's deliveries the statements must cover, and what it is counted from: one ofCOUNT_SOURCES.correctnessandowner: one sentence each.retention_days, andrecovery: how many payout lines a failure may lose, how many days of input you keep, and the RTO in hours, so that a failure at 06:00 is recovered before the bank file goes.size: your inputs (never fewer deliveries, smaller lines, better compression or a smaller peak than the facts above) and your two estimates, the storage at the end of the retention period and the payout lines a second in the busiest minute. The sample tests recompute both from your own inputs and accept them within 2 %.
Starter code · payout_doc.py
"""The one-page requirements document of Campus Bite's weekly rider payouts.
Replace every None and fill the two lists. Times are "HH:MM" strings, percentages are numbers
from 0 to 100, and sizes are in decimal GB (1 GB = 1,000,000,000 bytes).
"""
# What completeness may be counted from.
COUNT_SOURCES = {"pipeline-output", "pipeline-input", "delivery-app-records"}
def rider_payout_doc():
"""Return the requirements page as a dictionary."""
return {
# (time, who reads it), for example ("08:00", "riders read their statements in the app")
"readers": [],
"owner": None,
# (time, who is told when the file is not ready), in time order
"escalation": [],
"freshness": {"published_by": None, "days_per_30": None},
"completeness": {"min_percent": None, "counted_from": None},
"correctness": None,
"retention_days": None,
"recovery": {"lost_lines": None, "input_kept_days": None, "rto_hours": None},
"size": {
"deliveries_per_day": None,
"bytes_per_line": None,
"compression_ratio": None,
"peak_factor": None,
"storage_gb": None,
"peak_lines_per_second": None,
},
} The sample tests · test_payout_doc.py
import re
from payout_doc import COUNT_SOURCES, rider_payout_doc
TIME = re.compile(r"^([01][0-9]|2[0-3]):[0-5][0-9]$")
def minutes(hhmm):
"""'05:30' -> 330."""
hours, mins = hhmm.split(":")
return int(hours) * 60 + int(mins)
def nones(value, path="doc"):
"""The paths of every None left in the document."""
if value is None:
return [path]
if isinstance(value, dict):
return [p for k, v in value.items() for p in nones(v, f"{path}.{k}")]
if isinstance(value, (list, tuple)):
return [p for i, v in enumerate(value) for p in nones(v, f"{path}[{i}]")]
return []
def test_every_line_is_filled():
"""fills every line of the page: no None is left"""
doc = rider_payout_doc()
assert nones(doc) == []
assert isinstance(doc["owner"], str) and doc["owner"].strip()
assert isinstance(doc["correctness"], str) and doc["correctness"].strip()
def test_readers_and_escalation_have_times():
"""names at least two readers and an escalation path in time order, before the bank file goes at 10:00"""
doc = rider_payout_doc()
assert len(doc["readers"]) >= 2
for when, who in doc["readers"]:
assert TIME.match(when), when
assert who.strip()
steps = doc["escalation"]
assert len(steps) >= 2
for when, who in steps:
assert TIME.match(when), when
assert who.strip()
times = [minutes(when) for when, _ in steps]
assert times == sorted(times)
assert times[-1] < minutes("10:00")
def test_freshness_comes_before_the_first_reader():
"""publishes the statements before the first reader, on a stated share of the 30 runs"""
fresh = rider_payout_doc()["freshness"]
assert TIME.match(fresh["published_by"]), fresh["published_by"]
assert minutes(fresh["published_by"]) < minutes("08:00")
assert isinstance(fresh["days_per_30"], int) and 1 <= fresh["days_per_30"] <= 30
def test_completeness_is_counted_outside_the_pipeline():
"""counts completeness against the delivery app's own records"""
complete = rider_payout_doc()["completeness"]
assert complete["counted_from"] in COUNT_SOURCES
assert complete["counted_from"] == "delivery-app-records"
assert 0 < complete["min_percent"] <= 100
def test_retention_and_recovery_fit_the_readers():
"""keeps three years of lines, loses no line, keeps a week of input and recovers before 10:00"""
doc = rider_payout_doc()
assert doc["retention_days"] >= 1_095
recovery = doc["recovery"]
assert recovery["lost_lines"] == 0
assert recovery["input_kept_days"] >= 7
assert 0 < recovery["rto_hours"] <= 4
def test_size_inputs_are_not_optimistic():
"""sizes from the facts: no fewer deliveries, smaller lines, better compression or smaller peak"""
size = rider_payout_doc()["size"]
assert size["deliveries_per_day"] >= 36_000
assert size["bytes_per_line"] >= 120
assert 1 <= size["compression_ratio"] <= 10
assert size["peak_factor"] >= 11
def test_storage_estimate_follows_from_the_inputs():
"""states the storage at the end of the retention period, within 2 % of its own inputs"""
doc = rider_payout_doc()
size = doc["size"]
expected = size["deliveries_per_day"] * size["bytes_per_line"] / size["compression_ratio"] * doc["retention_days"] / 1_000_000_000
assert abs(size["storage_gb"] - expected) <= 0.02 * expected
def test_peak_estimate_follows_from_the_inputs():
"""states the payout lines a second in the busiest minute, within 2 % of its own inputs"""
size = rider_payout_doc()["size"]
expected = size["deliveries_per_day"] / 86_400 * size["peak_factor"]
assert abs(size["peak_lines_per_second"] - expected) <= 0.02 * expected A hint
Start with the size, because it is arithmetic: storage is deliveries a day × bytes per line ÷ compression ratio × retention days, divided by 1,000,000,000 for GB; the peak is deliveries a day ÷ 86,400 × the peak factor. Then work back from the readers: the statements must be ready before 08:00, and a failure at 06:00 plus your RTO must still leave the bank file ready at 10:00.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then go back over the seven ideas of this lesson, starting with the one you have answered least well:
Practice that adapts to you
Practise this lesson
14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): how do an SLI, an SLO and an SLA fit together for a data pipeline?
An SLI, a service level indicator, is a measurement: for a daily file, the time it appeared where its readers fetch it, or the share of the expected records it holds. An SLO, a service level objective, is a target for that measurement and how often it must hold, such as published by 06:00 on at least 29 of 30 days. An SLA, a service level agreement, is a promise to a customer with a consequence when its objectives are missed, such as a refund; if nothing happens when a target is missed, it is an SLO. They stack: an agreement names objectives, and each objective is measured by an indicator. Teams usually keep their own objectives tighter than what they agree with customers, so that they see a problem before it costs money.
Two longer questions start here: one sizes a store from inputs you name yourself, the other turns an absolute demand into recovery objectives. Sketch your first step for each before you open it:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A product team wants to keep every tap and screen view of its mobile app for two years, to study how features are used. The app has about 3 million daily users. Estimate the storage and the peak event rate, say which inputs you would measure first, and how you would check the estimate.
-
Mid to staff
A finance lead asks that the pipeline loading card and wallet settlements into the warehouse should never lose a single row and never go down. How do you turn that into recovery objectives, what would each choice cost, and how would you show that the objectives are met?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Over 30 nights the payout job never failed. On how many was its file both on time and complete?
On 24. Nobody had written down what early and complete meant, so the 6 bad nights went unnoticed.
-
What does a requirement need before it becomes an objective you can check?
It starts from the people who read the data and names a measurement, a target, how often the target must hold, and an owner who is told when it is missed.
-
Where and how should freshness and completeness be measured?
Where readers meet the data, against a count the pipeline did not produce, such as a provider's own report, and day by day. A successful job and a good monthly average can both hide a bad day.
-
What do the RPO and the RTO of a pipeline limit?
The RPO caps the data a failure is allowed to cost; the RTO caps how long readers go without it. Kept input plus an idempotent rerun meets the RPO, while noticing, restoring and rerunning all count toward the RTO.
-
How do you estimate the storage a dataset needs?
Multiply events per day by bytes per event, divide by a compression ratio measured on one real day of your own rows, and multiply by the days you keep it.
-
Why is a tenfold margin over the average a poor throughput target?
The busiest minute can still exceed it: ten times this year's 2.8 events a second is 28, below the 31.1 measured in the busiest minute. Size for a measured peak and write down the peak factor and its source.
-
Which other duties tend to hide inside a request for data?
Quality, security and legal duties. Each one gets an owner and a check, on the same one-page document as the targets and the escalation path.
Key takeaways
- A requirement starts with its readers and becomes an objective when it names a measurement, a target, how often it must hold and an owner who hears when it is missed.
- Freshness and completeness are measured where the data is read, against a count the pipeline did not produce, and judged day by day rather than as an average.
- The RPO limits what a failure may lose and the RTO how long readers wait; kept input with an idempotent rerun meets the first, and noticing, restoring and rerunning all count in the second.
- Storage is events a day times bytes per event, divided by a compression ratio measured on your own rows, times the days you keep.
- Throughput is sized for the busiest minute you have measured, with the peak factor and its source written down.
- Quality, security and legal duties hide inside requests, and each gets an owner and a check on the same one-page document as the targets and the escalation path.
Terms in this lesson
- completeness
- The share of the expected records that a dataset holds by a stated time, counted against a source the pipeline does not produce, such as a provider's own report.
- data freshness
- How old data is when someone reads it, or how soon after an event it can be read. Written as a target, for example that the oldest record is no older than a stated time.
- peak factor
- The busiest period's rate divided by the average rate, such as the busiest minute's events against an average minute's. Multiplying the average by it sizes a system for the peak.
- recovery point objective
- The most data a failure may lose, measured as the time between the last point the data can be recovered to and the failure. Input kept for processing again moves that point forward.
- recovery time objective
- The longest a system or dataset may stay unavailable after a failure before the harm is unacceptable, counting the time to notice, to restore and to process data again.
- retention period
- How long data is kept before it is deleted. It can be a minimum that a business or legal duty sets, and it ends with a job that deletes the older data.
- service-level objective
- A target for a measured property of a service, with how often it must hold, such as a file published by 06:00 on at least 29 of 30 days. A promise to customers with a consequence for missing it is a service level agreement.
References
- Site Reliability Engineering, chapter 4: Service Level Objectives (Google)
- The Site Reliability Workbook, chapter 13: Data Processing Pipelines (Google)
- NIST SP 800-34 Rev. 1: Contingency Planning Guide for Federal Information Systems (National Institute of Standards and Technology)
- Parquet file format: encodings (The Apache Software Foundation)
- DuckDB guide: file formats (DuckDB Foundation)
- Digital Personal Data Protection Rules, 2025 (G.S.R. 846(E)) (Ministry of Electronics and Information Technology, Government of India)
- Google Cloud Professional Data Engineer exam guide (Google Cloud)
- AWS Certified Data Engineer – Associate (DEA-C01) exam guide (Amazon Web Services)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress