Skip to content
ML Academy

Core day 7 of 60 · Week 2 · Part I · application depth

SQL and Analytical Data Modeling

A pickup report joins flight results to the booking curve and shows 13 million passengers for 2024 on a network that carried about one million. Where does the error come from, how do you fix the query, and which numbers on the same dashboard were wrong without looking wrong?

By the end: You find a join-grain error in a query, fix it by choosing the grain explicitly, state the KPI error it caused as a number, and say which metrics it did and did not distort.

At a glance

Write the grain of every table before you join; a right side that is not unique on the keys multiplies sums silently while averages look fine.

  • Grain

    One row per flight-date, per order, per meter-hour: write it for both sides before any join.

  • Fan-out

    A join returns sum over keys of L_k x R_k rows; 13 snapshots per flight make every sum 13x.

  • Ratios survive

    Averages and ratios of sums are unchanged, which is why the bug hides.

  • Three fixes

    Filter to one snapshot, pre-aggregate, or join at a common coarser grain.

  • Snapshots

    Never sum a snapshot across time; pick the state at decision time.

Recall · 5 min

Without notes: in yesterday's March query, why did removing exact repeats with DISTINCT not resolve every duplicate key in flight_day_raw, and what extra information would you need to pick the right version of a row?

Open due reviews

Sign in to record this block.

Concept · 10 min

Every table answers one question: what does one row represent, its Grain (unit of analysis)? flight_day has one row per flight and date; booking_curve has 13, one per snapshot. Join them on (date, flight_no) and each flight-date meets 13 partners. Nothing errors, nothing warns, and every SUM on the left table is 13 times too large: a fan-out, the most common silent bug in analytical SQL.

Joining a 13-snapshot curve to flight results multiplies every sum by 13 and leaves averages untouched

Predict first

After the unfiltered join, what happens to SUM(revenue) / SUM(passengers) (average revenue per passenger)?

Text description of the figure

Two small tables, flight-day results and booking-curve snapshots, with a grain selector, a join toggle and an aggregation selector. Turning on the unfiltered join shows each flight-date row repeated once per snapshot, the row count and SUM rising 13-fold while AVG stays the same; filtering to one snapshot restores the grain.

How one join multiplies every row by 13 Diagram
flight_day1 row perflight-datebooking_curve13 rows perflight-dateRight sideunique?13 copies per rowSUM x 13, AVG unchangedFilter one snapshotdays_to_departure = 30Grain kept1 row per flight-dateReport13.2 million passengersnoignored
  1. flight_day1 row per flight-date
    • to Right side unique?
  2. booking_curve13 rows per flight-date
  3. Right side unique?
    • ignored: to 13 copies per row
  4. Filter one snapshotdays_to_departure = 30
    • to Grain kept
  5. 13 copies per rowSUM x 13, AVG unchanged
    • to Report
  6. Grain kept1 row per flight-date
  7. Report13.2 million passengers

The check before the join: SELECT date, flight_no, COUNT(*) FROM booking_curve GROUP BY 1, 2 HAVING COUNT(*) > 1. Any row means a fan-out is coming.

A join, drawn row by row Illustration
flight_day aloneMarch flightsApril flights (fuller)rows 48SUM(passengers) ×1.00AVG(load factor) 0.7483

One square per flight-date: the grain the KPIs are defined on.

Joined to all 13 snapshotsMarch flightsApril flights (fuller)rows 624SUM(passengers) ×13.00AVG(load factor) 0.7483

Every column is 13 high. Sums scale by exactly 13; the average divides by 13 times the rows, so it does not move.

Same join, April snapshots missingMarch flightsApril flights (fuller)rows 480SUM(passengers) ×9.90AVG(load factor) 0.7405 (-0.0077)

April flights now get fewer copies (35% of joined rows instead of 50%), so the average leans toward the emptier March flights.

Seeded tables from the Day 7 grain explorer: 48 flight-dates, 13 booking-curve snapshots each; in the last panel 6 snapshots are removed from half the flight-dates, April first.

Each column is one flight-date and each square one row of the result. A uniform fan-out scales sums and hides in averages; an uneven one moves the averages too.

Today's run: 2024 passengers, millions Chart
  • Broken join13.0 x in every market13.24 million
  • Point-in-time join1.02 million

Average load factor is 0.7766, 0.8049 and 0.8157 by market in both versions; only totals and counts are wrong.

Three deliberate fixes Comparison
CriterionFilter the right sidePre-aggregateCoarser common grain
HowAND b.days_to_departure = 30GROUP BY date, flight_no in a CTE, then joinAggregate both sides to route x month, then combine
When to use itOne state is the right one: the snapshot at decision timeYou need a summary of many rows: total cancellationsThe question is asked at that grain
Watch out forFlights missing that snapshot drop out of an inner joinSumming snapshots double-counts; sum events onlyDetail is gone; you cannot drill back down
Event rows and snapshot rows Comparison
CriterionEventSnapshot
One row isSomething that happened: bookings made in one intervalA state at a moment: cumulative bookings 30 days out
Summing over timeMeaningful: events add upDouble-counts: each state contains the earlier ones
2024 total from booking_curveSUM(bookings) = 1,129,194SUM(cumulative_bookings) = 9,522,688, 8.4 x too many

Also separate event time (when it happened) from processing time (when it was recorded). A late revenue update has an old event time and a new processing time.

  • Before a join, write the grain of both sides and of the result. A join keeps the left grain only if the right side is unique on the join keys.
  • Fix a fan-out deliberately: a point-in-time filter, pre-aggregation, or a common coarser grain.
  • Separate event rows from snapshot tables, event time from processing time, and keep dimension history with type-2 rows. Window functions build per-row features (Day 24).

Assumptions

  • The join keys are clean and typed consistently (dates as the same ISO text, no casing differences); Day 6 issues break joins before grain does.

  • The snapshot schedule is complete for every flight-date; if some flights lack early snapshots, the fan-out factor varies by row and the damage stops being a constant multiple.

  • The decision time is known, so you know which snapshot is the legitimate point-in-time value.

Sign in to record this block.

Application and workplace decision · 10 min

A colleague's 2024 pickup report joins flight_day to booking_curve on date and flight number. Run the query: it computes the colleague's version and a fixed version side by side, by market. Then state (a) the KPI error, (b) which metrics on the report were affected, and (c) the fix you would merge.

SQL (airline sample database)

-- 2024 passengers by market: fan-out join vs point-in-time join
WITH broken AS (
  SELECT f.market, SUM(f.passengers) AS pax, ROUND(AVG(f.load_factor), 4) AS avg_lf
  FROM flight_day f
  JOIN booking_curve b ON b.date = f.date AND b.flight_no = f.flight_no
  WHERE f.date BETWEEN '2024-01-01' AND '2024-12-31'
  GROUP BY f.market
),
fixed AS (
  SELECT f.market, SUM(f.passengers) AS pax, ROUND(AVG(f.load_factor), 4) AS avg_lf,
         SUM(f.bookings_final) AS bookings_final, SUM(b.cumulative_bookings) AS bookings_30d
  FROM flight_day f
  JOIN booking_curve b
    ON b.date = f.date AND b.flight_no = f.flight_no AND b.days_to_departure = 30
  WHERE f.date BETWEEN '2024-01-01' AND '2024-12-31'
  GROUP BY f.market
)
SELECT x.market, x.pax AS pax_broken, y.pax AS pax_fixed,
       ROUND(1.0 * x.pax / y.pax, 2) AS inflation,
       x.avg_lf AS avg_lf_broken, y.avg_lf AS avg_lf_fixed,
       ROUND(1.0 * y.bookings_30d / y.bookings_final, 3) AS booked_share_30d
FROM broken x
JOIN fixed y ON y.market = x.market
ORDER BY x.market;

This code is executed in the repository's checks (pnpm lesson:py / lesson:sql). It is not run on this site; copy it into your notebook.

Sign in to record this block.

Mini-assessment · 8 min

Your first answer is your evidence. You can re-answer later in practice mode; that is labelled practice.

Question 1 · easy

`flight_day` has one row per (date, flight_no). `booking_curve` has one row per (date, flight_no, days_to_departure) with 13 snapshots per flight-date. You join them on date and flight_no only. What is the grain of the result?
Options

How confident are you? (optional, used for calibration)

Question 2 · medium

Over 2022-2024, `flight_day` holds 20,824 flight-dates and 2,987,466 passengers, and every flight-date has all 13 snapshots. A colleague "fixes" the fan-out by joining only the snapshots taken in the final week (`days_to_departure <= 7`, i.e. 7, 5, 3, 2, 1 and 0 days out). What does `SUM(f.passengers)` return?
passengers

How confident are you? (optional, used for calibration)

Question 3 · hard

After the unfiltered join, average load factor matched the correct value exactly, so a colleague argues the dashboard's average tiles are always safe from this bug. Next quarter, flights that opened for sale less than 90 days before departure will have only 8-11 snapshots instead of 13. What is the right assessment?
Options

How confident are you? (optional, used for calibration)

Question 4 · medium

A `route_dim` table (route, market) is overwritten in place when a route is reclassified. On 2024-03-01 one route moves from `domestic` to `intl-short`, and a report of 2023 passengers by market now counts that route's whole 2023 history under intl-short. How do you fix the history?
Options

How confident are you? (optional, used for calibration)

Question 5 · hard

A feature `pax_lag7 = LAG(passengers, 7) OVER (PARTITION BY flight_no ORDER BY date)` is built from `flight_day_raw`, with exact duplicate rows removed, for a next-day forecast. One IST-TZX flight number has no rows for 4-12 March 2023 (nine days). What does the feature return for that flight on 13-19 March, and what is the fix?
Options

How confident are you? (optional, used for calibration)

Sign in to complete lessons and keep your progress.

Technical depth · optional · 29 min in total

Open only what you need. Nothing here is required to complete the day; the assessment covers the core above.

Join cardinality: how many rows a join returns6 minThe row count of any join derived from per-key counts, a 4-order example worked by hand, and why the output can be far larger than either input.
Join cardinality as arithmetic over keys Formula
∣A⋈B∣=∑kLkRk∣A  LEFT JOIN  B∣=∑kLkmax⁡(Rk,1)∑joinedx=∑kRk Sk\begin{gathered}|A \bowtie B| = \sum_k L_k R_k \\ |A \;\text{LEFT JOIN}\; B| = \sum_k L_k \max(R_k, 1) \\ \sum_{\text{joined}} x = \sum_k R_k\, S_k\end{gathered}
kk
one value of the join key (an order_id, a (date, flight_no) pair, a meter id)
Lk,RkL_k, R_k
rows with key k on the left and on the right side
LkRkL_k R_k
an inner join pairs every left row of k with every right row of k: the product, not the larger count
max⁡(Rk,1)\max(R_k, 1)
a left join keeps an unmatched left row once, with NULLs on the right
SkS_k
the sum of a left-side measure x over the left rows of key k; after the join each copy counts R_k times

The left grain survives only when every R_k is 0 or 1 (inner) or at most 1 (left). Two fan-outs on one key multiply: sum of L_k R_k P_k rows. Every fan-out question is a question about the R_k.

Relationship names are statements about L_k and R_k Map

Many-to-one

The safe lookup: every left row meets at most one partner. Left grain kept.

Many-to-many

L_k x R_k rows per key: the product, not the larger count.

One-to-one

Both sides unique. Left grain kept.

One-to-many

A fan-out: each left row is copied R_k times.

Only the right column decides whether the left grain survives. The proof is cheap: max over k of R_k must be 1, which is what GROUP BY keys HAVING COUNT(*) > 1 on the right side checks (pandas: validate='many_to_one').

Worked by hand: 4 orders joined to their shipments Table
order_idrevenue xshipments R_kInner join rowsRevenue counted
112022240
2801180
320033600
4600 (not shipped)0 (left join: 1)0 (left join: 60)
TruthSUM 460, AVG 1156 shipments in total4 orders460
Inner join--6SUM 920, AVG 153.3
Left join--7SUM 980, AVG 140.0
Pre-aggregate shipments, then join--4SUM 460; shipment count kept as a column

Hypothetical e-commerce tables, verified in SQLite. The fan-out is uneven (R_k = 2, 1, 3, 0), so even the average moves: big orders ship in more parts and get more copies. Pre-aggregating the right side restores one row per order.

Anatomy of a fan-out: what SUM and AVG see3 minWhy sums inflate by exactly 13 and averages do not move, and when they do.
What a fan-out does to SUM and AVG Formula
SUMjoined=∑kmk xkAVGjoined=∑kmk xk∑kmk\begin{gathered}\text{SUM}_{\text{joined}} = \sum_{k} m_k\,x_k \\ \text{AVG}_{\text{joined}} = \frac{\sum_k m_k\,x_k}{\sum_k m_k}\end{gathered}
xkx_k
the left-side value of flight-date k (passengers, load factor)
mkm_k
number of right-side rows that match key k: 13 for a full booking curve, 0 drops the row
mk=mm_k = m
uniform fan-out: SUM is m times too large, AVG is unchanged
mkvariesm_k varies
AVG becomes a weighted mean; biased whenever m_k is related to x_k
∑km rk∑km pk\frac{\sum_k m\,r_k}{\sum_k m\,p_k}
a ratio of sums (revenue / passengers) under a uniform fan-out: the m cancels, which is why rate tiles look right

Every fan-out question reduces to one: what are the match counts m_k, and are they related to what you measure?

When a fan-out is worse than a constant multiple Overview
  • Missing snapshots

    Flights with fewer snapshots get fewer copies: averages shift too.

  • Two fan-outs

    Orders x shipments x payments: an order with 3 shipments and 2 payments appears 6 times.

  • One hot key

    Join cost grows with sum of L_k R_k: one key with 10,000 rows per side alone makes 100 million rows.

  • Dirty keys

    Case or type mismatches drop rows silently (Day 6).

  • Filtered windows

    Filter after computing windows, or LAG reads the wrong row.

Window functions, defined and worked by hand7 minThe frame as a set of rows, ROWS vs RANGE written as math, six days of meter data computed by hand, then the frames that leak.
A window value is an aggregate over a frame that moves with the row Formula
wi=aggj∈F(i)xjFROWS(i)={j:i−a≤j≤i−b}FRANGE(i)={j:ti−a≤tj≤ti−b}\begin{gathered}w_i = \mathrm{agg}_{j \in F(i)} x_j \\ F_{\text{ROWS}}(i) = \{ j : i-a \le j \le i-b \} \\ F_{\text{RANGE}}(i) = \{ j : t_i - a \le t_j \le t_i - b \}\end{gathered}
i,ji, j
positions of rows inside one partition, after sorting by the ORDER BY key
tit_i
the ORDER BY value of row i: a date as a day number, a timestamp, a sequence id
a,ba, b
the frame offsets: 'a PRECEDING AND b PRECEDING'; b = 0 means the frame reaches the current row
FROWSF_{\text{ROWS}}
counts positions: a fixed number of rows, however much time they span
FRANGEF_{\text{RANGE}}
counts in units of the ordering key: a fixed span of time, however many rows fall in it
agg⁡\operatorname{agg}
AVG, SUM, MIN, COUNT ... over the frame; LAG(x, k) is simply x at position i - k

The two definitions agree exactly when there is one row per unit of t (a complete date spine). A missing date shrinks the RANGE frame and stretches the ROWS frame.

Worked by hand: a substation's daily load (MWh) with 4 March missing Table
datex (MWh)LAG(x, 1)AVG, ROWS 2 to 1 PRECEDINGAVG, RANGE 2 to 1 days PRECEDINGrunning SUM
03-0140NULLNULLNULL40
03-02444040.040.084
03-034244(40 + 44) / 2 = 42.042.0126
03-055042 (two days back)(44 + 42) / 2 = 43.0 - spans 3 days42.0 - only 03-03 is within 2 days176
03-064750(42 + 50) / 2 = 46.0 - reaches back to 03-0350.0 - 03-04 is missing223
03-074547(50 + 47) / 2 = 48.548.5268

Hypothetical energy data, recomputed with SQLite 3.53 (RANGE ordered by julianday(date)). Until the gap both frames agree; after it the ROWS frame quietly reaches further back in time, and the RANGE frame holds fewer rows.

Where window functions run inside one query Sequence
  1. FROM and JOIN

    Rows are assembled; a fan-out happens here, before anything else sees the rows.

  2. WHERE

    Rows are filtered before any window reads them: a June filter here hides May from LAG.

  3. GROUP BY, HAVING

    Groups collapse; a window then runs over the groups, not the raw rows.

  4. Window functions

    Per partition after a sort, O(n log n) and usually the dominant cost; sliding frames then update in O(n).

  5. ORDER BY, LIMIT

    Applied last. To filter on a window value, or keep full history for features, filter in an outer query.

Logical evaluation order. Features that need history are computed in a CTE over all dates and filtered afterwards.

Anatomy of a window function Stack
  1. AVG(passengers)The value computed for each row, without collapsing rows.
  2. PARTITION BY flight_noRestart the calculation for each flight.
  3. ORDER BY dateDefine what 'previous' means.
  4. ROWS BETWEEN 7 PRECEDING AND 1 PRECEDINGThe frame: the seven rows before this one, excluding it.

Rows are not days. With gaps in the dates (flight_day_raw), a 7-row frame spans more than 7 days; use a date-based condition instead.

Frames on a calendar with a hole Illustration
One flight, one row per date it flew; the current row is day 16no rows12345678×9×10×11×12131415161718192021ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING7 rows, reaching 11 days back; average 150.0date in the 7 days before (RANGE on the date)exactly 7 days back, so only 3 rows exist; average 151.0ROWS BETWEEN 6 PRECEDING AND CURRENT ROWincludes the row being predicted: a leak; average 154.4

Rows are not days. After a gap, a row-count frame silently reaches further back; a date-based frame keeps its meaning and holds fewer rows.

The default frame and tied datesdatevalueROWS UNBOUNDEDdefault frame06-0144406-0226906-0239906-0351414SUM(value) OVER (ORDER BY date ...)

With ORDER BY and no frame clause the frame is RANGE up to the current row including its peers: both rows dated 06-02 already see each other.

Seeded synthetic passenger counts for one flight over 21 days with a 4-day hole. Frame membership computed with the SQL frame rules.

The same current row, three frames. Row counts and day counts part ways as soon as a date is missing; a frame that reaches the current row contains the answer.

Pick the frame for the question Table
QuestionFrameSafe when
Last 7 rowsROWS BETWEEN 7 PRECEDING AND 1 PRECEDINGone row per entity and date (a date spine)
Last 7 daysRANGE over a day number, 7 PRECEDING AND 1 PRECEDINGalways; the row count varies
Running totalROWS UNBOUNDED PRECEDINGalways; the default frame groups tied dates
Feature for day tany frame ending at 1 PRECEDINGnever ends at CURRENT ROW

In SQLite and PostgreSQL, RANGE with an offset needs exactly one ORDER BY term: a number such as julianday(date) in SQLite, or a date with an interval in PostgreSQL.

Three window features for ZX101 (lab output) Table
datepassengersLAG 7 rowsavg previous 7 rowsavg incl. today
2024-06-08173140151.4156.1
2024-06-09122173156.1148.9
2024-06-10132172148.9143.1

The last column contains the value it is meant to predict, so it leaks; the previous-7 window ends one row before the current date.

Choosing and asserting a fix3 minFilter, pre-aggregate or coarsen, picked from the question, then guarded by a check in the pipeline.
Choosing the fix from the question Diagram
Right sideunique on…Join as isassert it in the queryNeed the stateat one moment?Point-in-time filterone snapshot per keyNeed a summaryof many rows?Pre-aggregate eventsGROUP BY the keys, thenjoinCoarser common grainaggregate both sidesfirstyesnoyesnoyesno
  1. Right side unique on the keys?
    • no: to Need the state at one moment?
  2. Join as isassert it in the query
  3. Need the state at one moment?
    • no: to Need a summary of many rows?
  4. Point-in-time filterone snapshot per key
  5. Need a summary of many rows?
    • no: to Coarser common grain
  6. Pre-aggregate eventsGROUP BY the keys, then join
  7. Coarser common grainaggregate both sides first

DISTINCT is not on the map. It removes only rows identical in every selected column and can merge genuinely different rows.

Assert the grain where the join happens Table
WhereCheckFails when
Before writing SQLthe grain of left, right and expected result, one line eachyou cannot say what one result row is
SQL, before the joinGROUP BY keys HAVING COUNT(*) > 1 on the right sideany key has two partners
SQL, after the joinCOUNT(*) and COUNT(DISTINCT keys) of the resultthe two counts differ
pandasmerge(..., validate='many_to_one')the right side is not unique on the keys
Reconciliationone total against an external numberfinance or the schedule disagrees

pandas validate options are one_to_one, one_to_many, many_to_one and many_to_many (no check). Put the check in the pipeline, not in a reviewer's memory.

Joins that lose rows2 minInner joins, WHERE filters on outer joins and NULL keys drop rows without an error.
Joins also lose rows silently Comparison
CriterionINNER JOINLEFT JOINAnti-join
Left rows without a matchDroppedKept, right columns NULLOnly these are returned
Typical silent bugCustomers with no orders vanish from a churn table; flights without a 30-day snapshot tooA WHERE filter on a right column turns it back into an inner joinNone: it is the diagnostic
CheckCount left rows before and afterPut right-side filters in ON, not WHERELEFT JOIN ... WHERE right key IS NULL lists the orphans

NULL never equals NULL in a join condition, so rows with a NULL key never match. Fan-out adds rows; these lose them. Both pass without an error.

Events, snapshots and two clocks3 minWhat may be summed over time, and event time against processing time.
Events add up; states pile up Illustration
0701409060300States: cumulative bookings, ZX10190 days out: 3 on hand60 days out: 10 on hand45 days out: 21 on hand30 days out: 42 on hand21 days out: 64 on hand14 days out: 89 on hand10 days out: 107 on hand7 days out: 123 on hand5 days out: 135 on hand3 days out: 149 on hand2 days out: 156 on hand1 days out: 163 on hand0 days out: 171 on hand30 days out: 42final 171days to departurebookings on hand

13 rows, each the running total at that moment. The row at 30 days already contains everything booked before it.

01633Events: new bookings per intervalup to 90 days out: 3 new90 to 60 days out: 7 new60 to 45 days out: 11 new45 to 30 days out: 21 new30 to 21 days out: 22 new21 to 14 days out: 25 new14 to 10 days out: 18 new10 to 7 days out: 16 new7 to 5 days out: 12 new5 to 3 days out: 14 new3 to 2 days out: 7 new2 to 1 days out: 7 new1 to 0 days out: 8 newthese 13 add up to 171 = finalsnapshot interval, earliest to latestnew bookings

Differences of the states are events. Events are additive over time; states are not.

SUM of the states ÷ final bookingsmean 7.2×6×8×one dot per flight-date (48)

Summing states counts every early booking again at each later snapshot. The factor is not 13 and not constant: it depends on how early each flight sold.

Seeded booking curves from the Day 7 grain explorer (48 flight-dates, snapshots at 90, 60, 45, 30, 21, 14, 10, 7, 5, 3, 2, 1, 0 days). The sample database gives its own factor for 2024 (8.4×).

The same booking curve as 13 states and as 13 events. Summing the states over time counts early bookings again at every later snapshot.

Which measures may be summed, and over what Table
KindExamples across sectorsSum over time?Sum over entities?
Additive (events)units sold, kWh consumed, card transactions, bookings made in an intervalyesyes
Semi-additive (states)account balance at month end, stock on hand, cumulative bookings at 30 daysno: pick one momentyes, at one moment
Non-additive (ratios)conversion rate, gross margin %, load factornono: sum the parts, then divide

Kimball's three kinds of fact. A ratio of sums is right at any grain; an average of ratios weights every flight equally, which is a different question.

Slowly changing dimensionsadvanced3 minKeeping history so old facts join to old attributes.
A type-2 dimension: one row per version (example rows) Table
customer_idsegmentvalid_fromvalid_to
C-1042mass retail2022-01-012023-06-30
C-1042affluent2023-07-019999-12-31

A bank's customer dimension. Join facts on the key and on date between valid_from and valid_to, so a 2022 transaction keeps its 2022 segment. Joining to today's row rewrites history. (Illustrative id and dates.)

Joining a fact to the right version Diagram
Transaction on2022-05-10customer C-1042;may arrive…Match the versionvalid_from <= event date<= valid_toVersion 1: massretailthe attributes it hadthenmatch
  1. Transaction on 2022-05-10customer C-1042; may arrive late, in 2024
  2. Match the versionvalid_from <= event date <= valid_to
  3. Version 1: mass retailthe attributes it had then

A late-arriving fact takes the version valid at its event time, not the newest row. Validity windows must not overlap, or this as-of join fans out again.

Idempotent loads and backfillsadvanced2 minRe-running a job must give the same table, not more rows.
Re-runs that cannot duplicate rows Sequence
  1. Name the partition

    The date range or key set this run is responsible for.

  2. Replace

    DELETE the partition and INSERT it again in one transaction, or upsert on the key.

  3. Unique input

    An upsert batch must itself be unique on the key; PostgreSQL rejects touching one row twice.

  4. Assert

    Row count and key uniqueness of the partition after the load.

  5. Backfill the same way

    Parameterise the range; write to staging; compare totals; then swap.

A retried append is the most common source of the duplicate keys found on Day 6.

Memory anchor

Write the grain of both sides before the join; a right side that is not unique on the keys multiplies everything you sum.

Useful for

  • Reviewing any analytical query or dbt model before its numbers reach a report - order and shipment tables, claims and claim lines, accounts and transactions.

  • Building model features from snapshot and event tables (account balances, sensor states, booking curves) without double counting or peeking past the decision time.

  • Explaining a KPI discrepancy between two reports that join the same tables differently, for example finance revenue against a product dashboard.

Not suitable for

  • Fixing bad keys - grain checks assume clean keys; duplicate or inconsistent keys (Day 6) must be resolved first.

  • Replacing a semantic layer - a documented metric definition with an owner prevents the same join being rewritten ten ways.

Traps

DISTINCT as a fan-out fix

Adding DISTINCT hides duplicated rows only when they are identical in every selected column; it also merges genuinely different rows that happen to match, so totals can become too small.

How to avoid it

Fix the grain where it breaks - filter or pre-aggregate the right side to one row per key - and assert uniqueness.

Summing cumulative snapshots

Cumulative counts are states, not events. Summing them across snapshots counted 2024 bookings 8.4 times (9,522,688 vs 1,129,194); summing month-end account balances over a year in a bank does the same thing.

How to avoid it

Sum event (incremental) columns; for state columns pick one snapshot per key or take the last value.

Senior reasoning

A senior writes the grain of every input and of the output before writing the join, checks row counts before and after, and treats a plausible-looking rate as no evidence that the query is right. When a bug ships, they quantify which numbers were wrong and which were not, state the condition under which the unaffected ones were safe, and add an automated uniqueness test so the fix outlives the incident.

Interview question

"Revenue in your dashboard is 13 times what finance reports. Walk me through how you find the cause." How would you structure the answer?

Recall cards scheduled after completion

  • When does a join keep the left table's grain, and how do you check it before running the report?
  • After a uniform 13x fan-out, which metrics are wrong and which survive? When do the survivors break too?

Reviewed on a 1 · 3 · 7 · 14 · 30-day schedule; a miss returns the card to 1 day.

References

Optional sections

Deep dive · 10 min

Fan-out in disguise. Fan-outs also come from many-to-many joins on dimensions (a route mapped to two markets after a reorganisation), from joining two fact tables on a shared dimension (bookings and cancellations by date produce every booking x every cancellation that day), and from LEFT JOINs to tables with duplicate keys. The test is always the same: is the right side unique on the join keys?

Slowly changing dimensions and backfills. A type-1 dimension overwrites attributes (history lost); type 2 adds a versioned row with validity dates, and facts join on key and date range. When a definition changes - say, passengers starts to include infants - a backfill recomputes history under the new definition. Make backfills reproducible: parameterise the date range, write to a staging table, compare row counts and totals with the old version, then swap.

Idempotent re-runs. An Idempotent pipeline gives the same result whether it runs once or three times: overwrite a partition (DELETE for the date range, then INSERT) or MERGE on the key, never blind INSERT. A retried job that appends creates exactly the duplicate keys you found on Day 6.

Window functions on gappy data. LAG(x, 7) means seven rows back, not seven days back. On flight_day_raw with its gaps, the same expression silently compares with a different weekday. Join to a complete calendar first, or use a self-join on date(date, '-7 days'). Day 24 builds on this.

Hands-on lab: Point-in-time window features with LAG and rolling averages · 15 min

Run the query, which computes three features for flight ZX101 over its full history and then shows 12 days of June 2024. Then:

  1. Explain why pax_avg_incl_today is not a legitimate feature for predicting that day's passengers, while pax_avg_prev_7d is (Day 2 availability, Day 24 leakage).
  2. Rewrite pax_same_weekday_last_week so it stays correct on flight_day_raw, where some dates are missing.
  3. Why is the window computed in a CTE over all dates before filtering to June? What changes if the WHERE clause moves inside the CTE?
-- Window features over the full history, then display a slice
WITH feats AS (
  SELECT date, flight_no, passengers,
         LAG(passengers, 7) OVER w AS pax_same_weekday_last_week,
         ROUND(AVG(passengers) OVER (w ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING), 1) AS pax_avg_prev_7d,
         ROUND(AVG(passengers) OVER (w ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 1) AS pax_avg_incl_today
  FROM flight_day
  WHERE flight_no = 'ZX101'
  WINDOW w AS (PARTITION BY flight_no ORDER BY date)
)
SELECT * FROM feats
WHERE date BETWEEN '2024-06-08' AND '2024-06-19'
ORDER BY date;
Interview practice · 10 min

You are given orders (one row per order) and shipments (one row per shipment; an order can ship in parts). Write, in words, how you would report revenue per day and shipped units per day in one table.

Content version 6 · last verified 2026-10-11 · depends on sqlite