Skip to content

Versioning, history and point-in-time queries

intermediateCommon atAnthropicOpenAIStripeGoogleRead firstBinary searchHash mapsPractise in the Sandbox →

Instead of overwriting a value, append a version (timestamp, value) to that key’s list and keep the list sorted by timestamp. “Value as of t” is then the floor: the last version with timestamp ≤ t, found by binary search in O(log v) for v versions. Deletes become tombstone versions, so history stays intact.

A plain map forgets. put("price", 12.50) destroys 9.99, and nobody can ever ask what the price was last Tuesday.

A versioned store keeps a logbook per key. Each entry says “from this moment, the value is X”. To read the value at some moment t, open that key’s logbook and find the last entry written at or before t. Everything after t hasn’t happened yet from that moment’s point of view.

Because the logbook is sorted by time, “the last entry at or before t” is a binary search, not a scan. That’s the whole trick, and it shows up everywhere: databases (multi-version concurrency control), Git, configuration systems, feature flags, and audit logs.

Three words to know:

  • Version: one (timestamp, value) entry. The value is in effect from its timestamp until the next version.
  • Floor: the greatest element ≤ t. The opposite, the least element ≥ t, is the ceiling.
  • Tombstone: a version that says “deleted”. Reads that land on it return nothing, but older versions are still there for older reads.

Drag t to read the store at any moment. The search panel shows every probe of the binary search for the selected key. Then write a version with a past timestamp: it lands in the middle of the list, not the end. Write a delete and watch a tombstone hide the value only for reads after it.

Point-in-time reads: a version list per key + binary search
sandbox
price9.991012.502511.0040✕ deleted5514.0070ownerkim5raj30flagon60

binary search in price's timestamps3 probes

10
25
40
55
70
01234
floor
1. lo=0 hi=5 mid=2: ts 40 ≤ 45? yes → lo=mid+12. lo=3 hi=5 mid=4: ts 70 ≤ 45? no → hi=mid3. lo=3 hi=4 mid=3: ts 55 ≤ 45? no → hi=midanswer index = lo − 1 = 2

snapshot of every key as of t=45

price11.00 (@40)ownerraj (@30)flagnone yet
price = 11.00: the newest version written at or before 45.
version in effect at tprobed by the searchtombstone (delete)
Write a version at a past timestamp (out of order) and watch where it lands. Drag t to read history.
The problem says… What it’s asking for
“get the value as of time t” / “at or before ts” Floor search in a per-key sorted version list.
“keep the history” / “list every version” Never overwrite. The version list is the history.
“delete, but old reads still see the value” Tombstone versions, not removal.
“writes may arrive out of timestamp order” Insert in sorted position, not append.
“who had access / what was the status at time t” The same floor query over a list of state changes.
“what changed between t1 and t2” Two binary searches give the index range; count or slice it.
  1. One list per key: a map from key to a list of versions, sorted by timestamp. Parallel arrays (times, values) make binary search simple.
  2. Write: if the timestamp is newer than the last one, append (O(1)). If it’s older, insert at its sorted position. If it equals an existing one, decide with the interviewer: replace, or reject.
  3. Delete: append a tombstone version. Don’t remove anything.
  4. Read at t: binary search for the first timestamp > t. The version just before it is the floor. Index -1 means the key didn’t exist yet; a tombstone means it was deleted.

Here are steps 2 and 4 for a single history (one thermostat’s setpoints). In a store, there’s one of these per key.

floor_index.py
import bisect
def floor_index(times: list[int], t: int) -> int:
"""Index of the last time <= t in a sorted list, or -1 if every time is > t."""
lo, hi = 0, len(times) # 1. search the half-open range [lo, hi)
while lo < hi:
mid = (lo + hi) // 2
if times[mid] <= t:
lo = mid + 1 # 2. mid is a candidate; the answer is mid or later
else:
hi = mid # 3. mid is too new; the answer is before it
return lo - 1 # 4. lo = first index with time > t, so the floor is just before it
# A thermostat's setpoint history, kept sorted by time.
times: list[int] = []
temps: list[float] = []
def record(t: int, temp: float) -> None:
i = bisect.bisect_left(times, t)
if i < len(times) and times[i] == t:
temps[i] = temp # same instant: replace
else:
times.insert(i, t) # out-of-order writes land in the middle: O(n) shift
temps.insert(i, temp)
def setpoint_at(t: int) -> float | None:
i = floor_index(times, t)
return None if i < 0 else temps[i]
if __name__ == "__main__":
for t, temp in [(10, 19.0), (40, 21.5), (25, 20.0), (40, 22.0)]:
record(t, temp)
assert times == [10, 25, 40] and temps == [19.0, 20.0, 22.0]
assert setpoint_at(9) is None and setpoint_at(10) == 19.0
assert setpoint_at(39) == 20.0 and setpoint_at(1000) == 22.0
assert floor_index(times, 25) == bisect.bisect_right(times, 25) - 1 # same thing, stdlib
print("ok")

Retention: dropping history nobody can read

Section titled “Retention: dropping history nobody can read”

Unbounded history grows forever. If reads are only allowed at or after some horizon (say, the last 30 days), versions older than the floor of the horizon can never be seen again. Keep the floor itself: a read exactly at the horizon still needs it.

compact.py
def compact(times: list[int], vals: list[str], horizon: int) -> tuple[list[int], list[str]]:
"""Drop versions no read at time >= horizon can ever see.
Keeps the version in effect at `horizon` (the floor) and everything after it.
"""
keep_from = 0
for i, t in enumerate(times): # 1. find the floor of horizon (linear for clarity;
if t <= horizon: # use binary search on long lists)
keep_from = i
else:
break
return times[keep_from:], vals[keep_from:] # 2. older versions are unreachable
if __name__ == "__main__":
ts, vs = [5, 10, 20, 30], ["a", "b", "c", "d"]
assert compact(ts, vs, 25) == ([20, 30], ["c", "d"]) # a read at 25 needs "c"
assert compact(ts, vs, 20) == ([20, 30], ["c", "d"])
assert compact(ts, vs, 1) == (ts, vs) # horizon before all versions: keep everything
assert compact([], [], 9) == ([], [])
print("ok")
Operation Sorted array per key Balanced tree / sorted map per key Overwriting map (no history)
write, in time order O(1) amortized append O(log v) O(1)
write, out of order O(v) shift O(log v) O(1)
read latest O(1) (last element) O(log v) O(1)
read at t O(log v) O(log v) impossible
history of a key O(v) O(v) impossible
snapshot of all k keys at t O(k log v) O(k log v) impossible
memory every version every version + node overhead latest only

Most real workloads write in time order, so the sorted array with append is the default. Reach for a tree (.NET SortedDictionary, Python’s third-party sortedcontainers) only when out-of-order writes are common and lists are long.

A whole-store snapshot at t costs one floor search per key. If that query is frequent and keys are many, consider also keeping a global, time-ordered change log, or periodic checkpoints (see snapshots).

  • Version numbers instead of times. Each write bumps a global counter, and “version 7” is a point in time. Same floor search, just on integers you hand out.
  • Optimistic concurrency. “Update only if the version is still 7” (compare-and-set). The version number doubles as a conflict detector, the way HTTP ETag / If-Match and database row versions work.
  • MVCC. Databases like PostgreSQL keep multiple row versions so a reader sees a consistent snapshot as of its start time, while writers create new versions. Old versions are cleaned up later (PostgreSQL’s VACUUM).
  • Bitemporal data. Two time axes: when something was true in the world (valid time) and when the system learned it (transaction time). Needed for “what did we believe on March 1 about February?”
  • Range queries over history. Changes in [t1, t2]: bisect_left(t1) to bisect_right(t2), a count in O(log v).
  • Event sourcing. Store the events and derive the value by replay. Versioning is the cached result of that replay per key.
  • Floor vs ceiling. “Value at t” wants the last version ≤ t. Using bisect_left gives the first ≥ t, which is wrong whenever there’s no version exactly at t.
  • Equal timestamps. Two writes to the same key at the same t: replace, keep both in arrival order, or reject? Ask. Your binary search must agree with the choice.
  • Appending out-of-order writes. If you always append, the list is no longer sorted and binary search silently returns garbage.
  • Treating a tombstone as “not found” in history. A tombstone hides the value for reads after it, but it is still a version: it belongs in history listings and change counts.
  • Before the first version. Index -1. Don’t let it wrap around to the last element (a classic Python bug: values[-1]).
  • Mutable values. Storing a reference to a list or dict as a version means a later mutation rewrites history. Store immutable values or copies.
  • Ask: “Can writes arrive out of order? Can two writes share a timestamp, and if so who wins? Is a delete a version? Do I need full history or only point reads?”
  • Say: “Each key maps to a list of versions sorted by timestamp. A read at t is a floor search: binary search for the first timestamp greater than t, then step back one.”
  • Give costs for both cases: append in order is O(1), out-of-order insert is O(v) in an array, reads are O(log v).
  • Expect follow-ups: deletes and history, counts of changes in a time range, a snapshot of every key at t, memory limits (retention and compaction), and making it concurrent.
Check yourself
pick one
A key has versions at times [10, 25, 40]. What does a read at t = 30 return, and what does bisect_right(times, 30) return?
Check yourself
pick one
Versions: 5 → 'a', 12 → tombstone, 20 → 'b'. What do reads at t=4, t=12 and t=15 return?
Check yourself
pick one
Writes for one key always arrive in increasing time order. What's the cost of n writes followed by one point-in-time read?

These are classic exercises, not gym questions:

  • Stock price history: record trades with timestamps, answer “last trade price at time t” and “how many trades between t1 and t2”.
  • A feature-flag service that answers “was this flag on for this user at time t?”, with rules that change over time.
  • An array of n integers supporting set(i, v), checkpoint() → id and get(i, id) without copying the array on each checkpoint.
  • Git-style blame for a list of lines: for each line, which version last changed it?
  • Python bisect docs, including “Searching Sorted Lists” recipes for floor and ceiling.
  • .NET List<T>.BinarySearch and its complement-of-insertion-point return value.
  • PostgreSQL docs, “Concurrency Control: Introduction”: multi-version concurrency control in a production database.
  • Martin Kleppmann, Designing Data-Intensive Applications, ch. 7: snapshot isolation and MVCC.