Time looks like the simplest data type in your system. It is the one that ships the most silent, hard-to-reproduce bugs. Here are the seven that catch nearly every team.
Time is the data type that looks trivial and isn't. A datetime is just a number on a clock — until you ship it to users in another country, run it through a daylight-saving transition, or store it on a server that quietly runs on a different timezone than your laptop. Then the "number on a clock" turns out to be an ambiguous, politically-mutable, occasionally-nonexistent value, and your reminders fire an hour late, your reports double-count one day, and one customer swears an event happened before it was created.
Almost every timezone bug traces back to one of seven wrong assumptions. Here they are, each with the specific failure it produces and the fix. The good news: the fixes converge on three rules — store UTC, use timezone-aware datetime libraries, and identify zones by IANA name rather than raw offset.
Gotcha 1: Storing local time instead of UTC
The original sin. A user in Rome creates an event "at 14:00" and the app stores 14:00. No timezone attached. Six months later a colleague in New York opens it, or the value gets compared against a server clock, and there is no way to know what instant 14:00 actually referred to. The string is not a point in time; it is a point in time plus a missing piece of context.
The fix is the one rule that prevents most of the bugs below: store instants in UTC. Convert to the user's local zone only at the moment of display. UTC never observes daylight saving, never shifts with politics, and gives every stored instant one unambiguous meaning. Keep the user's chosen zone as a separate field (an IANA name — see Gotcha 7) when you need to re-render local wall-clock time later.
Gotcha 2: DST transitions create times that don't exist — and times that happen twice
Daylight saving is where "wall-clock time" stops being a well-behaved number. Twice a year the local clock jumps:
- Spring forward (the gap). In most of the EU, on the last Sunday of March the clock leaps from 02:00 straight to 03:00. The local times 02:00–02:59 do not exist that day. If your app lets a user schedule something for 02:30, that instant is undefined. Naive code silently invents an answer — or throws.
- Fall back (the repeat). In autumn the clock rewinds from 03:00 to 02:00, so every local time in the 02:00–02:59 window happens twice. "02:30" is ambiguous: which of the two is meant? A job scheduled for that window can run twice, or a duration computed across it can come out an hour wrong.
The fix is to never do arithmetic on local wall-clock times across a transition. Work in UTC, where the underlying instants are continuous and monotonic, and let a timezone-aware library map to and from local time. Good libraries expose the ambiguity explicitly — Python's zoneinfo, for instance, offers a fold attribute to distinguish the first 02:30 from the second, and will tell you when a wall-clock time falls in a gap instead of guessing.
Gotcha 3: Assuming offsets are fixed
A tempting shortcut is to store "the user is UTC+1" and reuse that number forever. Offsets are not constants. They change with the seasons (DST), and they change with politics: governments move zones, adopt or abolish daylight saving, and redraw boundaries with little notice. Samoa skipped December 30, 2011 entirely to switch sides of the date line. Multiple regions have proposed or enacted permanent-DST changes in recent years. An offset you hard-coded last year can simply be wrong this year.
The fix: do not store offsets as identity. An offset like +01:00 is a fact about one instant, not a property of a place. Store the IANA zone name (Europe/Rome) and derive the offset for a given instant at compute time, using an up-to-date tz database. When the rules change, you update the database, not your data.
Gotcha 4: Off-by-one from date-only vs datetime
A "date" like 2026-08-21 has no time and, strictly, no zone — but code constantly treats it as if it were midnight somewhere. The classic bug: a date-only value gets interpreted as 2026-08-21T00:00:00 in UTC, then displayed in a timezone west of UTC, where that instant is still 2026-08-20. Birthdays land a day early, "day of" reports drift, invoices book to the wrong period.
The fix is to keep date-only values as dates and never silently promote them to instants. When a boundary genuinely matters — "everything that happened on the 21st in Rome" — compute the start and end instants explicitly in the relevant zone (midnight Europe/Rome to midnight the next day), convert those to UTC, and query the half-open range. Be deliberate about which zone defines "a day"; it is a business decision, not a default.
Gotcha 5: Using the server's local time
Code that calls the equivalent of now() without specifying a zone gets whatever the host is configured to. That works on your machine, passes in CI, and breaks the day the workload moves to a container, a serverless region, or a colleague's laptop in another country. Timestamps written by two instances of the same service can disagree by hours, and logs stop being comparable.
The fix: make the server's timezone irrelevant. Configure hosts to UTC where you can, but more importantly, always ask for the current time in UTC explicitly and never rely on the ambient local zone. The correctness of your timestamps should not depend on a machine's regional settings.
Gotcha 6: Naive vs aware datetimes
Most languages distinguish a naive datetime (a wall-clock reading with no zone) from an aware one (an instant that knows its offset/zone). Naive datetimes are the raw material of nearly every bug above, because two of them can be compared or subtracted while representing instants hours apart — and the language will happily give you a wrong answer with no error.
- Subtracting two naive datetimes assumes they share a zone. If one came from a UTC API and the other from local input, the difference is silently off by the offset.
- Comparing naive and aware values raises errors in some languages and coerces in others — the coercing ones are the dangerous kind.
- Serialising a naive datetime drops the one piece of context a reader needs to interpret it.
The fix: make datetimes aware at the boundary and keep them aware. The moment a value enters your system — parsed from input, read from a row, returned by an API — attach its zone (usually UTC). Treat a naive datetime crossing a module boundary as a bug, the way you would treat an un-parsed string being passed where a number is expected.
Gotcha 7: UTC is not just "+00:00" — use IANA names, not offsets
Two subtle points sit under everything above. First, UTC is a time standard, not merely an offset. A timestamp written as +00:00 shares UTC's offset but carries no daylight-saving rules; UTC itself never has any. (UTC also historically inserts leap seconds to stay aligned with astronomical time — which is why 23:59:60 is a legal, if rare, wall-clock value. Most application stacks smear or ignore leap seconds, but it is worth knowing the second count of a UTC day is not always 86,400.)
Second, and most important operationally: identify zones by IANA name, not by offset. The IANA Time Zone Database (also called the tz database or "Olson" database) is the canonical, regularly-updated record of every zone's rules and their history — past and scheduled offset changes, DST rules, the lot. A name like Europe/Rome encodes all of that. An offset like +02:00 encodes none of it and cannot survive the next rule change. Store names; derive offsets; update the database when governments legislate.
| Instead of | Store / use | Why |
|---|---|---|
| Local wall-clock string | UTC instant | One unambiguous meaning |
| Fixed offset "+01:00" | IANA name "Europe/Rome" | Survives DST and law changes |
| Naive datetime | Aware datetime | Comparisons stay correct |
| Server local now() | Explicit UTC now | Host-independent |
| Bare date treated as midnight UTC | Explicit zone-scoped range | No off-by-one day |
Tool walkthrough
When you are debugging one of these bugs, the fastest first step is to pin down what an instant actually is in each zone. Toolhub's timezone converter takes a wall-clock time in one IANA zone and shows the corresponding local time in others, applying the correct DST rules for the date you pick — so you can see for yourself that a spring-forward 02:30 has no valid mapping, or that a fall-back window resolves two ways. To move between human-readable datetimes and the underlying UTC epoch value your logs and databases store, the timestamp converter turns a Unix timestamp into a readable UTC datetime and back, which is the quickest way to confirm whether a stored value really is the instant you think it is. Used together, they let you check the assumption at the root of most of these gotchas: that everyone involved agrees on which instant a given reading refers to.
Where to read further
- IANA Time Zone Database — the canonical source for zone names, rules, and their history. This is the database your language runtime and OS should be reading from.
- RFC 3339 — the profile of ISO 8601 used for timestamps on the internet, including how offsets and the
Z(UTC) designator are written. - MDN: JavaScript Date — reference for how one widely-used runtime handles (and mishandles) time, useful for understanding naive-versus-aware behaviour in practice.
None of these gotchas require exotic knowledge to avoid. Store instants in UTC, keep every datetime timezone-aware from the boundary inward, and identify zones by IANA name so the tz database — not your hard-coded assumptions — carries the rules. Do that consistently and the whole class of "it worked on my machine, an hour off in production" bugs simply stops occurring.
← All articles