One string, two moments
Every layer of this page is a legitimate answer to "what time is it", and the four lines in the hero are four layers contradicting each other in public.
Here is the script that produced them, run rather than remembered. The interpreter is /usr/bin/python3.14, which reports itself as CPython 3.14.6, and it is named because §1 of the house style asks that the command shown be the command that produced the output.
$cat hero.py from datetime import datetime, timezone from zoneinfo import ZoneInfo ny = ZoneInfo('America/New_York') a = datetime(2026, 11, 1, 1, 30, tzinfo=ny) b = a.replace(fold=1) print("a prints as :", a.strftime('%Y-%m-%d %H:%M:%S')) print("b prints as :", b.strftime('%Y-%m-%d %H:%M:%S')) print("same string :", a.strftime('%Y-%m-%d %H:%M:%S') == b.strftime('%Y-%m-%d %H:%M:%S')) print("a == b :", a == b) print() print("a as UTC :", a.astimezone(timezone.utc)) print("b as UTC :", b.astimezone(timezone.utc)) print("b - a :", int(b.timestamp() - a.timestamp()), "seconds") print() g = datetime(2026, 3, 8, 2, 30, tzinfo=ny) print("g asked for :", "2026-03-08 02:30:00") print("g prints as :", datetime.fromtimestamp(g.timestamp(), ny).strftime('%Y-%m-%d %H:%M:%S'))$python3.14 hero.py a prints as : 2026-11-01 01:30:00 b prints as : 2026-11-01 01:30:00 same string : True a == b : True a as UTC : 2026-11-01 05:30:00+00:00 b as UTC : 2026-11-01 06:30:00+00:00 b - a : 3600 seconds g asked for : 2026-03-08 02:30:00 g prints as : 2026-03-08 03:30:00
The reframe you have to make is this. A timestamp is not a time. It is a position in a stack five layers deep, and there are five different things you can legitimately call "the time it is", depending on which layer you stand on. Those five are the colours in the legend above, and the rail tells you which one every section of this page is standing on.
%H:%M:%S prints the third row and never asks the fourth. The last join is where the difference goes: the format simply has no place to put it.The sentence this page argues. The stack from an instant to a string widens at every step — more machinery, more agreement, more authorship — and every join in it loses something on the way. The count loses which second was inserted. The fields lose the count. The string loses the rule. Two of those losses are why the hero's four lines cannot all be reconciled on any one layer.
Instant
Epoch count
Civil fields
Zone rule
Rendered string
Sixteen combinations, and exactly two of them are broken — both in New York. Tokyo and Kolkata have no discontinuity to fall into at all, and London's two fall on other dates. Every instant in this tool is a pinned literal and every rule comes from a transition table lifted out of tzdata 2026c — there is no clock in it. Try the gap in New York, where the last cell reads back an hour late, then the same six numbers in Tokyo, where they are simply Sunday morning.
What a clock counts
An instant is not a number and has no representation. What a computer has instead is two quite different clocks, and using the wrong one for the wrong job is where a large fraction of real time bugs come from.
The first is the wall clock: the machine's belief about what the world's civil time is. It is a belief because something outside the machine maintains it — usually NTP, dragging it towards a network of reference clocks — and a belief that is being corrected can move backwards. The second is the monotonic clock: a count that only ever increases, from an arbitrary origin nobody cares about, which nothing is allowed to adjust.
$cat clocks.py import time from datetime import datetime, timedelta from zoneinfo import ZoneInfo for name in ('time', 'monotonic'): ci = time.get_clock_info(name) print("%-10s monotonic=%-5s adjustable=%-5s %s" % (name, ci.monotonic, ci.adjustable, ci.implementation)) print() ny = ZoneInfo('America/New_York') start = datetime(2026, 11, 1, 1, 30, tzinfo=ny) stop = start.replace(fold=1) print("start :", start.strftime('%H:%M'), " stop :", stop.strftime('%H:%M')) print("by the wall clock :", stop - start) print("by the epoch count:", timedelta(seconds=stop.timestamp() - start.timestamp()))$python3.14 clocks.py time monotonic=False adjustable=Trueclock_gettime(CLOCK_REALTIME)monotonic monotonic=True adjustable=Falseclock_gettime(CLOCK_MONOTONIC)start : 01:30 stop : 01:30 by the wall clock : 0:00:00 by the epoch count: 1:00:00
get_clock_info will tell you which clock you are holding, and it is worth asking. The wall clock reports adjustable=True, which is not a warning about precision — it is the statement that something else owns this number and may move it while you are looking away. The last two lines are that fact made concrete on a pinned pair: an hour passed, and subtracting the two wall-clock readings says nothing did.So the rule that follows is absolute, and it is the one thing on this page that has nothing to do with time zones: never measure a duration with the wall clock. Not because it is imprecise, but because it is not measuring anything — it is reporting a belief that can be revised in either direction while your operation is in flight.
# This is the one listing on this page with no output beside it, and# that is the point: its result cannot be captured, re-derived or# tested, because it is different every time it runs. Everything else# here is a pinned instant precisely so that it can be.# wrong -- the wall clock owes you nothingt0 = time.time() work() elapsed = time.time() - t0# may be negative# right -- a count nothing is allowed to adjustt0 = time.monotonic() work() elapsed = time.monotonic() - t0# a duration, guaranteed
now() is: a function whose value the page has no way to promise you.The wall clock going backwards is not hypothetical. A machine that boots with a dead battery, gets a plausible-looking time from the filesystem, and is then corrected by NTP thirty seconds later has moved its wall clock by however wrong it was. Every log line, cache expiry and rate-limit window computed from time.time() across that moment is wrong, and half of them are wrong in the direction that looks like nothing happened.
One integer for the planet
The second layer is one number: how far the instant is from an agreed origin, in an agreed unit. It is the layer on which the whole world can agree without discussing where anybody is.
The origin is midnight UTC on the first of January 1970. The unit is the second. Everything else about the layer is arithmetic, and the arithmetic is genuinely simple — which is exactly why it is worth being careful about the one place it is not.
$python3.14 epoch.py 1970-01-01T00:00:00Z 0 2001-09-09T01:46:40Z 1000000000 2026-11-01T05:30:00Z 1793511000 days from 1970-01-01 to 2017-01-01 : 17167 days * 86400 : 1483228800 calendar.timegm(2017-01-01T00:00:00): 1483228800 2016-12-31T23:59:59Z -> 1483228799 2017-01-01T00:00:00Z -> 1483228800 the count advanced by: 1 seconds that actually elapsed: 2 -- 23:59:60Z happened in between datetime(2016, 12, 31, 23, 59, 60): ValueError second must be in 0..59, not 60
timegm and the multiplication agree exactly, and why the answer is computable by hand from a calendar with no reference to physics. Every day is 86,400 units long by construction, whether or not it was.That construction has a price and the leap-second block is it. On the 31st of December 2016 a leap second was inserted: the world's clocks read 23:59:60 before they read 00:00:00. Two seconds elapsed between the last two lines the count can name, and the count moved by one. Unix time is not a count of seconds. It is a count of days, converted to seconds.
What that means in practice. A Unix timestamp cannot name a leap second at all, so a platform has to do one of two things at the moment one arrives: repeat a value, or skip one. Linux with the default kernel behaviour repeats — CLOCK_REALTIME steps back one second and 23:59:59 occurs twice — which is the same shape of problem as §07's autumn fold, one second wide instead of one hour. The alternative, run by several large clouds, is to smear: spread the extra second over hours so that no clock ever repeats or jumps, at the cost of every machine inside the smear window disagreeing with UTC by a fraction of a second. Neither is wrong; they are different answers to a question the format cannot represent.
Python declines to represent it either, and the last line shows how firmly: the second field is a plain integer bounded 0–59, so the leap second is not a special case to be handled — it is ValueError.
The second that does not fit
There are two ways to keep time and they disagree by a growing number of seconds, because one of them counts and the other one watches the planet.
TAI, International Atomic Time, is a count: a weighted average of several hundred atomic clocks, ticking SI seconds, never adjusted. UT1 is the earth's actual rotation, which is irregular and on average slowing. UTC is the compromise between them: it ticks TAI's second, so it is usable for measurement, but it is kept within 0.9 seconds of UT1 by having whole seconds inserted into it when the gap grows too large. Those are leap seconds, and the body that decides is the IERS — the International Earth Rotation and Reference Systems Service — which announces each one about six months ahead.
The tz database on this machine carries the whole history, because it is exactly the kind of thing that database is for.
$python3.14 leap.py leap seconds in tzdata : 27 first : 1972 Jun 30 23:59:60 last : 2016 Dec 31 23:59:60 any of them negative : False TAI - UTC, from leap-seconds.list: 37 seconds in force since NTP second : 3692217600 right/UTC leapcnt in the TZif header: 27 UTC leapcnt in the TZif header: 0 and zoneinfo's answer for right/UTC : 2026-01-01 00:00:00
The last three lines are a warning about this page's own toolchain. The tz database ships a parallel set of zones under right/ whose files really do carry all twenty-seven leap records — the header count is right there. Python's zoneinfo reads those files and discards the records: ask it for the first instant of 2026 in right/UTC and it hands back the same civil time as plain UTC, not one thirty-seven seconds off. That is not a bug, it is the documented scope of the module, and it is a good illustration of the general rule — the data being present in a file is not the same as the library using it.
In November 2022 the CGPM — the body that governs the SI — resolved to stop inserting leap seconds by 2035, letting UTC drift away from UT1 by whatever it drifts by and deferring the question of what to do instead. If it holds, the twenty-seven above is close to the final number, and this entire section becomes a historical note rather than an operational one. That would be the first simplification the subject has ever received.
| Scale | What it is | Leap seconds? | Relationship |
|---|---|---|---|
| TAI | Atomic time. A pure count of SI seconds since 1958, never adjusted. | No | the reference |
| UTC | Civil time. TAI's second, kept near the earth's rotation by inserted seconds. | Yes | TAI − 37s |
| UT1 | The earth's actual rotation angle. Not a count — a measurement. | n/a | within 0.9s of UTC |
| Unix time | Days since 1970 × 86400 + seconds. Cannot name 23:59:60. | No | repeats or smears |
Six numbers and nowhere
"2026-06-01 09:00:00" is not a time. It is a question, and the answer depends entirely on something the string does not contain.
Year, month, day, hour, minute, second is the layer everybody actually thinks in, and it is the one with no independent existence. Six numbers by themselves do not identify a moment; they identify a moment somewhere, and until something supplies the somewhere they name as many different instants as there are zones.
$python3.14 civil.py the tuple: (2026, 6, 1, 9, 0) as a naive datetime : 2026-06-01 09:00:00 its utcoffset() : None its timestamp() :depends on the machine's own zone -- not shownAsia/Tokyo 2026-06-01T09:00:00+09:00 1780272000 Asia/Kolkata 2026-06-01T09:00:00+05:30 1780284600 Europe/London 2026-06-01T09:00:00+01:00 1780300800 America/New_York 2026-06-01T09:00:00-04:00 1780318800 UTC 2026-06-01T09:00:00+00:00 1780304400
utcoffset() is None, which is Python saying it does not know what moment this is.Kolkata is in that list for the half hour. Zone offsets are not whole hours — India is at +05:30, Nepal at +05:45, parts of Australia at +09:30 — and sweeping this machine's whole database at two pinned instants finds 25 zone names on 13 distinct offsets that are not a whole number of hours. Four of the thirteen are quarter hours and three are west of Greenwich. Code that stores an offset as an integer number of hours is wrong for every one of them.
The line the page could not print. timestamp() on a naive datetime is deliberately missing its output above, because Python resolves it against the machine's own configured zone and this machine's is America/New_York. Printing a number there would be presenting an environment-dependent value as a fact about the tuple, which is the failure §1 of the house style is about. The behaviour is worth knowing precisely because it is silent: a naive datetime is not a time, and the standard library will nonetheless turn it into one using whatever the server happens to be set to.
Which is why the useful discipline is to decide, per value, which of two things a naive datetime means. Either it is a moment somebody has not told you the zone for — in which case it is incomplete data and should be repaired at the boundary — or it is a civil intention that is deliberately zoneless, like "the alarm goes at 07:00", which is a rule about the fields rather than about any instant. Storing the second kind as a timestamp is how an alarm ends up ringing at six.
A database with authors
The join between the count and the fields is not computed. It is looked up, in a file that is versioned, that is wrong by the time it ships somewhere, and that has a changelog.
The tz database — also the IANA time zone database, also the Olson database after Arthur David Olson, who started it — is a collection of text sources describing every civil time rule anyone has been able to establish since 1970. It is compiled into the binary TZif files under /usr/share/zoneinfo, and it is released several times a year, named for the year and a letter: this machine has 2026c.
$python3.14 tzdb.py first line of tzdata.zi: # version 2026c Rule lines (R) : 1958 Zone lines (Z) : 341 Link lines (L) : 257 Z + L : 598 available_timezones() : 598 America/New_York : e9ed07d7bee0c76a US/Eastern : e9ed07d7bee0c76a identical bytes : True same object : False Mexico City, 1 July 2021 : -0500 CDT Mexico City, 1 July 2022 : -0500 CDT Mexico City, 1 July 2023 : -0600 CST Mexico City, 1 July 2026 : -0600 CST
US/Eastern and America/New_York are byte-identical on disk, and the difference between the two names is entirely historical. The last four lines are Mexico abolishing daylight saving in 2022: same code, same zone name, different answer, because the rule changed underneath.Two consequences follow from "it is a data file", and they are the reason this page states its tzdata version in the same breath as its Python version.
- Every zone answer on this page has a version attached. Not a rounding-error's worth: whole hours, for whole countries, several times a year. A page like this one built against 2022a would have printed
-0500 CDTfor July of that year and been correct at the time. - Your machine's copy is not the world's copy. A container built six months ago has six-month-old rules, and it will produce confidently wrong civil times for any country that has changed its mind since — with no error, no warning and no way for the calling code to notice.
Why the names look the way they do. The canonical form is Area/Location, where the location is the largest city in the region that shares a rule — not the country, because countries split and merge and rules do not follow them. That is why it is America/New_York and not US/Eastern, and why Europe/Kyiv was added alongside the older Europe/Kiev link. The names deliberately avoid political entities wherever they can, which is a design decision made by people who had already been burnt.
The lossy step
Daylight saving does not shift a zone. It puts a discontinuity in the map between the count and the fields — a gap where civil times do not exist, and a fold where they exist twice.
Twice a year in New York, the function from the epoch count to the civil fields stops being a bijection. In March an hour of civil time is skipped, so 02:00 to 03:00 never occurs; in November an hour is repeated, so 01:00 to 02:00 occurs twice. Both are exact, both are in the database, and here are the two instants they turn on.
$python3.14 dst.py spring forward at epoch 1772953200 one second before: 01:59:59 EST at the transition: 03:00:00 EDT fall back at epoch 1793512800 one second before: 01:59:59 EDT at the transition: 01:00:00 EST THE FOLD -- 01:30 happens twice fold=0 01:30 EDT -0400 epoch 1793511000 fold=1 01:30 EST -0500 epoch 1793514600 THE GAP -- 02:30 never happens fold=0 02:30 EST -0500 epoch 1772955000 re-renders as 03:30 fold=1 02:30 EDT -0400 epoch 1772951400 re-renders as 01:30
fold does on each side. Going forward, the wall clock jumps from 01:59:59 to 03:00:00 and one second of elapsed time covers an hour of civil time. Coming back, it goes from 01:59:59 to 01:00:00 and repeats. In the fold, fold=1 selects the later instant, an hour after fold=0. In the gap it selects the earlier one, an hour before — and both of those are an hour away from a civil time that does not exist, so neither re-renders as what you asked for.PEP 495 is Python's answer, and it is a good example of a minimal fix. Rather than raise on ambiguity, or invent a new type, it added one bit to datetime: the fold attribute, 0 or 1, which says which of two possible instants an ambiguous civil time means. In the fold, 0 is the first pass and 1 is the second. In the gap, where no instant matches, 0 means "use the offset in force before the transition" and 1 means "use the one after" — which is why both of the gap's answers above land an hour off the time that was asked for.
Epoch count
Instant
New York fields
Zone rule
The slider moves one number and it moves it evenly. Drag from the left to the right and watch the New York column go 01:00, 01:30, then 01:00 again. The slider spans ninety minutes of epoch count either side of the transition at 1793512800; there is no clock anywhere in this tool, and the transition instant is a literal taken from tzdata 2026c.
And then there is the consequence that reaches code which never mentions a time zone at all: in the fold, comparing and sorting stop agreeing with chronology.
$python3.14 sort.py 01:45 EDT fold=0 epoch 1793511900 01:30 EST fold=1 epoch 1793514600 sorted(...) : ['01:30 EST', '01:45 EDT'] sorted by timestamp : ['01:45 EDT', '01:30 EST'] early < late : False early happened first : True
sorted used < and < compared the wall clock. Sorting by timestamp() — by the epoch count, the layer on which "before" is a fact — puts them back. This is the shape of the bug in real systems: a log viewer, an event queue or a billing window that reorders one hour a year and looks perfect the rest of the time.An offset is not a zone
-05:00 cannot tell you · and what breaks in the futureAn offset is the answer a rule gave at one instant. Storing the answer instead of the rule works perfectly for the past and fails silently for anything that has not happened yet.
The distinction is easy to state and easy to lose. America/New_York is a rule: a function from instants to offsets, with all its future behaviour written down. -05:00 is a number: what that function returned once. For an event that has already happened, the number is enough, because the instant is fixed and no rule change can move it. For an event in the future, the number is a guess about what the rule will say — and the rule is edited by legislatures.
$python3.14 offsets.py booked with the zone name : 2027-03-15T09:00:00-04:00 epoch 1805115600 booked with the offset : 2027-03-15T09:00:00-05:00 epoch 1805119200 apart by : 3600 seconds the offset record, read back in New York: 2027-03-15 10:00 EDT because the rule moved between booking and the meeting: 2027-03-13 -0500 EST 2027-03-14 -0400 EDT 2027-03-15 -0400 EDT round trip through a string: wrote : 2026-11-01T01:30:00-05:00 read : 2026-11-01T01:30:00-05:00 tzinfo : UTC-05:00 epoch : 1793514600 -- the instant survived zone : UTC-05:00 -- the rule did not
tzinfo, so the zone that produced it is gone.The rule for storing a time, and it is two rules. A moment that has happened — a log line, a payment, a measurement — is an instant: store the epoch count, or a UTC string, and never think about it again. A moment that is going to happen at a civil time somebody agreed — a meeting, a flight, an alarm — is not an instant yet: store the civil fields and the zone name, and resolve them to an instant as late as you can. Storing a future event as UTC bakes in today's copy of the database, and if the rule changes before the day arrives, the event silently moves.
The half-hour case makes the same point from the other side. Storing "+05:30" tells you the offset was five and a half hours; it does not tell you the zone was Asia/Kolkata rather than one of the others that has used that offset, and it cannot tell you what the offset will be next year. Offsets are lossy in both directions: many zones share one offset at any given moment, and one zone has many offsets over time.
Written down, read back
%Z · plus a deadline in 2038The last layer is a string, and the two things worth knowing about it are which grammar you are actually writing and what the parser on the other end will silently discard.
ISO 8601 is a large standard describing many formats: week dates, ordinal dates, durations, intervals, basic form without separators, reduced precision. RFC 3339 is a small profile of it for the internet, which fixes one shape — four-digit year, hyphens, T, colons, and a mandatory offset or Z — and forbids most of the rest. Every RFC 3339 timestamp is a valid ISO 8601 one; the converse is nowhere close.
$TZ=UTC python3.14 parse.py 2026-11-01T01:30:00-04:00 -> 2026-11-01 01:30:00-04:00 2026-11-01T01:30:00Z -> 2026-11-01 01:30:00+00:00 20261101T013000 -> 2026-11-01 01:30:00 2026-W45-7 -> 2026-11-08 00:00:00 TZ=UTC UTC -> 2026-11-01 01:30:00 tzinfo=None TZ=UTC EST -> ValueError 2147483647 -> 2038-01-19T03:14:07+00:00 2147483648 -> 2038-01-19T03:14:08+00:00$TZ=America/New_York python3.14 parse.py...TZ=America/New_York UTC -> 2026-11-01 01:30:00 tzinfo=None TZ=America/New_York EST -> 2026-11-01 01:30:00 tzinfo=None
%Z accepts an abbreviation only if it is one the parsing machine is currently using, so EST parses on a New York server and raises on a UTC one. When it does parse, look at the tzinfo: None. The abbreviation was matched, consumed and thrown away. Note also the last two rows of the first block — the top two lines carry offsets and come back aware, while the basic form and the week date come back naive, because the string never had an offset in it to keep.The reason %Z cannot do better is that the abbreviations are not unique and never were. Sweeping the whole database at two pinned instants, the CST that appears in a log could be Central Standard Time at −06:00, Cuba at −05:00, or China at +08:00; IST is Irish, Israel or India; PST is the Pacific coast at −08:00 or the Philippines at +08:00. There is no lookup that resolves them, which is why every serious format carries a numeric offset instead.
And the last two lines of that listing are a deadline. A signed 32-bit time_t runs out at 2,147,483,647, which is 03:14:07 UTC on the 19th of January 2038 — the moment after which such a clock reads as December 1901. Python is not affected, and neither is any 64-bit platform, but a great deal of embedded firmware and a great many database columns are, and the deadline is closer now than the year 2000 problem was when people started worrying about it.
What to write, if you get to choose. RFC 3339 with an explicit numeric offset, or with Z, for anything that crosses a boundary between two systems — it is unambiguous, it sorts lexicographically in the same order as chronologically when it is in UTC, and every language can read it. Add the zone name in a separate field when the event is in the future, per §08. And parse with fromisoformat or a real parser rather than with %Z, which is not a parser so much as a way of losing information politely.
The payoff
Everything is on the table now. Here are the hero's two timestamps decoded from the instant to the string, the exact reason the comparison said True, and the same treatment for the civil time that does not exist.
The two of them, layer by layer
$cat decode.py from datetime import datetime, timezone from zoneinfo import ZoneInfo ny = ZoneInfo('America/New_York') a = datetime(2026, 11, 1, 1, 30, tzinfo=ny) b = a.replace(fold=1) for name, d in (('a', a), ('b', b)): print("%s fields %s fold=%d" % (name, d.timetuple()[:6], d.fold)) print(" zone rule : %s %s %s" % (d.tzinfo.key, d.tzname(), d.strftime('%z'))) print(" epoch count: %d" % d.timestamp()) print(" instant : %s" % d.astimezone(timezone.utc).isoformat()) print(" rendered : %s" % d.strftime('%Y-%m-%d %H:%M:%S'))$python3.14 decode.py a fields (2026, 11, 1, 1, 30, 0) fold=0 zone rule : America/New_York EDT -0400 epoch count: 1793511000 instant : 2026-11-01T05:30:00+00:00 rendered : 2026-11-01 01:30:00 b fields (2026, 11, 1, 1, 30, 0) fold=1 zone rule : America/New_York EST -0500 epoch count: 1793514600 instant : 2026-11-01T06:30:00+00:00 rendered : 2026-11-01 01:30:00
prints as lines are the last row, and b - a is the difference of the second. The fourth, a == b, is the one this listing cannot explain, and it is next.Why a == b is True
This is the line that looks like a bug, and it is the one worth getting exactly right, because the rule has three branches and only the first one is famous.
One — the fields. If the two tzinfo attributes are the same object, CPython never calls utcoffset() at all. It compares the six civil fields directly, and fold is not one of the fields it compares. PEP 495 specified that deliberately, so that adding the fold bit could not change the meaning of any comparison that already worked. ZoneInfo caches by key, so ZoneInfo('America/New_York') hands back the identical object every time and a.replace(fold=1) keeps it. Both halves of the hero are on this branch, and that is the whole of why a == b is True.
Two — the refusal. If they are not the same object, == asks something ordering never asks: is either side ambiguous or impossible — does its own utcoffset() change when you flip its fold? If so, CPython declines. It does not compare instants and it does not raise; it answers not equal, whatever the two values are, because there is no answer it is willing to defend. Both halves of the hero are ambiguous by construction, so this is the branch the inverted comparison below actually lands on.
Three — the instants. Otherwise it compares by offset, which is to say by instant. Ordering has no second branch to fall into, so < comes straight here — which is why the listing below has == and < giving structurally different answers about the same pair.
Flip the one condition in branch one, changing nothing else about the two moments, and watch what happens.
$python3.14 decode.py...a == b : True b - a : 0:00:00 a.tzinfo is b.tzinfo: True c is b with a second ZoneInfo object for the same zone c prints as : 2026-11-01 01:30:00 -0500 c.timestamp() : 1793514600 -- the same instant as b a.tzinfo is c.tzinfo: False a == c : False a < c : True c - a : 1:00:00
c is b. Same zone, same offset, same epoch count — built with ZoneInfo.no_cache so that the object is a different one. Nothing about the moment changed and every answer did. Against b, a is equal and zero apart; against c, it is less than, and an hour apart. Read the tail of that listing against the three branches: a == c is branch two — not "these are an hour apart" but "I decline", because both sides sit inside the fold. a < c is branch three, and it is the only line here that is genuinely about the instants. And c - a is not a comparison at all — but subtraction is built the same way, with the same identity short-circuit, which is why the b - a in that listing is 0:00:00 while c - a is an hour. The 3600 seconds in the hero came from neither: it is b.timestamp() - a.timestamp(), the only one of the hero's four lines that goes all the way down to the instant.Branch one is a fast path, not the whole rule — and not a shortcut for it either. Two different ZoneInfo objects also reach the fields branch when their offsets agree and neither side is ambiguous: 09:00 in America/New_York and 09:00 in America/Toronto on an ordinary June day are equal, by the offsets matching rather than by the objects matching. Drop the second condition and it stops working. The same two zones at 01:30 on the first of November have the same offset and name the same instant, and == still answers no, because branch two is checked first and both sides are ambiguous. What identity buys is being checked before either question is asked — it is the only route to the fields branch that survives a pair the zone cannot resolve, and there are two hours a year of those: the hour that happens twice and the hour that never happens at all.
Do not read this as "Python is broken". Read it as the layers again. Within one zone, comparing by wall clock is the behaviour almost every program wants and the behaviour that existed before fold did; across zones there is no shared wall clock, so comparing by instant is the only option available. Both are defensible; what is not available is a single operator that means the same thing in both cases. If you need chronological order and your data can contain a fold, compare timestamp() or convert to UTC first — which is §07's sorted listing, and the same fix.
And the hour that never was
$python3.14 gap.py asked for : 2026-03-08 02:30:00 in America/New_York no exception : datetime.datetime(2026, 3, 8, 2, 30, tzinfo=zoneinfo.ZoneInfo(key='America/New_York')) offset chosen : -0500 (EST) epoch count : 1772955000 that instant : 2026-03-08T07:30:00+00:00 printed again : 2026-03-08 03:30:00 EDT round-tripped : False
The four lines from the hero, resolved
| The line | What it prints | Which layer, and why |
|---|---|---|
| a prints as | 2026-11-01 01:30:00 | Rendered string. %Y-%m-%d %H:%M:%S renders the civil fields and nothing else. Both values have the same six fields, so both produce the same characters — the format has no directive in it that could distinguish them. |
| b prints as | 2026-11-01 01:30:00 | Civil fields. The two really do agree here, which is what the autumn fold is: one hour of civil time that the zone rule maps from two different instants. |
| a == b | True | Zone rule. Same tzinfo object, so the comparison takes the fields branch and never asks for an offset; fold is not compared. Give one of them a second ZoneInfo for the same zone and the same two values compare unequal — by the second branch, which declines rather than answering. |
| b - a | 3600 seconds | Epoch count. Computed from timestamp(), which resolves each value all the way down to its instant. On that layer they are 1793511000 and 1793514600, and the difference is a fact rather than a convention. |
And the reframe the whole page was for. Nothing in those four lines is an error. Two objects were built from the same six numbers, a zone rule mapped them to two different instants, an equality operator compared them on the layer where they are identical, and a subtraction of their epoch counts compared them on the layer where they are not. Every step is correct and specified. The only thing that was ever wrong was the expectation that "the time" is one thing.
What to do about it. Store instants as instants — epoch counts or UTC strings — for anything that has happened. Store civil fields with a zone name for anything that will happen. Measure durations with a monotonic clock, never a wall clock. Compare and sort by the epoch count, not by the wall clock, wherever a fold can reach your data. Keep your tzdata current and know which version you are on, because it is a data file and it is wrong the moment a legislature votes. And when a civil time arrives from outside, decide explicitly what to do with the two hours a year that are ambiguous or impossible — because the default is to pick one silently, and it will pick the wrong one about half the time.