The Logs Came From the Future — Five Incidents a Clock Made
One in the morning came twice
One-line summary
Store instants (UTC) and use local time only for display. Records stored in local time overlap or disappear twice a year.
Why this is needed
"Our service is used only in Korea, so we just stored local time." This sentence causes no trouble for years. Korea does not currently use daylight saving time, so the difference between Korean time and UTC is always fixed at 9 hours. The problem comes afterward. The moment you get US customers and start sending notifications in their local time, add one more cloud region, or switch the settlement basis to local business days, this design breaks. And since the days it breaks are only twice a year, ordinary testing never catches it.
How it works
The rules for converting between local time and an instant are set politically by each country, and the collection of that history is
the IANA time zone database. Names such as Asia/Seoul and
America/New_York are not city names but the names of the entire set of time rules of that region. This database
is updated whenever a political decision is made. The latest release at the time of checking
was 2026d (published 2026-09-11), and one of the changes in that release was the Northwest Territories of Canada
moving to a permanent -06. Because the rules keep changing like this, you must not hard-code offsets
as numbers such as +09:00 in code. Store the name and leave the conversion to the
database.
On the day daylight saving time ends, clocks are turned back by one hour. Then that hour of local
time occurs twice. On the day it starts, clocks are turned forward by one hour, so that hour
does not exist at all. To tell these two cases apart, Python gave datetime an attribute called fold.
PEP 495 is the proposal for it: 0 is the earlier of the two candidates
and 1 is the later.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
def classify(naive):
early = naive.replace(tzinfo=ny, fold=0)
late = naive.replace(tzinfo=ny, fold=1)
if early.utcoffset() == late.utcoffset():
return "normal" # 후보가 하나뿐 — 평범한 시각
back = early.astimezone(timezone.utc).astimezone(ny).replace(tzinfo=None)
return "ambiguous" if back == naive else "nonexistent"
classify(datetime(2025, 11, 2, 1, 30)) # ambiguous — 두 번 온다
classify(datetime(2025, 3, 9, 2, 30)) # nonexistent — 오지 않는다
The latter part of the judgment is the key. The fact that the two candidates' offsets differ does not by itself separate a "time that occurs twice" from a "time that does not exist" — in both cases the offsets differ. The distinction is made with a round trip. Convert the local time to an instant and back to local time; if the original value comes out, the time is real (the occurs-twice case), and if a different value comes out, the time never existed.
The storage rule then becomes simple. Store instants in UTC. This is why, among PostgreSQL's
date/time types,
you are told to use timestamptz. The name is confusing, but this type does not store a time zone — it converts the input to
UTC for storage and shows it in the session's time zone when read. By contrast, timestamp (without time zone)
holds the written number as is, so nobody knows which region that number belongs to.
The exception, however, is future appointments. "A meeting at 9 a.m. on the first Monday of next month" is an appointment made in local time, not an instant. If that country changes its daylight saving rules in the meantime, a value fixed in UTC fires at the wrong time. For these, store the local time and the time zone name together and convert when reading.
What you see in the field
The most common accident is a batch job named after local time. In the early morning when daylight saving time ends, the "01:00 settlement" runs twice. Both runs finish normally, the logs show no errors, and the total number of runs is the same as usual. It is just that that day's sales get counted twice.
The second most common is a schedule placed on a nonexistent time. The 2 a.m. hour on the morning daylight saving time starts does not exist in that region, so a job scheduled for that time is quietly skipped once. It did not fail; it simply never happened, so no failure alert arrives either. Nobody knows until someone says the next morning, "there's no report from yesterday."
The third is human misjudgment. Looking at records kept only in local time, someone asks, "there are two entries at 01:30 — are they duplicates?" Those two are in fact different instants, an hour apart. If the records have no offset, there is in principle no way to tell.
What to check in the next quiz
Check which two accidents happen when you store in local time, how fold separates the two cases,
and what exceptions there are to the rule of storing in UTC.