Command Palette

Search for a command to run...

Writing / Architecture

Don't store what you can derive: expiry as computed state

Jul 31, 2026·4 min read
PostgreSQLData ModelingBackend

The tempting way to model an entitlement that ends — a contract, a subscription, a trial — is a status column you flip to expired when the clock runs out. It reads well, it queries easily, and it feels like state ought to be stored. It's also a small trap: you've signed up for a scheduled job, two sources of truth, and a stretch of time where the column and reality disagree.

Why a stored status drifts

A status you write needs something to write it, and that something is a job scanning for newly-expired rows and flipping them. Between the moment an end date passes and the moment the job next runs, the row says active and the truth says otherwise.

Shorten the interval and you burn compute for the privilege. Lengthen it and you widen the lie. Either way there are now two facts — the dates and the status — that can disagree, and every consumer downstream is quietly trusting that the job ran on time.

Derive it instead

Validity isn't independent state. It's a function of data already stored: the contract is active, and now falls inside its window.

valid = status == "active" AND start <= now <= end

No job, no second source of truth, no drift. The dates are the fact; validity is computed the instant somebody asks.

Note that the window is inclusive at both ends — a contract is valid for the whole of its final day, not up to the start of it. That's a product decision rather than a technical one, and the reason to state it explicitly is that inclusive and half-open windows look identical in a schema and differ by a day in a customer's experience. Whichever you choose, it should be a choice.

Compute it in exactly one place

Derived state has its own failure mode: five callers each writing the predicate slightly differently, one forgetting the status half, another disagreeing about the boundary. So it lives in a single Go function, and every path that needs to know whether a contract is valid calls it.

One definition of "valid," used everywhere, is the entire point. Scatter it and you've swapped a synchronization problem for a consistency problem, which is worse — at least a stale flag is consistently stale.

What you give up

Filtering. You can't WHERE status = 'expired' any more, because there's no such column. Queries that need to select on validity have to express the predicate, which means the predicate now exists in SQL as well as in Go, which is exactly the duplication the previous section warns about. A view is the usual answer — one SQL definition, sitting next to the Go one, with a test asserting they agree.

Indexing, in a way that surprises people. A derived predicate involving the current time cannot be indexed. Postgres requires expressions in generated columns and expression indexes to be immutable, and now() is not — so a generated is_valid column is rejected outright, and an index on the predicate can't be created either. What you index instead are the inputs: status, start_date, end_date, or a partial index on active rows. The planner does the time comparison at runtime. This works well, and it is worth knowing before you design a query plan around a column that can't exist.

"Now" becomes an input. Deriving from the clock means being explicit about which clock. Tests pin it rather than calling the real one, and anything auditable records the value it used, because "was this valid?" is unanswerable later unless you know what time you asked at.

Where a different shape fits

  • The transition has side effects. Deriving validity tells you a contract lapsed; it doesn't email anyone about it. If expiry needs to do something — notify, revoke, archive — you still need a scheduled job. The difference is that the job triggers actions rather than owning the truth, so a late run delays a notification instead of extending someone's access.
  • Validity depends on more than dates. Payment status, seat counts, manual suspension — as the inputs multiply, a predicate evaluated on every read gets expensive and hard to reason about. At that point a materialized decision, recomputed on input change rather than on a timer, starts to earn its keep.
  • Something outside your system needs to read it. A partner integration or a data warehouse can't call your Go function. They need a column or a view, and it should be generated from the same definition rather than reimplemented by whoever writes the export.

Store the dates, which are facts. Derive the verdict, which isn't — and keep the derivation somewhere singular enough that there's only ever one answer to argue with.