skip to content

How would you model a value that changes over time — say a product's price or an employee's salary — so the system can answer "what was it on 1 March 2024?", and what are the pitfalls in choosing the interval columns?

level: middleimportance: must knowfreq 46%

answer

  1. one row per period, [valid_from, valid_to)
  2. half-open kills off-by-one
  3. current = NULL or 9999-12-31, pick one
  4. overlap = exclusion constraint / trigger
  5. change = close row + insert, one transaction

basics

~20 s

Store one row per period with valid_from and valid_to, treated as a half-open interval [from, to). A change closes the current row and inserts a new one in the same transaction. Point-in-time lookup is valid_from <= t AND valid_to > t.

solid answer

~1 min

Use **effective-dated rows**: `(product_id, price, valid_from, valid_to)`, one row per period during which the value held. Key decisions: - **Half-open intervals `[valid_from, valid_to)`.** The end of one period is exactly the start of the next, so there are no off-by-one gaps and no `+1 day` arithmetic. Closed intervals force you to decide what happens between 23:59:59 and 00:00:00. - **Representing "still current".** Either `valid_to IS NULL` (honest, but every query needs `(valid_to IS NULL OR valid_to > t)`) or a sentinel far-future value such as `9999-12-31` (uniform predicates and simpler indexing, at the cost of a magic constant). Both are defensible — pick one and apply it everywhere. - **Enforcing no overlaps.** Two rows claiming the same instant is the classic bug. Where the engine supports range types with exclusion constraints, let the database enforce it; otherwise use a unique key on `(product_id, valid_from)` plus a trigger or a strict application procedure, and add a partial unique index guaranteeing at most one open-ended row per entity. - **Writing a change** is always two statements in one transaction: close the current row by setting its `valid_to`, then insert the new row with the same instant as its `valid_from`. Index `(product_id, valid_from DESC)`, and expect "as-of joins" to be the main query shape.

code

sql · 16 lines
sql
CREATE TABLE product_price (
  product_id BIGINT        NOT NULL,
  price      NUMERIC(12,2) NOT NULL,
  period     TSTZRANGE     NOT NULL,
  EXCLUDE USING gist (product_id WITH =, period WITH &&)
);

-- portable variant
CREATE TABLE product_price_plain (
  product_id BIGINT        NOT NULL,
  price      NUMERIC(12,2) NOT NULL,
  valid_from TIMESTAMPTZ   NOT NULL,
  valid_to   TIMESTAMPTZ   NOT NULL,
  PRIMARY KEY (product_id, valid_from),
  CHECK (valid_from < valid_to)
);

go deeper

for a junior

Describe one row per period with valid_from/valid_to and the point-in-time predicate that selects the row covering an instant.

for a middle

Argue for half-open intervals, pick a convention for the open period, and show the close-then-insert transaction.

for a senior

Add declarative overlap prevention, indexing for as-of joins, granularity and time-zone decisions, and the difficulty of backdated corrections.

for a principal

Set the convention across the schema, decide where current values are denormalized for hot paths, and identify when a second time axis or engine-native period support is warranted.

## The model An effective-dated (or *valid-time*) table stores one row per period during which a fact held: ``` CREATE TABLE product_price ( product_id BIGINT NOT NULL, price NUMERIC(12,2) NOT NULL, valid_from TIMESTAMPTZ NOT NULL, valid_to TIMESTAMPTZ NOT NULL DEFAULT TIMESTAMPTZ '9999-12-31 00:00:00Z', PRIMARY KEY (product_id, valid_from) ); ``` The entity is identified by `product_id`; the primary key includes the period start because a product legitimately has many rows. "What was the price at time `t`?" becomes a single predicate: ``` SELECT price FROM product_price WHERE product_id = 42 AND valid_from <= :t AND valid_to > :t; ``` ## Half-open intervals, and why they matter Use `[valid_from, valid_to)` — start inclusive, end exclusive. Consecutive periods then share a boundary instant: a price effective from 1 March has `valid_to = 2024-03-01` on the previous row and `valid_from = 2024-03-01` on the new one. Exactly one row matches any instant, with no gap and no overlap. The alternative, closed intervals (`valid_to` is the last valid instant), forces the previous row to end at `2024-02-29 23:59:59.999999`, which depends on your timestamp precision and breaks the moment someone changes the column type or the granularity. It also makes "is this period adjacent to that one?" arithmetic instead of equality. Half-open is the convention in the SQL standard's period support and in every serious temporal design; say so explicitly. ## "Current" — NULL or sentinel `valid_to IS NULL` reads as "no end yet", which is semantically honest, and it lets a partial unique index enforce "at most one open row per product". The cost is that every point-in-time predicate becomes `(valid_to IS NULL OR valid_to > :t)`, and NULLs in comparisons are a reliable source of bugs. A far-future sentinel (`9999-12-31`) gives one uniform predicate, works with `BETWEEN`-style reasoning, makes range types and exclusion constraints straightforward, and indexes cleanly. Its downsides are the magic value itself and the fact that some drivers or engines have limits near the maximum timestamp — pick a safe value and define it once. Either is fine; inconsistency is not. Half a schema on each convention guarantees wrong results at the boundaries. ## Preventing overlaps and gaps This is where hand-rolled designs usually fail. Two rows covering the same instant make the "what was it at t" query return two prices, and application code that takes the first row silently produces different answers depending on plan order. Defences, strongest first: 1. **Exclusion constraint over a range type** — the database rejects any insert whose period overlaps an existing period for the same entity. Where available this is the correct answer; it is declarative and race-proof. 2. **A trigger** that checks for overlap on insert/update. Works everywhere, but must lock the entity's rows to be safe under concurrency. 3. **Application procedure plus a unique key** on `(entity_id, valid_from)` and a partial unique index on the open row. This stops the two most common concrete mistakes (duplicate start instants, two open rows) even without full overlap protection. Also decide whether **gaps** are legal. For a price they usually are not — the product always has a price — so a gap is a bug. For an assignment ("who managed this team") gaps are meaningful. Say which you intend. ## Writing a change A change is never an `UPDATE` of the value. It is, in one transaction: close the currently-open row by setting `valid_to = :effective_at`, then insert a new row with `valid_from = :effective_at`. Doing this in two transactions leaves either an instant with no row or an instant with two. Because the effective instant is an input rather than "now", the same mechanism naturally handles **future-dated** changes (a price rise scheduled for next month) — the future row simply exists ahead of time. **Backdated** changes are harder: inserting a row effective from last week means splitting or closing rows that already exist, and it silently rewrites what a report run yesterday would have said. If you need to preserve what the system believed at the time, valid time alone is insufficient and you need a second time axis. ## Query shapes and indexing - **Current row**: `WHERE valid_to = '9999-12-31'` (or `IS NULL`) — supported by a partial index, and by far the most frequent read. Many teams additionally denormalize the current value onto the parent row for hot paths, accepting the duplication. - **As-of join**: joining orders to the price that applied at each order's timestamp — `ON p.product_id = o.product_id AND p.valid_from <= o.placed_at AND p.valid_to > o.placed_at`. Support it with an index on `(product_id, valid_from DESC)`; range-typed columns can use a GiST-style index where available. - **Timeline**: all rows for an entity ordered by `valid_from`. ## Practical details that bite - **Granularity.** Dates or timestamps? A salary effective "from 1 April" is a date fact; a price change at 14:03 is an instant. Mixing them creates boundary bugs. Choose per table. - **Time zones.** Store instants in UTC (`TIMESTAMPTZ`), and be explicit that "from 1 April" means a specific zone's midnight — payroll and billing get this wrong constantly. - **Foreign keys.** A child row referencing a temporal parent generally references the entity (`product_id`), not a specific version. If it must pin a version, it needs the period too, which is what the standard's temporal foreign keys address. - **Deleting.** "Remove the price" usually means closing the open period, not deleting rows; deleting destroys the history you built the table for.

  • Why are half-open intervals preferred over closed intervals for valid_from/valid_to?
    With `[from, to)` the end of one period equals the start of the next, so adjacency is equality and exactly one row matches any instant — no gaps, no overlaps, no arithmetic. Closed intervals force the previous row to end one tick before the next begins, which depends on the column's precision and breaks if the precision or type changes. Half-open is also the convention used by the SQL standard's period support.
  • How do you stop two rows from claiming the same instant for the same entity?
    Best is a declarative constraint: model the period as a range type and add an exclusion constraint that rejects overlapping periods for the same entity id. Where that is unavailable, use a trigger that checks for overlap while holding a lock on the entity's rows, since an unlocked check-then-insert races. As a cheap partial defence, a unique key on (entity_id, valid_from) plus a partial unique index allowing only one open-ended row prevents the two most common concrete errors.

saying these in an interview costs you the question

  • Closed intervals with 23:59:59 end times, which break when precision or granularity changes
  • Updating the value in place instead of closing the current period and inserting a new row
  • No overlap protection at all, relying on application code to be careful
  • Mixing NULL and a sentinel for the open period within one schema
  • Storing local timestamps without a time zone, so "effective 1 April" means different instants for different readers

context