Date Arithmetic: Advanced
What you will be able to do
How the gap arrives
| Calculation | What it does on sparse rows | What the reader sees |
|---|---|---|
| `AVG(bookings)` | Divides by the days that had bookings | An average inflated by every quiet day |
| `ROWS BETWEEN 6 PRECEDING AND CURRENT ROW` | A frame of 7 rows can span 10 calendar days | A 7-day window wider than its name |
| `LAG(bookings)` | Returns the previous row, not the previous day | Growth measured against 2 days ago |
| Line chart | Draws a straight line from 03-09 to 03-11 | A dead day that vanishes from the picture |
Put the dates on the left
SELECTc.cal_date,COALESCE(d.bookings, 0) AS bookingsFROM calendar AS cLEFT JOIN daily_bookings AS dON d.booking_date = c.cal_dateORDER BY c.cal_date
Zero versus unknown
The interviewer waits for this distinction. COALESCE(bookings, 0) asserts that nothing happened, and that assertion holds only if the source delivered data for that day. If ingestion failed on 03-10, the true value is unknown: filling 0 publishes a crash that never happened, and leaving the day out publishes an inflated average. The complete answer joins a load-status record into the spine and fills zero only where the pipeline confirms the day landed.
- ▸Facts on the left and the spine on the right: the LEFT JOIN keeps the facts, and the gaps stay gaps.
- ▸A WHERE filter on a fact column after the join: the filled rows carry NULL in that column and fail the test, so the gaps come back.
- ▸A spine that runs through today while today's load is still in flight: the last day shows a false zero.
Size the spine before building it
Keep facts sparse in storage and densify at read time, in the final model that feeds the chart. A fact table stored at host-by-day grain pays storage and scan cost for rows that carry no information. For the dates themselves, Postgres has generate_series, BigQuery GENERATE_DATE_ARRAY with UNNEST, Spark sequence with explode, and other engines a recursive CTE. A persisted calendar table beats all of them, because holiday logic needs one anyway.
The follow-up: can you skip the spine?
It comes often. A RANGE frame measures the window in calendar time instead of rows, so this rolling sum is correct on sparse rows without any fill:
Then name what it leaves broken. The output still has no row for 03-10, so the chart still skips it, and a 7-day average needs a fixed denominator of 7 rather than AVG over whatever rows land in the frame. Support varies too: Postgres accepts interval offsets in RANGE from version 11, while BigQuery requires a numeric ORDER BY such as UNIX_DATE(booking_date). In the room, say that the frame fixes the window and the spine fixes the output, and a dashboard usually needs both.
The question carries a single word that changes the tooling: business. "Host payouts release 2 business days after check-in; find the late ones." "Support replies within 3 business days; report the breach rate by country." DATE_ADD has never heard of July 4. Once a rule depends on the status of a day (holiday, weekend, trading day, office closure), that status is data, and data belongs in a table someone maintains.
Why weekday arithmetic fails first
The first attempt usually skips Saturdays and Sundays with EXTRACT(DOW ...). It survives until the first holiday, then fails across markets, because weekends are local too: the UAE moved its weekend from Friday and Saturday to Saturday and Sunday in January 2022. Rules hardcoded in CASE expressions also get copied, and 2 copies edited at different times are how 2 dashboards end up disagreeing about the same SLA.
- Knows weekends, never holidays
- A single weekend definition for every market
- Copied into each query that needs it
- N business days later needs a loop or a guess
- Every weekend and holiday stored as a row, observed dates included
- A row per market per date, so markets never collide
- A single owner and a single update, so every query agrees
- N business days later becomes an equality join
One row per market per date
The grain is a row per market per date, so a holiday in Lisbon and a holiday in Austin never collide. Each row carries whether the day is a business day, the holiday's name, and the column every due-date calculation reads: business_day_seq, a running count of business days that holds its value across weekends and holidays.
| market | cal_date | is_business_day | business_day_seq | holiday_name |
|---|---|---|---|---|
| US | 2024-07-03 | true | 1350 | |
| US | 2024-07-04 | false | 1350 | Independence Day |
| US | 2024-07-05 | true | 1351 | |
| US | 2024-07-06 | false | 1351 | |
| US | 2024-07-07 | false | 1351 | |
| US | 2024-07-08 | true | 1352 |
Business-day math becomes a join
Walk the interviewer through a row. A check-in on July 3 sits at sequence 1350, so the payout lands on the business day numbered 1352, which is July 8. A Saturday check-in on July 6 inherits 1351 from Friday and lands at 1353, on July 9, the same day as a Friday check-in. That inheritance is a convention, and stating it before you are asked ("a weekend start counts from the last business day") is what a strong answer does. Business days between 2 dates is a subtraction of sequences, so an SLA breach becomes closed.business_day_seq - opened.business_day_seq > 3.
Somebody owns this table
This question arrives without the word range. "Revenue is recognized per night stayed; give me monthly revenue." "Subscriptions start on any day of the month; what is MRR by month?" "Leave requests have a first and last day; how many fall in each payroll month?" Every row has a start and an end, and the answer buckets by month. Grouping by DATE_TRUNC('month', check_in) books the whole stay to the month it began, which is how a revenue dashboard drifts above the ledger without a single wrong row.
Exploding versus joining
- A row per night via generate_series, or sequence plus UNNEST
- Any bucket afterwards: month, fiscal period, ISO week
- Cost is ranges times length: 30M stays at 4 nights is 120M rows
- Breaks on long ranges: 2M annual plans become 730M rows
- A row per range per month it touches
- Clipping arithmetic with GREATEST and LEAST
- Output stays close to input: most stays touch 1 or 2 months
- Needs a small table of months, which the calendar supplies
The overlap join, row by row
SELECTs.stay_id,m.month_start,DATE_DIFF('day',CAST(GREATEST(s.check_in, m.month_start) AS DATE),CAST(LEAST(s.check_out, m.next_month) AS DATE)) AS nightsFROM stays AS sJOIN months AS mON m.month_start < s.check_outAND m.next_month > s.check_in
The convention to say aloud
Hotel nights are half-open by nature. Subscriptions often are not: a plan "ending 2024-03-31" usually includes the 31st. Feed an inclusive end into the same arithmetic and every range loses its last day, a 1-day error per subscription that adds up to a visible MRR gap. Do the conversion to half-open once with end_date + INTERVAL '1' DAY where the data enters, then tell the interviewer you did.
Allocating the money to the cent
- ▸Fan-out: any SUM(amount) after this join counts a 3-month stay 3 times. Sum the allocated revenue, never the original amount.
- ▸Open ranges: a stay or plan with no end needs COALESCE to the reporting period's end. Make that choice on purpose and state it.
- ▸Large range tables on both sides: an inequality join between them has no hash key. Keep one side a small period table that broadcasts.
- ▸Policy: if finance recognizes revenue at booking, splitting by night is wrong. Ask for the recognition rule first.
What the engine decides before reading
Pruning is decided at planning time, before a byte of data is read. The planner compares the WHERE clause with what it already knows about each partition. That's either the value in a directory name such as dt=2024-03-14 or the minimum and maximum a file's metadata records for the column. A partition whose values cannot satisfy the predicate is skipped. The form every planner can compare is the bare partition column against a constant.
Wrap the column in a function and the outcome becomes engine-specific. Spark evaluates deterministic expressions against directory partition values, so YEAR(dt) = 2024 still prunes there. On Postgres declarative partitioning and BigQuery a transformed column can cost the pruning, and so it can under min/max pruning over Snowflake micro-partitions or Parquet and Iceberg file statistics, because the planner would need to know the function preserves order. The answer that holds on every engine keeps the column bare and moves the arithmetic to the constant side.
| Filter on a table partitioned by booking_date | Prunes portably? | Why |
|---|---|---|
| `booking_date >= DATE '2024-03-01' AND booking_date < DATE '2024-04-01'` | Yes | Bare column against constants |
| `DATE_TRUNC('month', booking_date) = DATE '2024-03-01'` | No | A function wraps the column |
| `CAST(booking_date AS VARCHAR) LIKE '2024-03%'` | No | Cast plus pattern match |
| `booking_date >= CURRENT_DATE - INTERVAL '7' DAY` | Yes, in most engines | Folded to a constant at planning |
| `booking_date >= (SELECT MAX(loaded_date) FROM loads)` | Engine-dependent | Value unknown until the subquery runs |
| `created_at_utc >= TIMESTAMP '2024-03-01 00:00:00'` | No | Filters a column the table is not partitioned by |
The last row is the one people miss: a table partitioned by booking_date gets nothing from a filter on created_at_utc, unless the table format derives its partitions from that column, as Iceberg hidden partitioning does with days(created_at_utc).
The Rewrite
The incremental model that scans everything
The most expensive case is in incremental pipelines. A dbt model filtered with WHERE booking_date >= (SELECT MAX(booking_date) FROM target) looks incremental, but its bound is a subquery, and an engine that can't resolve it before planning reads the entire source on every run. Resolve the bound first and pass it in as a literal. The orchestrator's data interval works, and so does a script variable or any value rendered into the SQL. Spark 3.0 added dynamic partition pruning for the related join case, where the wanted dates come from a filtered dimension table, but a portable pipeline does not depend on it.
Prove it from the plan
"How would you know?" is the follow-up, and "the query got faster" is the answer that loses the point. Every engine shows pruning directly. Spark's physical plan lists PartitionFilters on the scan node, and an empty list means a full read. A BigQuery dry run reports bytes processed before anything runs. Snowflake's query profile shows partitions scanned against partitions total. Postgres EXPLAIN lists only the partitions that survived.
Guardrails beyond one query
Fixing a query helps a dashboard. The organizational answer is a guardrail: BigQuery's require_partition_filter table option rejects any query without a filter on the partition column, which turns an accidental full scan into an error message. Pair it with an alert on bytes scanned per query, since a filter that satisfies the rule can still ask for years of partitions. Then choose the partition column by what readers filter on: a table partitioned by ingestion date serves the pipeline, while analysts filtering on booking date get no pruning at all.
This question often arrives as a design prompt with no time zones in it: "design the bookings table," while the interviewer watches what type you give created_at. Or it arrives as a symptom: "a booking made at 11:30pm in Honolulu shows up on tomorrow's report," or "daily numbers shifted when someone changed the warehouse session zone." The storage choice is made once, and every reader of the table inherits it.
What each choice commits readers to
- Ordering and durations across regions are exact
- Every local-day question needs a conversion
- Every reader must know which zone defines the day
- Daylight saving is handled by the conversion, never by the data
- A local day is a plain DATE cast for a single market
- Events in different zones cannot be ordered against each other
- 01:30 happens twice on the night clocks fall back
- Converting to any other zone needs a zone nobody stored
Instant, zone and local date
The complete answer stores the instant, in a type that means an absolute point in time, and the IANA zone of whatever defines the business day, here the property's America/Los_Angeles, since the guest's zone and headquarters' zone answer different questions. Where readers filter by local day constantly, it also stores a local_date column written at load time, so nobody repeats the conversion and the partition key can match it. The type names differ by engine, and naming the right one is part of the answer:
| Engine | Absolute instant | Wall-clock, no zone |
|---|---|---|
| Postgres | `TIMESTAMPTZ` (stored as UTC) | `TIMESTAMP` |
| BigQuery | `TIMESTAMP` | `DATETIME` |
| Snowflake | `TIMESTAMP_TZ` or `TIMESTAMP_LTZ` | `TIMESTAMP_NTZ` |
| Spark | `TIMESTAMP` (shown in the session zone) | `TIMESTAMP_NTZ` (3.4 and later) |
A local day expressed in UTC
SELECTCOUNT(*) AS bookings,SUM(amount) AS revenueFROM bookingsWHERE created_at_utc >= '2024-03-14 07:00:00'AND created_at_utc < '2024-03-15 07:00:00'
Grouped by UTC date, March 14 would hold bookings 1 through 3 for $70. The Los Angeles day holds bookings 2 through 4 for $90: the same count, different bookings, $20 apart. Those bounds also move twice a year, from 08:00 UTC before March 10 to 07:00 after, so they belong in the calendar table as day_start_utc and day_end_utc per zone and date, computed once, rather than as an offset hardcoded in each query.
Storage zone and partition date
Future times break the rule
- Densify at read time with a spine bounded to each entity's active dates
- Fill zero only for days the source confirms it loaded; leave the rest NULL
- Keep each market's business days and holidays in a single owned calendar table
- Convert ranges to half-open once, split by overlap join, and allocate by cumulative rounding
- Store instants in UTC with the zone that defines the day; store future appointments as local time
- Wrap the partition column in a function and trust the result rows to reveal the cost
- Group a range by its start month when the question counts nights or days
- Hardcode a UTC offset that daylight saving changes twice a year
- Bound an incremental load with a subquery the planner can't resolve before reading
> The finance complaint from the opening, worked end to end: the vacation-rental marketplace's March revenue dashboard runs $1.2M above the ledger, its quiet markets show a 7-day average that never dips, and every refresh reads 3 years of bookings. Each symptom traces back to one of the date decisions this lesson covers, and fixing them in the right order is the answer the interviewer was waiting for.
Spines, business calendars, month splits, prunable filters and time zones: the 5 date decisions behind every reporting bug.
- Category
- SQL
- Difficulty
- advanced
- Duration
- 25 minutes
- Challenges
- 2 hands-on challenges
Topics covered: Filling Gaps with a Date Spine, Holiday-Aware Calendar Tables, Splitting Date Ranges by Month, Partition Pruning on Date Columns, Storing UTC vs Local Time
Lesson Sections
- Filling Gaps with a Date Spine (concepts: Outer Joins, Null Defaulting, Cross Joins, Window Frames, Row Offsets)
How the gap arrives The request almost never uses the words "missing days." It arrives as "plot daily bookings for each host," "why does day-over-day growth spike every Monday," or "the rolling average looks too high for quiet markets." Underneath each one sits a sparse fact table: a day with no bookings writes no row, and every calculation built on it counts rows while the question counts days. The interviewer is checking whether you name that mismatch before touching the window function. Put t
- Holiday-Aware Calendar Tables (concepts: Aggregate Window Fns, Window Partitioning, Inner Joins)
Why weekday arithmetic fails first One row per market per date Observed dates belong in the rows too, and they are where calendars split. July 4, 2026 falls on a Saturday, so federal offices close on Friday, July 3, and so do companies that follow the federal schedule. The Federal Reserve stays open that Friday, so banks process payouts over Fedwire and ACH as on any other business day. The staff calendar marks July 3 as a non-business day while the bank calendar keeps it a business day, which i
- Splitting Date Ranges by Month (concepts: Inequality Joins, Join Cardinality, Date Differences, Aggregate Window Fns, Array Flattening)
Exploding versus joining Both work, and the interviewer grades whether you name the cost. Explode when ranges are short and the next step needs daily grain anyway. Use the overlap join when ranges run long, or when the output only needs months. The overlap join, row by row A range overlaps a month when it starts before the month ends and ends after the month starts. With both sides half-open (check-out is the morning the guest leaves, so that night is not stayed), the test is 2 strict comparison
- Partition Pruning on Date Columns (concepts: Storage Optimization, Filtering)
The pruning question rarely says partition. It shows up as "this dashboard costs $40 a refresh," "this job gets slower every month," or a pull request whose filter returns the right rows. The tell is a query whose cost grows with the table's history while its answer needs a week of it. The interviewer wants you to explain the mechanism before you propose the rewrite, then show the evidence. What the engine decides before reading The Rewrite Both return identical rows. That is why this bug surviv
- Storing UTC vs Local Time (concepts: Timezones, Timestamp Types, Date vs Timestamp)
What each choice commits readers to Put it the way the interviewer wants to hear it: UTC storage moves work onto the reader, and local storage makes some questions impossible to answer at all. Work can be paid for; lost information can't be recovered, which is why the UTC instant is the default. Instant, zone and local date A local day expressed in UTC Reading a local day out of UTC storage converts the day's boundaries, never each row. The Los Angeles day of 2024-03-14 runs from 07:00 UTC that