Why do pipeline data intervals use half-open bounds that include the start but exclude the end?
answer
- the boundary instant needs one owner
- adjacent windows must tile the timeline
- BETWEEN is inclusive on both sides
- minus one second breaks at millisecond precision
basics
~20 sHalf-open bounds make consecutive intervals tile the timeline with no gap and no overlap. A record whose timestamp lands exactly on a boundary belongs to exactly one run, so nothing is processed twice and nothing is skipped.
solid answer
~40 sWith half-open bounds, a daily window is `[2026-03-11 00:00, 2026-03-12 00:00)` and the next is `[2026-03-12 00:00, 2026-03-13 00:00)`. The shared instant belongs only to the later window, so the two runs partition the timeline exactly. If you instead write an inclusive-both-ends filter such as `ts BETWEEN '2026-03-11' AND '2026-03-12'`, a record at exactly midnight on the 12th is claimed by both days and gets counted twice — the classic silent duplication bug that shows up as totals that are slightly too high. The common "fix" of subtracting a second from the upper bound is worse: it works while timestamps have second precision and starts dropping rows the moment the source emits milliseconds or microseconds. The correct pattern is always `ts >= start AND ts < end`.
code
sql · 14 lines-- double-counts any row stamped exactly at midnight
SELECT SUM(amount) FROM payments
WHERE created_at BETWEEN '2026-03-11 00:00:00'
AND '2026-03-12 00:00:00';
-- drops rows between 23:59:59.001 and 23:59:59.999
SELECT SUM(amount) FROM payments
WHERE created_at BETWEEN '2026-03-11 00:00:00'
AND '2026-03-11 23:59:59';
-- correct at any timestamp precision
SELECT SUM(amount) FROM payments
WHERE created_at >= '2026-03-11 00:00:00'
AND created_at < '2026-03-12 00:00:00';go deeper
Remember the shape of a correct window filter: greater-or-equal the start, strictly less than the end. Avoid BETWEEN for time ranges.
Explain why tiling matters and demonstrate both failure modes — the double-counted boundary row and the rows lost by a minus-one-second upper bound.
Connect interval bounds to partition overwrite and reconciliation: only tiling windows let a rerun replace exactly one partition and let daily sums balance against the source.
Make it a platform standard rather than a per-pipeline habit — a shared macro or utility that emits the bounds, so no team hand-writes a range filter and reintroduces the bug.
## What half-open means An interval written `[start, end)` includes its lower bound and excludes its upper bound. For a daily pipeline the run covering 11 March owns `[2026-03-11 00:00:00, 2026-03-12 00:00:00)`, and the run covering 12 March owns `[2026-03-12 00:00:00, 2026-03-13 00:00:00)`. The instant `2026-03-12 00:00:00.000` belongs to the second interval and only to the second interval. This is not a stylistic preference. It is the only convention under which consecutive intervals **tile** the timeline: every instant belongs to exactly one window, with no gaps and no overlaps. Two closed intervals would share their endpoint (overlap); two open intervals would leave the endpoint homeless (gap). ## The duplication bug The most common way this goes wrong is a source filter written with an inclusive upper bound: ```sql -- WRONG: claims midnight of the next day as well SELECT SUM(amount) FROM payments WHERE created_at BETWEEN '2026-03-11 00:00:00' AND '2026-03-12 00:00:00'; -- RIGHT: half-open, tiles cleanly with the next run SELECT SUM(amount) FROM payments WHERE created_at >= '2026-03-11 00:00:00' AND created_at < '2026-03-12 00:00:00'; ``` `BETWEEN` in SQL is inclusive on both sides. Any row stamped exactly at midnight is summed into 11 March by one run and into 12 March by the next. The failure mode is nasty because it is *small and silent*: on a table with millisecond-resolution timestamps only a handful of rows land exactly on the boundary, so the totals are wrong by a rounding-error amount that nobody notices until someone reconciles against the source. Systems that generate timestamps at second granularity, or that batch-insert with a truncated `created_date`, make it much larger. ## Why subtracting a second is not the fix The reflex repair is to close the window just short of the boundary: ```sql -- fragile: assumes second precision forever WHERE created_at BETWEEN '2026-03-11 00:00:00' AND '2026-03-11 23:59:59'; ``` This silently drops everything in `23:59:59.001` to `23:59:59.999` as soon as the source starts emitting sub-second timestamps — which happens when someone upgrades a connector, changes a column type, or switches from a batch export to a streaming one. Now you have the opposite bug, data loss, and it is even harder to spot because the missing rows are never anywhere. Half-open bounds are precision-independent: `< end` is correct at second, millisecond, microsecond or nanosecond resolution, and correct for date types as well. ## Where the same rule shows up elsewhere **Partition naming.** If the output partition is `dt=2026-03-11`, the rows in it must be exactly the rows matched by the half-open filter. Any mismatch between the filter and the partition label means the partition does not mean what its name says, and every downstream consumer inherits the error. **Overwrite semantics.** Idempotent reprocessing works by deleting and rewriting one partition. That is only safe if the partition and the interval are the same set of rows; overlapping intervals would have one run deleting rows another run wrote. **Reconciliation.** "Sum the daily partitions and compare to the source total for the month" only balances if the daily windows tile. Overlaps inflate the sum, gaps deflate it, and either way the reconciliation cannot tell you which day is at fault. **Event time versus ingestion time.** The half-open rule tells you how to slice a timestamp column, not *which* timestamp column to slice. Filtering ingestion time gives windows that always balance but assign records to the day they arrived; filtering event time gives business-correct assignment but exposes you to late arrivals landing after their window closed. Pick deliberately and write it down, because switching later silently reshuffles history. ## How to answer State the tiling property first, then give the concrete failure: `BETWEEN` double-counts the boundary row, minus-one-second drops sub-second rows, `>= start AND < end` is right at every precision. Finish by connecting it to partition overwrite: intervals that tile are what let a rerun replace exactly one partition without touching its neighbours.
- How would this bug show up in production, and how would you catch it?As totals a fraction of a percent above the source, growing with volume and worst on sources with coarse timestamps. Catch it with a reconciliation check that sums the daily partitions over a month and compares against a single source-side aggregate for the same half-open range; an overlap inflates the partition sum.
- Should the filter use event time or ingestion time?Event time assigns records to the period they describe, which is what the business means, but exposes you to arrivals after the window closed. Ingestion time always balances and never needs late reprocessing, but misassigns delayed records. Choose per pipeline, document it, and do not change it retroactively without reprocessing history.
- Does the same rule apply when partitioning by date rather than timestamp?Yes, and it is easier to get wrong. A `dt` column that is a date still needs `dt >= start_date AND dt < end_date`; writing `dt <= end_date` pulls in the following day's partition entirely, not just a boundary row, which turns a subtle overlap into a full duplicate day.
Adjacent rooms share a wall. The wall has to be counted as part of exactly one room, or measuring both rooms and adding them up gives you more floor space than the building has.
saying these in an interview costs you the question
- Uses BETWEEN with the next interval's start as the upper bound
- Subtracts one second to close the window
- Says boundary rows are too rare to matter
- Assumes source timestamps will always have second precision
- Names a partition differently from the filter that populated it