Skip to content

Timestamps, windows and time

coreCommon atStripeAnthropicGoogleBloombergRead firstIntervalsPractise in the Sandbox →

Store instants (epoch seconds or UTC timestamps), not local wall-clock times. Represent every span as half-open [start, end): consecutive windows then tile time with no gaps and no double counting, and the length is just end − start. Convert to a time zone only at the edges, and pass the clock in as a dependency so tests can control it. Containment and overlap checks are O(1).

Time in software is two different things that look alike:

  • An instant: a point on the universal timeline, such as 1772953200 seconds since 1970-01-01T00:00Z. Everyone on Earth agrees on it.
  • A wall-clock time: what a clock on a particular wall shows. That same instant is “March 8 2026, 3:00 am” in New York and “7:00 am” in London. A wall-clock time only means something together with a time zone, and some wall-clock times happen twice a year while others never happen at all (2:30 am in New York on that very morning).

Most bugs come from mixing them. Keep instants in your data and your arithmetic; turn them into wall-clock strings only when a human reads them or a business rule is defined in local terms (“the store closes at 9 pm local”).

The second habit is half-open intervals. A window [10:00, 11:00) includes 10:00 and excludes 11:00. The next window is [11:00, 12:00). An event at exactly 11:00 belongs to exactly one of them. Closed windows [10:00, 11:00] would count it twice; open ones would count it nowhere. Dijkstra argued for this convention for array ranges in 1982 for the same reason: adjacent ranges share an endpoint, and lengths subtract cleanly.

Move events onto boundaries (multiples of the bucket size) and switch the interval type. With [a, b) the bucket counts always add up to the number of events. With [a, b] boundary events are counted twice; with (a, b] an event at the very start is lost.

Window boundaries: who owns the edge?
interactive
[0,30)2[30,60)3[60,90)2[90,120)30306090120t=0: in 1 bucket(s)t=12: in 1 bucket(s)t=30: in 1 bucket(s)t=30: in 1 bucket(s)t=47: in 1 bucket(s)t=60: in 1 bucket(s)t=89: in 1 bucket(s)t=90: in 1 bucket(s)t=104: in 1 bucket(s)t=119: in 1 bucket(s)
counted oncecounted twicecounted nowhere
events10
sum of buckets10
double-counted0
lost0
Half-open buckets tile the line: every instant belongs to exactly one bucket, so the bucket counts always add up to the number of events. An event exactly on a boundary goes to the bucket that starts there.

Now the time-zone side. Each column below is exactly 30 minutes of real time apart. Watch the local clock jump forward an hour in spring (some local times never exist) and step back in autumn (some local times happen twice). The labels come from your browser’s time-zone database, live.

Wall clock vs elapsed time across a DST change
pick a zone
UTC
05:00
05:30
06:00
06:30
07:00
07:30
08:00
08:30
local
00:00
GMT-5
00:30
GMT-5
01:00
GMT-5
01:30
GMT-5
03:00
GMT-4
03:30
GMT-4
04:00
GMT-4
04:30
GMT-4
first instant after the jumpwall-clock time that happens twice
Each column is 30 real minutes apart, but the local clock jumps an hour at 07:00 UTC. The local times in between never exist: "01:30 + 30 min" lands on 03:00. A job scheduled for a missing local time must be moved by a rule you choose (Python's fold/zoneinfo, .NET's TimeZoneInfo.IsInvalidTime).
start2026-03-07 14:00 GMT-5
+24 h elapsed2026-03-08 15:00 GMT-4
+1 calendar day2026-03-08 14:00 GMT-4 (23 h elapsed)

The chips at the bottom are the classic “tomorrow at the same time” trap: adding 24 hours of elapsed time and adding one calendar day give different answers on a DST day.

The problem says… What to apply
“valid from start until expiry”, “TTL”, “lease” Half-open start <= t < expiry; store absolute end, not “remaining”.
“per minute / per hour / per day buckets” Tumbling buckets [k·size, (k+1)·size), bucket = floor(t / size).
“in the last N seconds” A trailing window (now − N, now] or [now − N, now); ask which.
“timestamps never decrease” / “may arrive out of order” Decide what late data does: reject, accept within a grace period, or re-bucket.
“at 9 am in the customer’s time zone”, “daily at midnight local” Store the zone id (America/New_York), compute instants per occurrence.
“expires after 30 seconds” in code you must test Inject a clock; never sleep in tests.
  1. Contains: start <= t < end. Start belongs, end doesn’t.
  2. Overlap length: max(0, min(end₁, end₂) − max(start₁, start₂)). Touching intervals overlap by 0.
  3. Bucket: the bucket holding t starts at floor(t / size) · size. Use real floor division: C# / and JS Math.trunc round toward zero, which puts t = −1 in the wrong bucket.
contains / overlap / bucket_start.py
def contains(start: int, end: int, t: int) -> bool:
"""1. Half-open [start, end): the start belongs, the end does not."""
return start <= t < end
def overlap(a: tuple[int, int], b: tuple[int, int]) -> int:
"""2. Length shared by two half-open intervals; 0 if they only touch."""
return max(0, min(a[1], b[1]) - max(a[0], b[0]))
def bucket_start(t: int, size: int) -> int:
"""3. Tumbling bucket [k*size, (k+1)*size) holding t. Python's // floors, even for t < 0."""
return (t // size) * size
if __name__ == "__main__":
# Back-to-back windows share a boundary but never an instant.
assert contains(0, 60, 59) and not contains(0, 60, 60) and contains(60, 120, 60)
assert overlap((0, 60), (60, 120)) == 0 # touching is not overlapping
assert overlap((10, 50), (40, 90)) == 10
assert overlap((0, 100), (20, 30)) == 10 # nested
assert bucket_start(119, 60) == 60 and bucket_start(120, 60) == 120
assert bucket_start(-1, 60) == -60 # floor, not truncation toward zero
# Lengths add up with no off-by-one: [0,60) + [60,120) is exactly [0,120).
assert (60 - 0) + (120 - 60) == 120 - 0
print("ok")

Code that calls now() directly is hard to test: the test has to sleep, and it’s flaky near boundaries. Pass the clock in.

  1. The object reads the clock once at creation and stores an absolute end instant.
  2. In tests, pass a fake clock you advance by hand. Tests become instant and deterministic.
  3. Test the exact boundary: one tick before the end (alive) and exactly at the end (expired).
Deadline + FakeClock.py
import time
from typing import Callable
Clock = Callable[[], float] # seconds; time.monotonic in production
class Deadline:
"""Expires `seconds` after creation. Takes the clock as a parameter, never calls time.* itself."""
def __init__(self, seconds: float, clock: Clock = time.monotonic) -> None:
self._clock = clock
self._end = clock() + seconds # 1. read the clock once, store an absolute end
def remaining(self) -> float:
return max(0.0, self._end - self._clock())
def expired(self) -> bool:
return self._clock() >= self._end # 2. half-open: alive on [start, end)
class FakeClock:
"""2. A test clock you move by hand: deterministic, instant, no sleep()."""
def __init__(self, t: float = 0.0) -> None:
self.t = t
def __call__(self) -> float:
return self.t
def advance(self, dt: float) -> None:
self.t += dt
if __name__ == "__main__":
clk = FakeClock(1_000.0)
d = Deadline(30, clk)
assert not d.expired() and d.remaining() == 30
clk.advance(29.5)
assert not d.expired()
clk.advance(0.5) # 3. exactly at the end: expired
assert d.expired() and d.remaining() == 0
assert not Deadline(5).expired() # the real clock still works
print("ok")
Python TypeScript / JS C# / .NET
Current instant datetime.now(timezone.utc), time.time() Date.now() (ms) DateTimeOffset.UtcNow, TimeProvider.System.GetUtcNow()
Elapsed time time.monotonic() performance.now() Stopwatch, TimeProvider.GetElapsedTime
Time zones zoneinfo.ZoneInfo("America/New_York") (3.9+) Intl.DateTimeFormat({ timeZone }); Temporal.ZonedDateTime where available TimeZoneInfo.FindSystemTimeZoneById (IANA ids work cross-platform since .NET 6)
Ambiguous / missing local times fold attribute (PEP 495) Temporal’s disambiguation option TimeZoneInfo.IsAmbiguousTime, IsInvalidTime
Test clock pass a callable pass a { now() } object TimeProvider + FakeTimeProvider (package Microsoft.Extensions.TimeProvider.Testing)

Language-specific traps worth knowing by heart:

  • Python: a naive datetime (no tzinfo) is not an instant. Comparing naive with aware raises TypeError; datetime.utcnow() returns a naive value and is deprecated since 3.12. Use aware datetimes everywhere.
  • JavaScript: Date months are 0-based (new Date(2026, 2, 8) is March 8). Date.parse("2026-03-08") is UTC midnight, but Date.parse("2026-03-08T10:00") (no Z) is local time. Temporal fixes most of this; it’s shipping in newer browsers but not in Node 22, so check your runtime.
  • C#: DateTime.Kind can be Unspecified, which silently becomes local or UTC depending on the call. Prefer DateTimeOffset for instants.
Operation Time
contains / overlap / bucket of one timestamp O(1)
Count events in a window over a sorted list O(log n) with two binary searches
Merge or intersect n intervals O(n log n) to sort, O(n) to sweep; see intervals
Local ↔ instant conversion O(log transitions) lookup in the zone’s rule table; effectively O(1)

The real cost of time bugs isn’t CPU: it’s the event counted twice in a billing report, the coupon that expired a second early, the cron job that ran twice at 1:30 am in November.

  • Tumbling vs sliding vs session windows. Tumbling: fixed, non-overlapping buckets. Sliding: a window that moves with each event or each query. Session: windows that close after a gap of inactivity. See streaming.
  • Event time vs processing time. When did it happen vs when did we see it. Late events need a policy: drop, accept within a grace period, or correct a past bucket.
  • Inclusive end dates from humans. “Valid through March 31” means [Mar 1, Apr 1) in code. Convert at the boundary and say so.
  • Calendar arithmetic. “Add one month” to January 31 has no single right answer (Feb 28? Mar 3?). Ask, and use the library’s rule explicitly.
  • Leap seconds. Unix time ignores them; every day is exactly 86,400 seconds. In interviews, say you’re assuming that.
  • Closed intervals that double count at boundaries, or < where <= was needed at the start.
  • Storing local times without an offset or zone. Twice a year you can’t tell which instant you meant.
  • Adding 86,400 seconds to mean “tomorrow” in a zone with DST.
  • Truncating division for bucket indices on negative timestamps.
  • Mixing units: JS milliseconds vs Unix seconds vs .NET ticks (100 ns). Name variables ts_ms, ts_s.
  • Reading the clock several times in one operation, so checks disagree with each other. Read once, pass the value down.
  • Storing “remaining TTL” instead of an absolute expiry: it goes stale the moment you store it.
  • Ask: Are timestamps seconds or milliseconds? Integers? Can they decrease or arrive out of order? Is the window end inclusive? Is “a day” UTC or the user’s zone?
  • Say your convention up front: “I’ll treat every range as half-open, start <= t < end, so adjacent windows don’t overlap.”
  • If the API takes ts as a parameter, say why that’s good: it’s an injected clock, the code is deterministic and replayable.
  • Test the boundary instants explicitly: end − 1, end, start.
  • Follow-ups to expect: late or out-of-order events, per-user time zones, expiry with a grace period, scaling a windowed counter (see rate limiting).
Check yourself
pick one
A promo is valid for 3600 s from t0. A request arrives at exactly t0 + 3600. With the half-open convention, is it valid?
Check yourself
pick one
You bucket timestamps per minute with `ts / 60 * 60` in C# (integer division). One event has ts = -30. Which bucket does it land in?
Check yourself
pick one
A daily report job is scheduled at 01:30 America/New_York. What happens on 2026-11-01?

Classic exercises, not gym questions:

  • Given a list of meetings as [start, end), compute total busy time with overlaps merged.
  • Write local_midnights(zone, start_date, n) that returns the UTC instants of the next n local midnights, and check one week across a DST change has a 23-hour day.
  • Convert human “valid from Mar 1 through Mar 31” ranges to half-open instants in a given zone.
  • Unit-test a session timeout class with a fake clock: alive at end − 1, expired at end, no sleep.