A reminder fires at 4:00 in the morning. Nobody set it for 4:00 in the morning.

The support ticket says the alert was scheduled for 1:00 PM. You check the database and the row says 2026-03-14 04:00:00, which is exactly what the scheduler read and exactly when it ran. Nothing crashed. No exception was thrown. Every line of code did precisely what it was written to do, and the result was still wrong — because somewhere between the user's browser and that column, a number lost the one piece of information that gave it meaning.

Time zone bugs have a signature: they are silent, they are intermittent, and they show up months after the code ships. They don't announce themselves in a stack trace. They show up as a customer saying "the report covers the wrong day" or a billing run that double-charges one hour in November. The good news is that almost all of them come from a single mistake repeated in different costumes, and once you can name that mistake, you can design it out of your system for good.

The difference between a moment and a reading on a wall

Here is the distinction that solves most of this, and almost nobody is taught it explicitly.

An instant is a single point on the universal timeline. The moment a payment cleared. The moment a server rebooted. It happened once, everywhere in the universe simultaneously, and the only disagreement is what number people's clocks were showing when it did.

A wall time is what a clock on a wall reads: "March 14, 9:00 AM." On its own, that is not a moment. It's a moment plus a place. March 14 at 9:00 AM in Chicago and March 14 at 9:00 AM in Berlin are seven hours apart. Without a location attached, that string is an incomplete sentence.

Most time zone bugs are one type being quietly substituted for the other.

The bug at the top of this post is exactly that substitution. Someone typed a wall time, the code treated it as an instant, and the two silently disagreed by however many hours the user happened to live from UTC. Nothing validates this for you. A datetime object with no time zone attached will happily do arithmetic, sort, serialize, and save — it just means something different than you think it does.

Python calls these naive and aware datetimes, which is an unusually honest bit of naming:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

naive = datetime(2026, 3, 14, 9, 0)          # 9:00 somewhere. Useless alone.
aware = datetime(2026, 3, 14, 9, 0,
                 tzinfo=ZoneInfo("America/Chicago"))  # an actual moment

print(naive.timestamp())   # guesses the server's local zone — the silent bug
print(aware.astimezone(timezone.utc))  # 2026-03-14 14:00:00+00:00

That third line is the whole problem in miniature. naive.timestamp() doesn't fail. It picks whatever zone the machine is set to, which on your laptop is your city and in production is almost certainly UTC. The same code produces different answers in two environments, and your tests pass in both.

Store UTC, convert at the edges

The rule that falls out of this is short enough to put on a sticky note: convert to UTC the moment data enters your system, keep it in UTC everywhere inside, and convert to local time only in the last step before a human reads it.

Think of it like character encoding. You decode bytes into text at the boundary, work in text internally, and encode back to bytes on the way out. Time works the same way. The zone is a presentation concern, not a storage concern. It belongs in the same layer as date formatting and currency symbols.

This gives you three concrete wins that are easy to underrate until you've lived without them:

Sorting and comparison become trustworthy. Two UTC instants can be compared as numbers, always, with no edge cases. Compare two wall times from different zones and you are comparing labels, not moments.

Arithmetic stops lying. "Three hours from now" is unambiguous in UTC. In local time, three hours from 11:00 PM on a DST transition night might be 1:00 AM or 3:00 AM, and whichever one your code picks, it picked it by accident.

AD

Debugging gets dramatically cheaper. When every log line, every row, and every queue message is in the same zone, you can put them side by side and the ordering is real. Anyone who has tried to correlate an application log in server-local time against a CDN log in UTC knows exactly how much time this saves.

What your database column actually promises

This is where the abstraction leaks, because the type names are genuinely misleading and they're misleading differently in each engine.

TypeWhat it storesWhat it gives back
MySQL DATETIMEThe literal wall time you wroteThe same literal string, no conversion
MySQL TIMESTAMPConverted to UTC on writeConverted back to the session's zone on read
Postgres timestampThe literal wall time, no zoneThe same literal value
Postgres timestamptzConverted to UTC on writeConverted to the session's zone on read

Read the middle column twice. MySQL DATETIME and Postgres timestamp are storage for wall times — fine if that's genuinely what you mean, dangerous if you're using them for instants and relying on discipline to keep everything UTC. TIMESTAMP and timestamptz are storage for instants, and both quietly convert based on the session time zone, which means a client that connects with a different session setting sees different numbers from the same row.

Postgres's timestamptz is the most commonly misunderstood of the four. It does not store a time zone. It converts your input to UTC, stores that, and converts back on the way out. The "with time zone" in the name means time-zone-aware, not time-zone-carrying.

The practical takeaway: whichever engine you're on, pin the session zone to UTC explicitly rather than inheriting whatever the server, the driver, or the container image happened to set.

-- MySQL: make the session's interpretation explicit
SET time_zone = '+00:00';

-- Convert only for display, at the very end
SELECT
  id,
  created_at,                                        -- UTC, for logic
  CONVERT_TZ(created_at, '+00:00', 'America/Chicago') AS local_created
FROM orders
WHERE created_at >= '2026-03-01 00:00:00';

One footnote that bites people: CONVERT_TZ only accepts named zones like 'America/Chicago' if the time zone tables are loaded into the server. On a fresh install they often aren't, and the function silently returns NULL instead of erroring. If you see unexplained nulls in a converted column, check that first.

The hour that doesn't exist and the hour that happens twice

Daylight saving time is where "just add an offset" finally falls apart, and it's worth understanding precisely rather than defensively.

When clocks spring forward, a wall time does not exist. In the US on March 8, 2026, the clock jumps from 1:59 AM to 3:00 AM. There is no 2:30 AM that day. If a user has a recurring 2:30 AM job, what should happen? There is no correct answer — only a decision you make on purpose or one your library makes for you.

When clocks fall back, a wall time happens twice. On November 1, 1:30 AM occurs, then an hour passes, then 1:30 AM occurs again. A wall time in that window maps to two different instants. Code that resolves it by guessing will be right half the time, and the half it gets wrong is the half that generates the support ticket.

from datetime import datetime
from zoneinfo import ZoneInfo

chicago = ZoneInfo("America/Chicago")

# Ambiguous: 1:30 AM happens twice on fall-back day
first  = datetime(2026, 11, 1, 1, 30, tzinfo=chicago, fold=0)
second = datetime(2026, 11, 1, 1, 30, tzinfo=chicago, fold=1)

print(first.utcoffset())   # -5:00 — still on daylight time
print(second.utcoffset())  # -6:00 — back on standard time
print(second - first)      # 0:00 ... naive subtraction won't save you

That last line is the trap. Subtracting the two aware datetimes gives zero, because the subtraction compares wall times, not instants. Convert both to UTC first and the real one-hour gap appears. Any time you're doing arithmetic on times that might cross a transition, convert to UTC before you subtract, not after.

And none of these rules are permanent. The IANA time zone database — the thing every language quietly depends on — gets updated several times a year because governments change their minds about DST with weeks of notice. Your application is only as correct as its copy of that database. If you're deploying containers, the tz data is baked into the image, which means a long-lived image slowly drifts out of date. That's a dependency to patch, not a constant.

The exception: future events

There is one important case where "store UTC" is the wrong answer, and it trips up teams that have internalized the rule too well.

AD

If someone schedules a meeting for March 15, 2027 at 9:00 AM in Berlin, what they mean is the wall time. They mean "when the clock in this room says nine." If you convert that to UTC today and store 2027-03-15T08:00:00Z, you have frozen an offset that might not be true by then. If Germany changes its DST policy — and the EU has debated exactly this for years — your carefully stored UTC instant now fires at the wrong time on someone's calendar.

For things that already happened, store the instant. For things people intend to happen, store the intention.

So for future, human-scheduled events, store the wall time and the IANA zone identifier as two separate fields — 2027-03-15 09:00 plus Europe/Berlin — and resolve them to an instant when you actually need to fire. Store the zone as an identifier like Europe/Berlin, never as an abbreviation like CET and never as a raw offset like +01:00. Abbreviations are ambiguous across regions, and an offset is a snapshot of a rule, not the rule itself.

What good looks like in practice

If you want one pass through a codebase that removes most of this category of bug, here's the order I'd do it in:

Make naive datetimes impossible to create by accident. Parse with an explicit zone at every input boundary — API handlers, CSV importers, form parsers. If your language lets you lint for it, lint for it.

Set the session time zone to UTC explicitly in your connection setup rather than trusting the default. Do the same in your container's environment.

Send ISO 8601 with an offset over the wire, always: 2026-03-14T14:00:00Z. Never send a bare 2026-03-14 14:00:00 in JSON and hope the other side assumes what you assumed.

Store the user's IANA zone on their profile as a first-class field. You'll need it for display, for digest emails, and for anything scheduled — and inferring it from an IP address every time is both unreliable and slightly creepy.

Write one test that runs your date logic with the process time zone set to something that isn't UTC. TZ=Asia/Tokyo pytest will surface hardcoded assumptions in about four seconds.

On the front end, the new Temporal API finally gives JavaScript honest types for this — Temporal.Instant for moments, Temporal.PlainDateTime for wall times, and no more silent coercion between them. It reached Stage 4 in the ECMAScript standard in 2026 and ships natively in Chrome 144+, Firefox 139+, and Edge 144+, with Safari still catching up as of this writing. If you're targeting Safari, a polyfill covers the gap, and the mental model is worth adopting either way.


None of this is difficult, which is exactly why it gets skipped. A naive datetime works perfectly on your machine, in your zone, in the season you wrote it. It fails in March, on a server in a different country, for a user you've never met.

The fix costs an afternoon: decide that a moment is a moment, store it in UTC, keep the wall time only when the wall time is genuinely what someone meant, and convert at the last possible second. Do that once, and the 4:00 AM reminder stops being a mystery — because it never gets scheduled in the first place.