Programming with PythonData Formats and Pipelines › Day 95

Day 95: Dates, Times, and Time Zones

Day 95 of 365 — Dates, Times, and Time Zones

After this lesson you will be able to tell an instant from a wall clock and never confuse the two again — naming what a naive datetime actually is and why the standard library still lets you build one; attaching a zone with zoneinfo and reading the offset the IANA database says was in force; arguing, rather than asserting, that UTC is the storage format and local time a rendering concern, with a sort that proves it; measuring the 23-hour day and the 25-hour day and explaining why subtracting two local midnights cannot; producing the two distinct instants that both render as 01:30 on one October night and separating them with fold; finding the wall-clock reading in March that never happened and watching a round trip through UTC fail to come home; writing RFC 3339 rather than merely ISO 8601 and saying why lexicographic order equals chronological order for one of those and not the other; naming the two traps in strptime — %Z parsing almost nothing and discarding what it parses, and a date string that parses two ways two months apart with no error raised; choosing between a wall clock and a monotonic clock for a duration and saying what the wrong one reports on the night the clocks change; explaining why "add one month" is not a timedelta and why the standard library refuses to choose the policy for you; and finally writing the offset resolver from scratch over a rule table, in both directions, classifying every wall reading as normal, ambiguous or nonexistent, and checking it against the real database on twenty-six comparisons — so that zoneinfo stops being magic and becomes thirty lines you have already written.

Course
Programming with Python
Category
Data Formats and Pipelines
Reading time
≈ 42 min
Practical time
≈ 30 min
Lesson duration
1h 12m
Last verified
2026-08-16

Hands-on lab for this lesson

Lab files on GitHub: https://github.com/ai-roadmap-365/ai-roadmap-365.github.io/tree/main/labs/sections/programming-with-python/day-095-dates-times-and-time-zones

  1. Get the hands-on files. Clone the labs repository once (you can reuse this clone for every lesson). This works on macOS, Linux, and Windows (PowerShell or WSL):
    git clone https://github.com/ai-roadmap-365/ai-roadmap-365.github.io.git
    cd ai-roadmap-365.github.io
  2. Open this lesson's lab. Move into the directory for this specific day. Every lab lives at the same predictable path — section / subsection / week / day:
    cd labs/sections/programming-with-python/day-095-dates-times-and-time-zones
  3. Read the lab guide. Open `README.md` in that directory. It lists the exact commands, what each does, the expected output, and how to check your work — read it before running anything.
  4. Run it and check your work. Follow the README's "How to run" section: run the example first to see the finished result, then complete the numbered exercises in `starter/`, then run the tests. The tests pass (exit 0) only when your work is correct.
    bash tests/run_tests.sh   # or the test command named in the lab README

You can also open the lab as a local page (works offline, shows the file tree and expected output).

Learning objectives

By the end of this lesson you will be able to:

Prerequisites

Why this matters

A subscription business runs one job every night. It reconciles the day’s payments, writes a partition of rows into the warehouse, and emails a summary. It has run at 01:30 every night for two years. The schedule entry says exactly that: 01:30, Europe/London.

On the night of 25 October 2026, every customer was charged twice.

Nobody had deployed anything. Nobody had touched the scheduler. The job ran at 01:30, finished, and ran again at 01:30 — and both runs were legitimate, because the local clock in London genuinely read 01:30 twice that night, one hour apart.

Here are the bytes, from a real run on the authoring machine:

  the local clock read 01:30 2 times:
    2026-10-25T00:30:00+00:00 UTC  =  01:30 BST (fold=0)
    2026-10-25T01:30:00+00:00 UTC  =  01:30 GMT (fold=1)

Two instants. One string. The scheduler was matching the string.

Five months earlier the same schedule entry had failed in the opposite direction, and nobody noticed for three months. On 29 March 2026 the clocks went forward at 01:00, so the local clock went straight from 00:59 to 02:00 and never showed 01:30 at all:

    firings on 2026-03-29: 0

No exception. No alert. The daily partition for that date is simply absent, and it was found in June by somebody building a chart that had a gap in it.

And the monitoring, which measured how long the job took by subtracting two readings of the local clock, reported this on the October night:

  a task ending after the clocks go back
    (starts 01:50 BST, ends 20 real minutes later at 01:10 GMT)
    real elapsed time                0:20:00
    local clock at start / end       01:50:00 / 01:10:00
    a naive local stopwatch reports  -1 day, 23:20:00
    wrong?                           True

Minus forty minutes, for twenty minutes of work. That figure went into the average.

Three failures, one cause, and it is the sentence this whole lesson exists to make true for you:

A timestamp without a time zone is not a time. It is a rumour.

Rumours are the right word because of how they fail. They are plausible, they are specific, they are confidently repeated, and they are correct most of the time — which is exactly what stops anybody checking them. 2026-10-25 01:30:00 looks like a fact. It is a fragment of one.

The cost lands in four places, and each one is worth naming precisely because you will meet all four.

Money moves twice, or not at all. The October failure charged real cards. The March failure skipped a day of reconciliation. Neither raised anything, so neither was caught by the tests, the type checker, the linter or the monitoring — all of which were passing that night.

Wrong numbers look right. A duration of minus forty minutes is at least obviously nonsense. A duration of eighty-five minutes for a twenty-five minute job is not: it goes into a dashboard, drags an average, and eventually somebody is paged for a latency regression that never happened.

Some data is destroyed rather than merely misfiled. When two instants an hour apart are written down as the same local string, no later query and no cleverer comparator can separate them. The information is gone at the moment of writing. This is the failure mode that cannot be fixed after the fact, and it is why the storage decision matters more than the display decision.

And the failure schedule is the worst one available. These bugs are correct on 363 days a year. Your test suite passes. Your staging environment passes. Your load test passes. The bug is scheduled, published years in advance, and arrives at 02:00 on a Sunday.

The AI connection is not a stretch, and it is the reason this day sits in the middle of a data-engineering week rather than in a footnote. Every training set has a time axis, and a leaky one silently invalidates the model built on it. Splitting train from validation by time, computing a feature “as of” a moment, joining events from two systems whose clocks disagree — all three are this problem wearing different clothes. A model evaluated on data that leaked backwards through a time-zone bug looks excellent and is worthless, and the gap between the two is discovered in production by your users. We will come back to this properly at the end, with the specific mechanism.

By the end of today you will have reproduced every one of those failures on your own machine with real output, and then written the thirty lines that zoneinfo runs when it answers the question — so it stops being magic and becomes something you have already built.

The idea in plain language

Here is the whole subject in one picture, and then we will spend the rest of the day earning it.

Diagram: one instant drawn in five representations at once — epoch seconds, UTC ISO 8601 text, an aware datetime in Europe/London, an aware datetime in America/New_York, and a naive datetime whose offset is a question mark — with a table of what each can and cannot answer and a warning that two instants an hour apart render as the same London wall clock

There are exactly two kinds of thing in this material, and almost every bug is a confusion between them.

An instant is a moment in the history of the universe. It happened once, everywhere, at the same time. The Apollo 11 landing was an instant. The moment your card was charged was an instant. Instants can be ordered, subtracted, and compared, and everybody everywhere agrees about the answer.

A wall-clock reading is what a particular clock in a particular place showed. 01:30 is a wall-clock reading. So is 2026-10-25 01:30:00, and so is Tuesday morning. A wall-clock reading is a label, and labels are assigned by governments, which change their minds.

The confusion is easy to make because the two look identical when written down, and one of them contains the other. An aware datetime is a wall-clock reading plus the offset that was in force, and that combination names an instant. A naive datetime is a wall-clock reading with the offset missing — and it names an instant the way “the third house” names an address.

The analogy: the tape measure and the labels

I am going to use one image for the whole day, because it maps cleanly onto every single case and it does not break.

Real time is a tape measure being pulled out at a constant rate. It never stops, never reverses, and never repeats a position. Every instant is a position on that tape.

A local clock is a set of labels stuck onto the tape. In London the labels read 01:00, 01:30, 02:00 and so on, and normally the labelling is regular and boring.

Twice a year, somebody re-labels a stretch of tape while it is still moving.

Now every part of the day drops straight into that picture:

The thingIn the analogy
An instanta position on the tape
A naive datetimea label with no tape attached
An aware datetimea label plus the note saying which stretch it was on
A UTC timestampthe tape’s own markings, which nobody re-labels
An offsethow far this label is from the tape’s own marking
A time zoneone region’s whole history of re-labelling decisions
The IANA databasethe book recording every re-labelling anybody has ever done
foldwhich of two identical labels you meant
time.monotonic()a stopwatch that measures tape, ignoring labels entirely
ISO 8601 in UTCwriting tape positions in a fixed-width way, so they sort

The reason the analogy is worth carrying is that it makes the right answer obvious in each case. If you want to know how far apart two events were, measure the tape. If you want to know what somebody’s clock said, read the label. If you write down only the label, you have not recorded where on the tape you were — and on two nights a year, that is unrecoverable.

The rule that follows

Store instants. Render labels.

Store UTC, in a format that sorts. Convert to local time at the very edge of the system, on the way out to a human being, and nowhere else. If you will later need to know what somebody’s own clock said — for a meeting invitation, a recurring alarm, an opening hour — store the zone name alongside, never the offset, because an offset is a property of a zone at a moment and will be wrong six months later.

That is the whole recommendation. The rest of the lesson is the argument for it, because a rule you have merely been told is a rule you will abandon under deadline pressure, and a rule you have watched fail three times is one you keep.

Historical background

The problem is older than computing, and knowing where the pieces came from explains why the API looks the way it does.

Local time used to be genuinely local. Before the railways, a town set its clocks by the sun, and noon in one town was a few minutes off noon in the next. That was harmless until two trains could be in the same place at the same time. Standardised time zones exist because a timetable needs one clock, and the fact that they were invented for scheduling rather than for astronomy is a useful thing to remember: they are an administrative convenience, imposed by decision, and they change by decision.

Daylight saving was a policy choice and remains one. It is not a natural phenomenon and it is not uniform: the dates differ by country, the size of the shift is usually an hour but not always, and whether a country observes it at all changes over time. There is no algorithm. There is only a record of what each government decided, and a note of what each has announced for next year.

That record is the IANA Time Zone Database. It is public-domain, maintained collaboratively, and released several times a year under names such as 2026c — which is exactly what was installed on the machine this lesson was written on:

IANA database version: 2026c  (from /usr/share/zoneinfo/+VERSION)

The IANA page describes it as containing “code and data that represent the history of local time for many representative locations worldwide”, updated “to reflect changes made by political bodies to time zone boundaries, UTC offsets, and daylight-saving rules”. The word doing the work in that sentence is history. The database is not a formula; it is a record, and it grows every time a legislature acts.

ISO 8601 was first published in 1988, with revisions in 1991, 2000, 2004 and 2019 and an amendment in 2022. Its central design decision is stated plainly in the standard: the fields are arranged so that “the greatest temporal term (typically a year) is placed at the left and each successively lesser term is placed to the right”, with the consequence that “the lexicographical order of the representation thus corresponds to chronological order”. That property is the reason Day 91 could store timestamps as text in a database with no date type and still write ORDER BY, and we will prove it and then break it later today.

RFC 3339 arrived in July 2002, written by G. Klyne and C. Newman, and it is deliberately much smaller. It describes itself as “a profile of the ISO 8601 standard”, narrowing the sprawling original to what internet protocols actually need: a date, a separator, a time, and — this is the important part — a mandatory offset to UTC. Z is defined there as “a suffix which, when applied to a time, denotes a UTC offset of 00:00”.

In Python, the pieces arrived over two decades. datetime has been in the standard library since Python 2.3. The fold attribute, which is the single bit that distinguishes the two instants sharing one wall-clock label, arrived in Python 3.6 through PEP 495. And zoneinfo — the module that reads the IANA database directly — arrived only in Python 3.9, through PEP 615, written by Paul Ganssle and created in February 2020.

That last date is worth pausing on, because it explains a decade of Python code you will meet. Until 3.9 the standard library could tell you about UTC and about fixed offsets, and it could not tell you when the clocks change in Berlin. Everybody used the third-party pytz, whose unusual localize() API exists because it predates fold and had to solve the ambiguous hour without any help from the language. We will come back to that in the alternatives, because understanding why pytz is shaped the way it is makes it obvious why you should no longer use it.

PEP 615 also made a decision that is easy to miss and turns out to be the most operationally important fact in the whole module: zoneinfo ships no data. It reads whatever the operating system has. The PEP is explicit that the tzdata PyPI package is “unnecessary (and may be undesirable) on systems that deploy time zone data”, and that bundling would complicate standard-library updates. The consequence is a live dependency between your Python program and your operating system’s patching schedule, and we are going to look straight at it.

What it is — and what it is not

Let us be precise about the objects, because the whole subject collapses into confusion without it.

A naive datetime

The documentation gives the test exactly. A datetime object d is aware if and only if d.tzinfo is not None and d.tzinfo.utcoffset(d) does not return None. Anything else is naive.

A naive datetime holds seven numbers — year, month, day, hour, minute, second, microsecond — and nothing else. It is a label with no tape.

The obvious question is why the standard library lets you build one at all, given the trouble they cause. The answer is that a great many times genuinely have no zone, and forbidding naive datetimes would make them unrepresentable:

Those are legitimate uses, and a naive datetime (or date, or time) is the correct type for them. The failure is not naive datetimes existing. The failure is a naive datetime crossing a boundary — being stored, compared with an aware one, converted, or transmitted — as though it named an instant.

Python catches exactly one of those for you, and it is a gift:

>>> from datetime import datetime, timezone
>>> datetime(2026, 8, 16, 12, 0) - datetime(2026, 8, 16, 11, 0, tzinfo=timezone.utc)
TypeError: can't subtract offset-naive and offset-aware datetimes

That is the only place the language stops you. Everywhere else — sorting a mixed list, writing one to a database column, comparing to a string — the naive value passes silently.

An aware datetime

An aware datetime is a wall-clock reading plus a tzinfo that can supply an offset. Two flavours matter:

from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo

fixed = datetime(2026, 10, 25, 1, 30, tzinfo=timezone.utc)          # a fixed offset
zoned = datetime(2026, 10, 25, 1, 30, tzinfo=ZoneInfo("Europe/London"))  # a real zone

timezone.utc and timezone(timedelta(hours=1)) are fixed offsets: they answer “what is the offset?” with the same number forever. ZoneInfo("Europe/London") is a zone: it answers by looking the instant up in a database of rules, so it gives +00:00 in January and +01:00 in July.

That distinction is not pedantry. Storing “the offset was +01:00” is not the same as storing “this happened in London”, and the difference shows up the first time you need to compute a future occurrence.

What UTC is not

UTC is not a time zone in the interesting sense. It has no daylight saving, no politics and no history of change. It is the reference every offset is stated against, and it is what makes instants comparable.

It is also not datetime.utcnow(). That function returns a naive datetime holding UTC field values — the worst of both worlds, because it looks exactly like a local time and compares wrongly against one with no error raised. It has been deprecated since Python 3.12. Write this instead:

datetime.now(timezone.utc)   # aware, correct, and impossible to misread

What a time zone is not

A time zone is not an offset, and it is not an abbreviation.

Europe/London is a name for a region’s entire history of decisions. +01:00 is one answer that name gives, at one moment. And BST, IST, CST are display labels, not identifiers: IST is India, Ireland and Israel; CST is used by at least three different places. There is no lookup from abbreviation to zone, because the mapping is not a function. This is why strptime’s %Z directive is nearly useless, as we will see.

What zoneinfo is not

It is not a database. It is a reader. The data lives on your disk, zoneinfo.TZPATH says where it looks, and if nothing on that path provides the zone you asked for you get ZoneInfoNotFoundError, which is a subclass of KeyError.

Here is that search path on the machine this lesson was written on:

zoneinfo.TZPATH — searched in this order, first match wins:
  present  /usr/share/zoneinfo
  absent   /usr/lib/zoneinfo
  absent   /usr/share/lib/zoneinfo
  absent   /etc/zoneinfo

IANA database version: 2026c  (from /usr/share/zoneinfo/+VERSION)

zones available here: 598

598 zones, from files. Not one of those rules is in Python.

Why it was created and what problems it solves

Take the pieces in the order they solve problems, because each one exists because the previous one was not enough.

datetime exists because dates and times are not numbers. Months have different lengths, years have different lengths, and the sequence of days is not arithmetic. A type that knows 31 January plus one day is 1 February saves you from writing that logic and getting it wrong.

Offsets exist because a wall-clock reading is not portable. Two colleagues writing 09:00 in a shared document mean two different instants, and nothing in the string says so.

zoneinfo and the IANA database exist because offsets are not stable. If you store +01:00 for London you have recorded something true in July and false in January, and the only way to know which applies is to know the rules — which are not derivable, because they are decisions.

fold exists because even a zone plus a wall-clock reading is not always enough. That is the sharp end of the whole subject, and it deserves the space.

The thing that is genuinely hard

Converting an instant to a local time is easy: one instant, one answer, always. Look up the offset in force at that instant, add it, done.

Converting a local time to an instant is not, because the mapping is not one-to-one. On most days it is. Twice a year it is not:

There is no clever algorithm that avoids this, because the ambiguity is in the world rather than in the code. The information was destroyed when somebody wrote down the label without the tape position. fold is Python’s answer: one extra bit on the datetime object saying “I meant the first one” or “I meant the second one”.

The documentation is exact about what it means: fold=0 represents the earlier of the two moments with the same wall time, and fold=1 the later. PEP 495 also defines what it does in the gap, where there is no moment at all: fold=0 uses the offset in force before the gap, and fold=1 the offset after it.

Here is all of that, measured:

====================================================================
AMBIGUOUS — the same wall clock, two different instants
====================================================================
wall reading: 2026-10-25T01:30:00  zone: Europe/London

  fold=0
    local     2026-10-25T01:30:00+01:00
    offset    +01:00  (BST)
    the UTC instant it means  2026-10-25T00:30:00+00:00
    epoch seconds             1792888200
  fold=1
    local     2026-10-25T01:30:00+00:00
    offset    +00:00  (GMT)
    the UTC instant it means  2026-10-25T01:30:00+00:00
    epoch seconds             1792891800

  They are 1:00:00 apart. Same string, same zone, different moments.

Two epoch numbers, 3600 apart. That is the entire problem, in four lines, and every failure in the opening section follows from it.

time.monotonic() exists for a different reason, and it is the last piece. Even a perfectly correct UTC clock is adjustable: NTP nudges it, an administrator can set it, a laptop waking from sleep re-syncs it. If you measure a duration by subtracting two readings of an adjustable clock, an adjustment between the readings corrupts the measurement. A monotonic clock is one that only ever moves forward and is never adjusted. It cannot tell you the date — that is not a limitation, it is the whole design.

How it works

Now the mechanism, in the order the machine executes it.

Where the data comes from

A compiled zone file is, in essence, a sorted list of UTC instants at which the offset changes, plus the offset (and abbreviation) in force after each one, plus a rule string for extrapolating past the last stored transition into the future.

You do not have to take that on faith. You can discover a zone’s rules from the outside, by asking the database for the offset at two instants and bisecting when they differ:

def find_transition(zone, low, high):
    """Bisect for the instant in (low, high] where the offset changes."""
    before = low.astimezone(zone).utcoffset()
    if high.astimezone(zone).utcoffset() == before:
        return None
    while high - low > timedelta(seconds=1):
        middle = low + (high - low) / 2
        if middle.astimezone(zone).utcoffset() == before:
            low = middle
        else:
            high = middle
    return high.replace(microsecond=0)

Run that over London in the late 1960s and the database tells you something genuinely surprising:

A zone is not a constant. Europe/London at noon on 1 January:
  1967: offset +00:00  name GMT   daylight saving 0:00:00
  1969: offset +01:00  name BST   daylight saving 0:00:00
  1970: offset +01:00  name BST   daylight saving 0:00:00
  1971: offset +01:00  name BST   daylight saving 0:00:00
  1972: offset +00:00  name GMT   daylight saving 0:00:00
  2026: offset +00:00  name GMT   daylight saving 0:00:00
  For three winters London sat at +01:00 with no daylight saving in
  force: Britain ran an experiment with year-round summer time.
  Bisecting the database for the two boundary instants:
    clocks went forward  1968-02-18T02:00:00+00:00
    and did not go back until  1971-10-31T02:00:00+00:00
  Three years and eight months between one spring forward and the
  next autumn back. No code models that; a file records it.

Read those two instants again. Three years and eight months between one spring forward and the next autumn back. No formula produces that. It is in a file because somebody recorded what Parliament decided, and it is on your disk right now.

Notice also the daylight saving 0:00:00 on the 1969–1971 rows next to an offset of +01:00. The database is recording that those years were standard time at +01:00, not summer time — a distinction that matters if you have ever written if is_dst: anywhere.

Instant to local: the easy direction

Given an instant, find the last transition at or before it and adopt its offset:

def offset_at_instant(instant, rules):
    offset = rules.base_offset
    for transition in rules.transitions:
        if instant >= transition.instant:
            offset = transition.offset
        else:
            break
    return offset

One instant, one answer. Always. This is the argument for storing instants in a single sentence: the direction that has a unique answer is the direction you want to be doing at query time.

Local to instant: the direction that has 0, 1 or 2 answers

Chop the timeline into segments — stretches of UTC during which the offset does not change, bounded by the transitions. For London in 2026 there are three: before 29 March at +00:00, between the two transitions at +01:00, and after 25 October at +00:00.

Now, for each segment: assume its offset applies, compute the UTC instant that would produce the wall reading you were given, and keep the candidate only if that instant actually falls inside that segment.

That last clause is the whole trick. A candidate landing outside its own segment is a self-contradiction — it says “the offset was +01:00 at a moment when the offset was not +01:00” — and is discarded. What survives is one, two, or zero answers:

Here is the implementation, which is the from-scratch build of the day:

def candidates(wall, rules):
    """Every UTC instant whose local reading in this zone is `wall`."""
    found = []
    for start, end, offset, name in segments(rules):
        instant = wall.replace(tzinfo=UTC) - offset
        if (start is None or instant >= start) and (end is None or instant < end):
            found.append((instant, offset, name))
    return found

And the classification on top of it:

def resolve(wall, rules, fold=0):
    found = candidates(wall, rules)
    if len(found) == 1:
        return found[0][1], "normal"
    if len(found) == 2:
        return found[fold][1], "ambiguous"     # segments are in time order
    # zero candidates: the reading is in a gap. fold=0 takes the offset
    # before the gap, fold=1 the offset after it.
    ...

Roughly thirty lines, over a three-line rule table. Then check it against the real database on thirteen wall readings and both folds:

rule table: base +00:00 GMT
            at 2026-03-29T01:00:00+00:00 -> +01:00 BST
            at 2026-10-25T01:00:00+00:00 -> +00:00 GMT

wall clock           fold  mine     zoneinfo  kind         agree
----------------------------------------------------------------
2026-03-29T00:59:00  0     +00:00   +00:00    normal       yes
2026-03-29T01:00:00  0     +00:00   +00:00    nonexistent  yes
2026-03-29T01:00:00  1     +01:00   +01:00    nonexistent  yes
2026-03-29T01:30:00  0     +00:00   +00:00    nonexistent  yes
2026-03-29T01:30:00  1     +01:00   +01:00    nonexistent  yes
2026-03-29T02:00:00  0     +01:00   +01:00    normal       yes
2026-10-25T01:00:00  0     +01:00   +01:00    ambiguous    yes
2026-10-25T01:00:00  1     +00:00   +00:00    ambiguous    yes
2026-10-25T01:30:00  0     +01:00   +01:00    ambiguous    yes
2026-10-25T01:30:00  1     +00:00   +00:00    ambiguous    yes
2026-10-25T02:00:00  0     +00:00   +00:00    normal       yes

cases: 13 wall readings x 2 folds = 26 comparisons
disagreements with zoneinfo: 0
cases classified wrongly:    0

Twenty-six comparisons, zero disagreements. (The table above is abridged; the lab prints all twenty-six rows.)

What does the real zoneinfo add? Every zone in the database rather than one, every recorded change each has ever made rather than the two in 2026, a compiled binary file and a cache of loaded zones rather than a table typed by hand and scanned from the top, the rule string that extrapolates into the future, and correct handling of the historical oddities. The algorithm, though, is the one above. That is the whole trick, and you have now written it.

The consequence, drawn out

Animated diagram: the autumn-back transition of 25 October 2026 in Europe/London hour by hour, with a UTC timeline above and the local wall clock below reading 01:00, 01:30, 01:00, 01:30, 02:00 — the two 01:30 readings marked fold zero and fold one, their epoch seconds shown 3600 apart, and a job scheduled at 01:30 local firing twice from one schedule entry

The top row is the tape: UTC, running left to right, never repeating. The row below is the labels, and it reads 01:00, 01:30, 01:00, 01:30, 02:00. The wall clock went backwards, and nothing anywhere raised an error. The row below that shows the same two 01:30 readings as epoch seconds, where the difference the label hides becomes a plain 3600. And the bottom row is the schedule entry firing twice.

An everyday analogy

Let us push the tape-and-labels image a little harder, because it earns its keep on the cases people find counter-intuitive.

Why is a naive datetime allowed to exist? Because a label detached from the tape is sometimes exactly what you mean. “Take this pill at 08:00” is a label. It is not a position on the tape, and forcing it to be one would make it wrong the moment the patient flies to another country. The type is correct; the mistake is gluing it to the tape later while pretending you knew where it went.

Why does adding a timedelta behave the way it does? Because datetime + timedelta moves along the labels, not along the tape. Adding two hours to 00:30 on the October night gives you the label 02:30 — which, that night, is three hours of tape away:

  start                      2026-10-25T00:30:00+01:00
  + timedelta(hours=2)       2026-10-25T02:30:00+00:00
  + two hours of real time   2026-10-25T01:30:00+00:00
  a whole hour apart: 1:00:00

Both answers are correct; they answer different questions. “The meeting is at 09:00 next Tuesday” is label arithmetic and you want the first. “The token expires in one hour” is tape arithmetic and you want the second — convert to UTC, add, convert back.

Why does == say two instants an hour apart are equal? Because when both operands carry the same tzinfo, the documentation says the tzinfo and fold attributes are ignored and the base datetimes are compared — that is, it compares the labels. Two labels reading 01:30 are the same label, and they are. The tape positions are not:

    first == second            -> True
    same instant?              -> False
    first.timestamp() equal?   -> False

This is documented, deliberate, and the reason for a rule worth memorising: convert to UTC before you compare, sort, deduplicate, or use a datetime as a dictionary key.

Why is a future local time a prediction rather than a fact? Because the labelling for next March has not happened yet. It has been announced, which is why the database has an entry, but an announcement is a statement of intent by a government that is entirely free to change it — and several do, most years, sometimes with a few weeks’ notice. Your image’s copy of the label book is as current as your last rebuild.

And why is a monotonic clock a different instrument entirely? Because it measures tape and ignores labels. It cannot tell you the date, in the same way a tape measure cannot tell you which house you are standing outside. That is not a gap in its capability; it is what makes it trustworthy for the one job it has.

The analogy has one honest limit worth naming. The tape is not perfectly uniform: UTC itself occasionally has a second inserted to keep it in step with the Earth’s rotation, and Python’s datetime does not model that at all. We will get to leap seconds shortly.

Examples in practice

Everything in this section is captured from a real run. Nothing is illustrative.

How long is a day?

Not 24 hours, twice a year. And the way you measure decides whether you can see it:

zone                 local date         real       wall  what happened
----------------------------------------------------------------------
Europe/London        2026-03-29        23.0h      24.0h  spring forward
Europe/London        2026-10-25        25.0h      24.0h  autumn back
Europe/London        2026-06-15        24.0h      24.0h  an ordinary day
America/New_York     2026-03-08        23.0h      24.0h  spring forward
America/New_York     2026-11-01        25.0h      24.0h  autumn back
Australia/Lord_Howe  2026-10-04        23.5h      24.0h  forward by half an hour
Australia/Lord_Howe  2026-04-05        24.5h      24.0h  back by half an hour

The wall column reads 24.0 on every row, including the two that are not. That column is what you get from subtracting two local midnights, and it is why a report that measures a day that way is wrong twice a year and right the rest of the time.

The correct measurement converts both midnights to UTC first:

start = datetime.combine(day, time(0, 0), tzinfo=zone)
end   = datetime.combine(day + timedelta(days=1), time(0, 0), tzinfo=zone)
elapsed = end.astimezone(UTC) - start.astimezone(UTC)

Note the last two rows. Lord Howe Island moves by thirty minutes, so its short day is 23.5 hours and its long day 24.5. Any code that special-cases “plus or minus exactly one hour” is already wrong there, and there are other such zones historically. Do not encode the size of the shift; ask the database.

The hour that never happened

wall reading: 2026-03-29T01:30:00  zone: Europe/London

  fold=0
    local     2026-03-29T01:30:00+00:00  offset +00:00
    as UTC    2026-03-29T01:30:00+00:00
    back to London  2026-03-29T02:30:00+01:00  <- NOT what you started with
  fold=1
    local     2026-03-29T01:30:00+01:00  offset +01:00
    as UTC    2026-03-29T00:30:00+00:00
    back to London  2026-03-29T00:30:00+00:00  <- NOT what you started with

Python constructed both objects without complaining. It has to: a naive wall reading plus a zone is a request, and refusing every request that cannot be honoured would break the perfectly reasonable case of parsing user input you then want to validate. The failure shows in the round trip. Out to UTC and back is the standard detection idiom, and it is two lines:

aware = wall.replace(tzinfo=zone)
nonexistent = aware.astimezone(UTC).astimezone(zone).replace(tzinfo=None) != wall

There is a neat detail here that the lab makes you find. Both cases — ambiguous and nonexistent — give different instants at fold=0 and fold=1, so testing != cannot tell them apart. The order can:

Casefold=0 givesfold=1 givesTest
Ambiguousthe earlier instantthe later instantfirst < second
Nonexistentthe later instantthe earlier instantfirst > second
Ordinarythe same instantthe same instantfirst == second

One comparison classifies all three.

Sorting: the argument for UTC, made rather than asserted

Four events, each a real instant, each recorded by an office in a different country. Store them as UTC ISO text and sort the strings — no parsing, no key function:

  2026-08-16T11:30:00Z  ordered
  2026-08-16T15:00:00Z  packed
  2026-08-16T16:00:00Z  dispatch
  2026-08-16T18:00:00Z  checkout

  sorted as text     == sorted as instants : True

Now store the same four events as each office’s local clock:

  2026-08-16T14:00:00  checkout
  2026-08-16T17:00:00  dispatch
  2026-08-16T17:00:00  ordered
  2026-08-16T20:30:00  packed

  sorted as text     : ['checkout', 'dispatch', 'ordered', 'packed']
  sorted as instants : ['ordered', 'packed', 'dispatch', 'checkout']
  same order?        : False

The exact reverse. Every one of those strings is well-formed ISO 8601 and every one of them is true; they are simply measured against different rulers.

And here is the case that catches careful people, because it looks like it should be fine. Attach the offset to each local string, so nothing is lost and any parser can recover every instant exactly:

  2026-08-16T14:00:00-04:00  checkout
  2026-08-16T17:00:00+01:00  dispatch
  2026-08-16T17:00:00+05:30  ordered
  2026-08-16T20:30:00+05:30  packed

  sorted as text     : ['checkout', 'dispatch', 'ordered', 'packed']
  sorted as instants : ['ordered', 'packed', 'dispatch', 'checkout']
  same order?        : False

Still wrong. The comparison walks the characters left to right and reaches a decision long before it gets to the offset on the end. Losslessness and sortability are different properties. UTC ISO text has both, which is the entire reason it is the storage format — for a SQLite column, for a key in a key-value store, for a filename in a partitioned bucket, for a log line you will one day sort from the shell.

The third failure is the one you cannot recover from at all:

  2026-10-25T00:30:00Z  ->  London local  2026-10-25T01:30:00
  2026-10-25T01:30:00Z  ->  London local  2026-10-25T01:30:00

  identical local strings? True

Two instants an hour apart, one string. No sort can order them and no query can separate them, because the information is gone at the moment of writing.

ISO 8601, RFC 3339, and what Python parses

One instant, several legal renderings:

    isoformat()              2026-10-25T01:30:00+00:00
    strftime Z form          2026-10-25T01:30:00Z
    basic ISO 8601 form      20261025T013000Z
    ordinal date (ISO 8601)  2026-298T01:30:00Z
    ISO week date            datetime.IsoCalendarDate(year=2026, week=43, weekday=7)

All of those are ISO 8601. Only the first two are RFC 3339. That gap is why “we use ISO 8601” is a much weaker statement than people intend: the standard also permits the basic form with no separators, week dates, ordinal dates, durations and intervals.

What Python actually accepts, on 3.14.0:

    fromisoformat('2026-10-25T01:30:00+00:00'     ) -> 2026-10-25T01:30:00+00:00
    fromisoformat('2026-10-25T01:30:00Z'          ) -> 2026-10-25T01:30:00+00:00
    fromisoformat('2026-10-25 01:30:00Z'          ) -> 2026-10-25T01:30:00+00:00
    fromisoformat('20261025T013000Z'              ) -> 2026-10-25T01:30:00+00:00
    fromisoformat('2026-W43-7'                    ) -> 2026-10-25T00:00:00
    fromisoformat('2026-10-25T01:30:00+0100'      ) -> 2026-10-25T01:30:00+01:00
    fromisoformat('Sun, 25 Oct 2026 01:30:00 GMT' ) -> ValueError: ...

Two things to take from that. fromisoformat was strict before Python 3.11 — the trailing Z raised ValueError, which produced years of text.replace("Z", "+00:00") in real codebases — and from 3.11 it accepts most of ISO 8601. And look at the week-date line: it parses, and it silently becomes a naive midnight. Permissive parsing has a cost, and the cost is that malformed input sometimes succeeds.

The last line is RFC 2822, the email date format, which fromisoformat correctly refuses; email.utils.parsedate_to_datetime is the standard-library function for that one.

The two strptime traps

  Trap 1 — %Z parses almost nothing, and throws the zone away:
    UTC  -> datetime.datetime(2026, 10, 25, 1, 30)
         tzinfo is None — the zone name is gone
    GMT  -> datetime.datetime(2026, 10, 25, 1, 30)
         tzinfo is None — the zone name is gone
    BST  -> ValueError: does not match the format
    EST  -> ValueError: does not match the format

%Z accepted only UTC and GMT here, and even then produced a naive datetime — the zone was parsed and discarded. That is not really a Python defect: an abbreviation cannot identify a zone, because the mapping is not a function.

  Trap 2 — the order of the numbers is a cultural convention:
    05/03/2026 parsed as %d/%m/%Y -> 2026-03-05
    05/03/2026 parsed as %m/%d/%Y -> 2026-05-03

Both parse. Both succeed. They are two months apart and no error is raised in either direction. This is precisely the failure ISO 8601 was written to end, and it is why the format matters more when the data crosses an organisational boundary than when it stays inside one.

Which clock, and what the wrong one does

  clock            monotonic  adjustable  implementation
  time             False      True        clock_gettime(CLOCK_REALTIME)
  monotonic        True       False       mach_absolute_time()
  perf_counter     True       False       mach_absolute_time()
  process_time     True       False       clock_gettime(CLOCK_PROCESS_CPUTIME_ID)

The library will tell you which clock is which, and the adjustable column is the hazard stated in one word. On an undisturbed machine the two agree closely — 0.042173 seconds against 0.042173 seconds in the run captured for this lesson — and that agreement is exactly what makes the bug invisible in testing.

The difference appears only when the clock moves:

  a task spanning the whole repeated hour
    (starts 01:00 BST, ends 01:15 GMT)
    real elapsed time                1:15:00
    a naive local stopwatch reports  0:15:00

  a task ending after the clocks go back
    (starts 01:50 BST, ends 20 real minutes later at 01:10 GMT)
    real elapsed time                0:20:00
    a naive local stopwatch reports  -1 day, 23:20:00

  spring forward in London: a task over the gap
    (starts 00:45 GMT, ends 25 real minutes later)
    real elapsed time                0:25:00
    a naive local stopwatch reports  1:25:00

Three failures, three shapes. Under-reporting by an hour turns a 75-minute job into a logged 15. A negative duration makes while elapsed < timeout never terminate. Over-reporting by an hour pages somebody for a healthy job. All three are one line of time.monotonic() away from correct.

The rule, in six words: wall clock for when, monotonic for how long.

QuestionUseWhy
How long did this take?time.monotonic()never adjusted
Has the timeout expired?time.monotonic()never goes backwards
How fast is this function?time.perf_counter()highest resolution
How much CPU did it use?time.process_time()excludes time asleep
When did this happen?datetime.now(timezone.utc)a calendar instant
What should the log line say?datetime.now(timezone.utc)comparable across hosts

Durations, months, and leap seconds

timedelta stores days, seconds and microseconds only. It has no months or years field, deliberately, because those have no fixed length:

  start                       2026-01-31
  + timedelta(days=30)        2026-03-02
  + timedelta(days=31)        2026-03-03

Neither is “one month later”. 31 January + 1 month has at least three defensible answers — 28 February if you clamp to the month end, 3 March if you overflow, or an error because the caller has not said which they meant — and the standard library gives you calendar.monthrange to inform the decision while pointedly making none of them for you. Choose, write it down, and put it in a named function. Billing systems and subscription renewals live or die on this choice and it is not the same for every business.

And leap seconds:

    datetime(2016, 12, 31, 23, 59, 60) -> ValueError: second must be in 0..59, not 60

A leap second is a real second inserted into UTC to keep it in step with the Earth’s rotation, which is neither constant nor predictable. During one, a UTC minute genuinely contains sixty-one seconds. Python’s datetime documentation is explicit that it models “an idealized time… assuming that every day has exactly 24*60*60 seconds. (There is no notion of ‘leap seconds’ here.)” So an epoch count silently repeats or stretches a second when one occurs, and many large operators now smear the extra second across a whole day so that no clock ever shows :60.

The practical position: unless you are doing metrology or reconciling trades to the microsecond, treat “a day has 86400 seconds” as true, know that it is an approximation, and use a monotonic clock for any duration you actually care about.

Epoch seconds and their two ceilings

    epoch seconds        1792891800
    the epoch itself     1970-01-01T00:00:00+00:00
    signed 32-bit limit  2038-01-19T03:14:07+00:00

An epoch count is unambiguous by construction: no zone, no offset, no wall clock anywhere in it. It is also unreadable by construction, which is a genuine cost — nobody notices that 1792891800 is a month wrong while scanning a log, and everybody notices that 2026-11-25T01:30:00Z is.

Two ceilings are worth carrying. The famous one is above: a signed 32-bit count of seconds runs out in January 2038, and code storing seconds in an int32 still exists in plenty of places. The quieter one is that Python’s timestamp() returns a float, with 53 bits of mantissa:

    2026-08-16T09:00:00.123456+00:00
      -> 1786870800.123456
      -> 2026-08-16T09:00:00.123456+00:00
    smallest representable step near now: 3.975e-07 s

Microseconds survive the round trip. Nanoseconds do not — which is exactly why time.time_ns() exists and returns an integer. This is Day 70’s floating point arriving in a place you would not have looked for it.

Implications: security, privacy, performance, scalability, and cost

Security — expiry and validity windows. A token that expires “one hour from now”, checked against an adjustable wall clock, can be handed an extra hour by a clock adjustment; a session that should have ended can be extended by moving the clock. Certificate and signature validity windows are absolute instants, and comparing them against a naive local datetime makes the comparison wrong by the offset — in one direction that accepts something already expired. Lockout windows have the same shape: “three failures in five minutes” measured on a wall clock covers sixty-five real minutes on one night of the year. Durations belong on a monotonic clock; absolute expiry belongs on an aware UTC instant.

Security — parsing. fromisoformat and strptime raise ValueError on rubbish, which is correct and also means an unhandled one is a denial of service on a request handler. Catch it. And prefer the library parsers to a hand-rolled regular expression over date text, which is one of the more reliable ways to write a catastrophically backtracking pattern.

Privacy — timestamps are personal data more often than people expect. A precise timestamp plus a local zone is a location signal: storing Europe/London next to somebody’s activity says roughly where they are, and a change in that field says they moved. A sequence of timestamps is a behavioural profile — when somebody logs in, how long they stay, whether they work at 02:00 — and none of that is anybody’s name while all of it identifies a person quite well. Microsecond-precision timestamps are a strong fingerprint for correlating one person’s records across two systems that share nothing else.

The design conclusion is that precision is a decision made at collection time. If your feature needs “which day did this happen”, storing the second and the microsecond is data you have chosen to hold and must now protect, retain and delete correctly. Round at the point of collection; you cannot un-collect later. And storing UTC instants and rendering in the viewer’s zone at display time gives you the same product with none of the location inference stored.

Performance. ZoneInfo("Europe/London") is cached — construct it twice and you get the same object back — so the cost is a one-time file read per zone. available_timezones() is the opposite: the documentation warns it may open many files and is recomputed on every call, so it belongs in a startup check rather than in a loop. Conversions themselves are cheap: a lookup in a sorted table.

The performance question that actually bites is a data one. A UTC ISO text column supports an index and a range scan directly, because text order is chronological order. A local-time column does not: correct ordering requires converting every row, which means a full scan and no index. That is not a small constant factor — it is the difference between a range scan and a table scan on every time-filtered query you will ever write.

Scalability. UTC removes an entire class of cross-machine disagreement. Two servers in two regions writing UTC produce a stream that merges and sorts correctly with no coordination at all; two servers writing local time produce a stream that needs each row’s origin decoded before it can be ordered. This is why every distributed log format, every cloud provider’s audit trail, and every sensible partitioning scheme is in UTC.

Partitioning deserves its own note. If your warehouse partitions by date(local_timestamp), then on the October night one hour of rows collides with another hour and on the March night an hour is missing — and the partition boundary itself is a different instant in each region. Partition on UTC and carry local time as a derived column if a human needs it.

Cost. Storage: an epoch integer is 8 bytes, an ISO text timestamp is 20, and at a billion rows that difference is real but almost never the binding constraint. The dominant costs are elsewhere and are all engineering costs. A backfill to repair a leaked split. A duplicate-charge incident and the refunds, the support load and the trust. A month of a data scientist’s time chasing a model that was validated against leaked data. Set against those, the price of doing it right is that everybody on the team learns one rule and one library module.

The operational cost with a name. Your container images have an expiry date you did not set. A two-year-old image holds a two-year-old opinion about when the clocks change, and it will be confidently wrong for exactly the window a government moved. Add the zone database version to your build’s recorded metadata, the way you already record your Python version, and rebuild on a schedule rather than on incident.

Alternatives: free, open source, and commercial

Every option below is free and open source. There is no paid tier in this space at all, which is itself worth noticing: correct time handling is public infrastructure, funded by nobody and depended on by everybody.

Honesty note up front. Only zoneinfo, datetime, time and calendar — all standard library — are installed on the machine this lesson was written on, and only those produced the outputs quoted above. Everything else in this section is described from its documentation, and no output is reproduced for any of it. Where I state a version number or a behaviour I have not run, I say so.

zoneinfo — the standard library

Choose it for all new code on Python 3.9 or newer. It is the reference implementation of the model in this lesson, it has no dependencies, and it is the one everything else is now measured against.

from zoneinfo import ZoneInfo
from datetime import datetime
london = datetime(2026, 10, 25, 1, 30, tzinfo=ZoneInfo("Europe/London"), fold=1)

Free, PSF licence, nothing to install — except on Windows, where the IANA database does not exist and you add the first-party tzdata package from PyPI. That is not a workaround; PEP 615 designed it that way, so that systems which already ship the data are not made to carry a second copy.

Cost: it reads whatever data your system has, so keeping it current is your operations problem rather than your dependency manager’s.

pytz — the older approach, and why it looked like that

Choose it essentially never for new code. Understand it because you will meet it, and because why it is shaped the way it is explains the whole design of fold.

pytz predates PEP 495 by many years. Without a fold bit on the datetime object, there was nowhere to record which of two identical wall-clock readings you meant — so pytz put the information in the tzinfo object instead. Its zone objects are not a single zone; they are a factory producing one fixed-offset object per historical offset, and localize() is the call that picks the right one:

# pytz's shape — NOT run here, quoted from its documentation
import pytz
london = pytz.timezone("Europe/London")
aware = london.localize(datetime(2026, 10, 25, 1, 30), is_dst=True)

That is why passing a pytz zone directly as tzinfo= famously gives an offset like -00:01 or +00:53: you get the object for the region’s first recorded offset, which is often a pre-standardisation local mean time. It is not a bug so much as the visible edge of a design constraint that no longer exists.

Free, MIT licence. Its maintainer now recommends zoneinfo for new code, and the reason is exactly the above: the language grew the bit that made the workaround unnecessary.

dateutil — parsing, recurrence, and relative deltas

Choose it for three things the standard library genuinely does not do. First, dateutil.parser.parse will make a decent attempt at nearly any human-written date, which is invaluable for messy input and dangerous for anything else — it will guess, and a guess that succeeds is worse than an error. Second, relativedelta, which is the “add one month” policy the standard library refuses to choose, implemented and documented. Third, rrule, a full implementation of the iCalendar recurrence rules — “the last Thursday of every month” — which is a genuinely hard problem you should not solve yourself.

# NOT run here — quoted from the dateutil documentation
from dateutil.relativedelta import relativedelta
new = old + relativedelta(months=+1)

Free, Apache 2.0 / BSD dual licence. Cost: the permissive parser is a liability on untrusted input, and its own tz implementation is now largely superseded by zoneinfo.

arrow — one object, a friendlier surface

Choose it when a team keeps making the same mistakes and you want an API that makes the correct thing the default. arrow replaces datetime with a single always-aware type that defaults to UTC, and adds humanised output (“an hour ago”) and easy shifting and range generation.

Free, Apache 2.0. Cost: it is a parallel type, so it does not compose with libraries expecting datetime without conversion at every boundary, and “always aware, defaults to UTC” silently converts things you may have wanted to keep naive.

pendulum — a drop-in with stricter semantics

Choose it when you want arrow’s ergonomics with better standards behaviour and a type that subclasses datetime, so existing code keeps working. Its distinguishing feature is that it takes the ambiguous and nonexistent cases seriously and lets you choose the policy explicitly rather than defaulting silently.

Free, MIT licence. Cost: another dependency to keep current, on a subject where being out of date is the specific failure mode; and because it subclasses datetime, a pendulum object escaping into a library that does exact type checks can behave in ways neither party expected.

The storage question: three options, and it is a real trade-off

This is the decision that outlives every library choice, because it is baked into your data.

OptionSorts as textHuman-readableSpaceThe real cost
Epoch integerno (varying digit lengths)no8 bytesunreadable in logs and dumps; second- or millisecond-precision must be agreed and is often not
ISO 8601 UTC textyesyes~20 bytesslightly larger; needs a CHECK constraint or equivalent to stop malformed values
Native database typeyesyes8 bytesnot portable, and every engine has its own opinions about zones — PostgreSQL’s timestamptz stores an instant and converts on the way out, timestamp does not, and confusing the two is its own genre of bug

Choose the native type when your database has a good one and your data will not leave it. PostgreSQL’s timestamptz is a genuinely good design.

Choose ISO 8601 UTC text when there is no native type (SQLite, as in Day 91), when the data must be readable in a dump or a log, or when it crosses systems. You give up eight bytes a row and gain readability, portability and a format that sorts.

Choose epoch integers when space or arithmetic speed genuinely dominates, and accept that every dump, log and bug report will need a conversion before a human can read it. Then write the precision down: seconds and milliseconds look identical until they are three orders of magnitude apart.

There is a fourth option that is not a real option: storing local time. The lab demonstrates in three ways why it is not, and the third — two instants collapsing into one string — is unrecoverable.

ConceptWhat it isWhat it is notThe confusion it causes
Instantone moment, everywherenot a reading, not a label”the timestamp” said of a naive value
Wall-clock readingwhat one clock showednot a momenttwo offices’ 09:00 treated as equal
Offsetzone’s distance from UTC at a momentnot a property of the zonestoring +01:00 for London and being wrong in January
Time zonea region’s whole rule historynot an offset, not an abbreviationIST used as an identifier when it names three places
Abbreviation (BST, CST)a display labelnot unique, not parseable%Z appearing in a format string
UTCthe reference for offsetsnot a zone with rules; not utcnow()naive UTC values compared to local ones
GMTa zone name and a historical standardnot a synonym for UTC in every contextused interchangeably until a legal document disagrees
foldwhich of two identical readingsnot a daylight-saving flagassuming fold=1 means “DST”
dst()how much of the offset is daylight savingnot the offsetif dst(): used to decide the offset
timedeltadays, seconds, microsecondsnot months or yearstimedelta(days=30) called “a month”
Wall-clock arithmeticmoving along the labelsnot elapsed time”expires in an hour” done with + timedelta(hours=1)
time.time()the current instant, adjustablenot a stopwatchmeasuring durations with it
time.monotonic()a stopwatchnot a calendar; no meaning across processestrying to log it, or compare it between machines
Epoch secondsan instant as a countnot readable; not always secondsa milliseconds field parsed as seconds, giving 1970
ISO 8601a large standardnot a single format”it’s ISO 8601” said of a week date
RFC 3339a small profile with a mandatory offsetnot all of ISO 8601assuming any ISO 8601 string is a timestamp
Leap seconda real inserted second in UTCnot representable in datetimeexpecting 23:59:60 to parse

Two rows in that table are worth expanding, because they are subtle.

fold is not a daylight-saving flag. It says which of two identical wall-clock readings you meant, in terms of order — earlier or later. On the autumn transition the earlier one happens to be the daylight-saving one, which makes the misreading easy, but the concept is about ordering rather than about summer time. In the gap it means something different again: which side of the missing hour to take the offset from.

dst() is not the offset. utcoffset() gives the total distance from UTC; dst() gives how much of that is daylight saving. The 1969–1971 London rows above show why the distinction is real: offset +01:00, daylight saving 0:00:00, because that hour was standard time that year. Code branching on if dst(): to decide what the offset is will be wrong there and in every similar case.

When to use it — and when not to

Always store instants in UTC for: log lines, audit records, event streams, created_at and updated_at, metrics, message timestamps, anything ordered, anything joined across systems, anything that will be partitioned. This is the default and it should require an argument to depart from.

Store the zone name as well when you will later need the wall clock somebody actually saw:

For those, store the local time and the zone name — never the offset — and compute the next occurrence as a UTC instant after each firing, so that a database update or a rule change is picked up rather than baked in.

Store a naive date or time deliberately when there genuinely is no instant: a birthday, a public holiday, an alarm, a shop’s opening time. Give the column a name that says so, so nobody later “fixes” it by attaching a zone.

Use a monotonic clock for every duration, timeout, retry window and rate limit. No exceptions worth carving out.

Do not store local time as your primary representation. Do not store an offset where you mean a zone. Do not use an abbreviation as an identifier. Do not schedule a job at a local time near a transition — 01:00 to 03:00 local is a minefield in most of the world, and if the requirement really is local, make the job idempotent and give it a key so a second firing is a no-op. Do not measure elapsed time with time.time(). Do not put datetime.now() in a test that asserts anything about dates.

And one more, because it is the failure this lesson opened with: do not assume your tests cover this. They do not, unless they pin the instants. A suite that reads the clock passes on 363 days a year, and the two days it fails on are the two days production fails on.

Knowledge check

Work through the questions in the quiz sidecar. Before you do, try these three from memory, because they are the ones that separate having read this from having understood it:

  1. Two aware datetimes in the same zone compare equal, but their timestamp() values differ by 3600. What are they, and what rule does this teach about comparison?
  2. You must schedule “every Tuesday at 09:00 Paris time” so it survives the next transition and the next database update. What exactly do you store, and what do you recompute, and when?
  3. A colleague proposes storing local time with the offset attached, arguing that nothing is lost. They are right that nothing is lost. Why is it still the wrong storage format?

Hands-on exercise

The lab is labs/sections/programming-with-python/day-095-dates-times-and-time-zones/, and it is called The Hour That Happened Twice.

Start by proving the harness is green, then read the brief, then find out where you stand:

cd labs/sections/programming-with-python/day-095-dates-times-and-time-zones
bash tests/run_tests.sh
echo "exit code: $?"

# read starter/00_brief.md — it is the incident from the top of this lesson

bash starter/02_check.sh

Then work down the ten exercises in starter/01_timezones.py. They build in order: the zone count, the storage format, the day length, the two offsets of an ambiguous reading, the round-trip test for a nonexistent one, the fold-order test that separates the two cases, the text sort, the choice of clock, and finally the two directions of the resolver.

The last two are the day’s from-scratch build. You are handed a three-line rule table — a base offset and two transitions — and you write the lookup zoneinfo performs against the real database. When you are done, the checker runs your resolver against zoneinfo on thirteen wall readings and both values of fold, and every one of the twenty-six comparisons must agree.

Try each exercise before opening the matching example. This is a subject where nearly everybody has to be wrong once before the distinction lands, and reading a correct answer to a question you have not yet asked yourself teaches almost nothing.

Expected output

An untouched starter:

   1. not started   zone_count
   2. not started   to_utc_text
   ...
0 of 10 exercises complete.

with exit code 1. When you have finished, the same command reports:

10 of 10 exercises complete.

with exit code 0. The full suite ends with a line captured from a real run:

75 checks, 0 failure(s).

and exits 0. The measurements the lesson quotes are all reproducible from the examples: the 23-hour and 25-hour days from examples/02_odd_days.py, the two instants and the job firing twice from examples/03_fold.py, the three sorts from examples/04_sorting.py, the clock comparison from examples/05_clocks.py, and the twenty-six-way agreement from examples/06_resolver.py.

Validate your work

  1. bash tests/run_tests.sh ends with 75 checks, 0 failure(s). and exits 0.
  2. python3 -c "import zoneinfo; print(len(zoneinfo.available_timezones()))" prints a number over 100 — it printed 598 on the authoring machine, and a different number on yours is fine and is part of the lesson.
  3. Europe/London on 2026-03-29 measures 23.0 hours and on 2026-10-25 25.0 — and subtracting the two local midnights says 24.0 on both.
  4. 2026-10-25 01:30 in Europe/London gives +01:00 at fold=0 and +00:00 at fold=1, naming instants exactly one hour apart.
  5. Those two aware datetimes compare equal with ==, and their UTC conversions do not.
  6. 2026-03-29 01:30 does not survive a round trip through UTC: it returns as 02:30+01:00.
  7. A schedule matching local 01:30 fires twice on 25 October and zero times on 29 March.
  8. Four events sorted by UTC text come out chronological; by local text they come out in the exact reverse; and by local text with offsets they are still wrong.
  9. examples/06_resolver.py reports disagreements with zoneinfo: 0 across 26 comparisons.
  10. After the run, find . -type d -name __pycache__ finds nothing.

Troubleshooting

Common mistakes

Practice assignment

Take a real piece of code you have written — from Day 81’s scheduler, Day 90’s repository, or Day 91’s library report — and perform a time audit on it. Produce a short written deliverable, not just a patch.

  1. Inventory. List every place the code touches time. For each one, write down which of the two kinds it is: an instant or a wall-clock reading. Be strict; the ones you are unsure about are the interesting ones.
  2. Classify every duration. For each subtraction or elapsed-time measurement, say which clock it uses and which it should use. Convert every genuine duration to time.monotonic().
  3. Find the naive values. Grep for datetime.now(), utcnow(), date.today() and fromtimestamp( without a tz argument. For each hit, decide whether it should be aware, and if it should, fix it.
  4. Check the storage. For every persisted timestamp, state the format and whether text order equals chronological order for it. If not, write the migration you would need — you do not have to run it.
  5. Write the transition test. Pick one behaviour in the code and write a test that pins an instant on the wrong side of a real transition. Watch it fail. Fix the code. Watch it pass. This is the deliverable that proves the audit was real.
  6. Write a paragraph naming the one change that removed the most risk, and one place where you deliberately kept a naive datetime and why.

Then, separately, implement add_months(instant, n, policy) with at least two policies — clamp to the month end and overflow into the next month — using calendar.monthrange. Find a case where they differ by more than three days, and write a short argument for which one a subscription-billing system should use. There is a defensible answer and it is not the same for every business.

Extension challenge

Pick one. Each is genuinely harder than the lab, and each ends in something you could show somebody.

1. Extend the resolver into the past and find where it breaks. Add Europe/London’s transitions for 1968 to 1972 to your rule table, discovering the boundary instants by bisection rather than by looking them up. Re-run your resolver against zoneinfo for wall readings across those years. Then answer: how many segments does your table have now, does the classification still hold at the very first one, and what happens to a wall reading before your table’s base offset begins?

2. Build a transition explorer. Write a tool that takes a zone name and a year range and prints every transition in it — instant, offset before, offset after, and the size of the jump — discovered entirely by bisection against zoneinfo. Run it across all 598 zones for the current year and answer three questions with real numbers: how many zones change their clocks at all, what is the largest single jump you can find, and how many transitions are something other than exactly one hour.

3. Reproduce the incident end to end. Write a small scheduler that fires a callable when a local wall clock matches a target, driven by a simulated clock you advance yourself rather than by real time. Show it firing twice on 25 October 2026 and zero times on 29 March 2026 in Europe/London. Then fix it two ways — by scheduling in UTC, and by keeping local scheduling but making the job idempotent with a firing key — and write down which fix you would ship and what each one assumes about the job.

4. Audit a dataset for temporal leakage. Take any dataset with timestamps and at least two sources. Compute the distribution of “time between event and ingestion”, look for the negative tail, and find out what it means. Then build a train/validation split two ways — by local wall clock and by UTC instant — and measure exactly how many rows land on different sides of the boundary. That number is your leakage, and writing it down is the point.

5. Write the storage comparison for real. Take a million-row table of events and store the timestamps three ways: epoch integers, ISO 8601 UTC text, and your database’s native type. Measure the size on disk, the time for a range query with an index, and the time for the same query on a local-time column. Report what you measured on your machine, on your data, on one day — including anything that contradicted your expectations, because on this subject something usually does.

The AI thread

Every training set has a time axis, whether or not anybody chose it. The moment a row was created, the moment it was ingested, the moment a label was applied, the moment a feature was computed — those are four different instants, and a model is only honest if it was built from the ones that were actually available at prediction time.

This is where a time-zone bug stops being an inconvenience and becomes an epistemological problem. Consider the three places it enters.

The split. You divide train from validation at “midnight on 1 June”. If your timestamps are local and your data comes from three regions, that boundary is three different instants, hours apart. Events that happened after the cutoff in one region are filed as before it, and the model is trained on some of the future it is then asked to predict. Validation accuracy rises. Production accuracy does not. Nothing errors, and the gap between the two numbers is discovered by your users.

The features. “As of” is the hardest phrase in feature engineering. A feature computed as of a moment must use only what existed at that moment, and the join that computes it has to compare instants — not wall clocks, not local dates. A customer’s “number of orders in the last 30 days” computed with a local-date boundary against UTC-stored orders includes or excludes hours of activity depending on the region, systematically, in a way that correlates with geography and therefore with everything geography correlates with.

The joins. Two systems, two clocks, two conventions. One writes UTC, the other writes local time with no offset, and a join on “the nearest event within five minutes” quietly matches things an hour apart or fails to match things that are simultaneous. You will not see an error. You will see a feature with less signal than it should have, and you will conclude that the feature is weak.

The mechanism these three share is worth stating plainly, because it is what makes the failure so persistent: a temporal leak produces a better-looking model. Every other data bug you meet makes your metrics worse, and a metric getting worse is a thing people investigate. This one makes them better. It is rewarded by exactly the process meant to catch it, and it survives review because the reviewer is looking at a number that went up.

The defences are unglamorous and they work. Store instants in UTC. Cut at an instant, never at a local date. Record the ingestion time as well as the event time, and check the distribution of the difference — the negative tail is your leak, in rows you can count. Compute every feature strictly as of the cutoff instant, and test that by recomputing a sample at a later instant and asserting the values did not change. And pin every instant in every test, because a temporal test that reads the clock is testing the day you ran it.

There is a broader point underneath, and it is the reason this day sits where it does in the course. Day 91 argued that the questions you can ask cheaply later are fixed by the schema you choose now. Today is the sharper version of the same claim: the questions you can answer honestly later are fixed by whether you recorded an instant or a rumour. A model cannot recover a distinction the data destroyed. When two events an hour apart were written down as the same local string, no architecture, no amount of compute and no cleverer loss function will separate them again — and the model will confidently learn something from the merged row, because that is what models do with whatever they are given.

Quiz

Q1. Your service stores every event timestamp as ISO 8601 text in UTC — for example 2026-10-25T01:30:00Z — in a database with no date type, and orders reports with a plain text ORDER BY. Why does that give the right chronological order?

  1. Because the database recognises the format and silently parses it into a date before comparing
  2. Because ISO 8601 puts the fields in most-significant-first order at fixed widths and the offset is identical on every row, so comparing the characters left to right gives the same answer as comparing the instants
  3. Because UTC has no daylight saving, and any format at all sorts correctly once daylight saving is out of the picture
  4. Because text comparison in most databases falls back to numeric comparison when both operands look like numbers
Show answer

Answer: B. Because ISO 8601 puts the fields in most-significant-first order at fixed widths and the offset is identical on every row, so comparing the characters left to right gives the same answer as comparing the instants

Two properties do the work together, and both are required. The format is fixed-width and most-significant-first — year, then month, then day, then hour — so the first character where two strings differ is the most significant place where the instants differ, which is exactly what a chronological comparison needs. That is a deliberate design choice in the standard rather than a happy accident. And the offset is the same on every row, so no row needs adjusting before the comparison. Option 2 is the tempting near-miss and is false: the lab sorts the same four events as local text WITH their offsets attached, which is unambiguous, lossless, daylight-saving-free per row, and still comes out in the wrong order — because the sort compares the digits left to right and never reaches the offset on the end. Losslessness and sortability are different properties, and UTC ISO text is the format that has both. Option 0 is wrong: a text column is compared as text. Option 3 describes a coercion that does not apply here and would not help if it did.

Q2. A job takes just over an hour, starting at 01:50 BST on 25 October 2026 in London and ending twenty real minutes later. Your monitoring measures duration by subtracting two readings of the local clock. What does it report, and which clock should it have used?

  1. Twenty minutes, correctly — daylight saving cancels out over a short interval
  2. Eighty minutes, and it should have used datetime.now(timezone.utc) at both ends
  3. Minus forty minutes, and it should have used time.monotonic()
  4. It raises an exception, because the two local datetimes are not comparable
Show answer

Answer: C. Minus forty minutes, and it should have used time.monotonic()

The clocks went back at 01:00 UTC, so the local clock read 01:50 at the start and 01:10 at the end: subtracting gives minus forty minutes for twenty minutes of real work, and the lab prints exactly that. Nothing raises, which is the problem — a retry loop written as `while elapsed < timeout` never terminates on a negative elapsed, and a latency metric computed this way goes into the dashboard as a negative number once a year. The fix is time.monotonic(), which time.get_clock_info reports as monotonic and NOT adjustable: it only moves forward, it is never corrected by NTP or by daylight saving, and it has no relationship to any calendar. Option 1 names a real improvement — UTC instants at both ends would give the right answer here — but it is still the wrong tool, because a UTC wall clock is still adjustable and an NTP correction between the two readings still corrupts the measurement. The rule is short: wall clock for WHEN, monotonic for HOW LONG.

Q3. In Europe/London, what is the difference between 2026-10-25 01:30 and 2026-03-29 01:30 as wall-clock readings?

  1. The October one names two instants an hour apart; the March one names no instant at all
  2. Both name two instants, but only the October one can be disambiguated with fold
  3. The October one is invalid and Python raises; the March one is merely ambiguous
  4. Neither is unusual — both are ordinary readings, and fold only matters for zones outside Europe
Show answer

Answer: A. The October one names two instants an hour apart; the March one names no instant at all

October is the repeated hour: the clocks went back at 02:00 local, so 01:30 arrived, an hour passed, and 01:30 arrived again. Those are two instants — epoch 1792888200 and 1792891800, exactly 3600 seconds apart — and fold=0 selects the earlier while fold=1 selects the later. March is the deleted hour: the clocks jumped from 01:00 to 02:00 and no clock in the country ever showed 01:30. Python builds that object anyway and raises nothing, because a wall reading plus a zone name is a request rather than a fact; the failure only shows in the round trip, where converting to UTC and back returns 02:30+01:00 instead of what you asked for. Both cases have two folds and both give two different instants, which is why `!=` cannot distinguish them and the ORDER can: an ambiguous reading gives the earlier instant at fold=0, a nonexistent one gives the later. Option 2 is a common expectation and simply is not what the library does.

Q4. You attach Europe/London to 2026-10-25 01:30 twice, once with fold=0 and once with fold=1, then compare the two aware datetimes with ==. What happens?

  1. False, because they represent instants an hour apart
  2. A TypeError, because objects with different fold values cannot be compared
  3. True, because Python normalises both to the fold=0 instant before comparing
  4. True, because two aware datetimes with the same tzinfo are compared by their wall-clock fields and fold is ignored
Show answer

Answer: D. True, because two aware datetimes with the same tzinfo are compared by their wall-clock fields and fold is ignored

This is documented and it surprises everybody once. When both operands are aware and carry the same tzinfo attribute, the documentation says the tzinfo and fold attributes are ignored and the base datetimes are compared — that is, the wall-clock fields. So two objects naming instants exactly one hour apart test equal, while their timestamp() values differ by 3600 and their UTC conversions are plainly different. The practical consequence is bigger than the curiosity: anything that sorts, deduplicates, groups by, or uses aware datetimes as dictionary keys inherits this, and it will behave correctly for 363 days a year. The habit that removes the whole class of problem is to convert to UTC at the boundary of your system and compare instants, never wall clocks. Option 2 describes a normalisation Python does not perform, and if it did it would still be lossy.

Q5. A container image built two years ago runs a scheduler and has never been rebuilt. A government has since moved its country's daylight-saving date by two weeks. What happens, and what does it tell you about zone rules?

  1. Nothing — Python computes daylight saving from an algorithm, so it is always current
  2. The scheduler fires an hour out for those two weeks, because zoneinfo reads a tzdata database that is data on disk and the image's copy is stale
  3. zoneinfo raises ZoneInfoNotFoundError once the stored rules expire, so the failure is loud
  4. Only historical timestamps are affected; future ones are recomputed from the current rules at runtime
Show answer

Answer: B. The scheduler fires an hour out for those two weeks, because zoneinfo reads a tzdata database that is data on disk and the image's copy is stale

Python contains no time zone rules at all. zoneinfo is a reader for the IANA database that the operating system installs, and zoneinfo.TZPATH lists exactly where it looks — on the authoring machine, /usr/share/zoneinfo, holding version 2026c. That database is public-domain, maintained collaboratively, released several times a year, and updated whenever a legislature changes its mind, sometimes with a few weeks' notice. A two-year-old image holds a two-year-old opinion about the future and will be confidently, silently wrong for exactly the window the rules moved. This is why the lesson insists that a FUTURE local time is a prediction rather than a fact: 09:00 next March in a given city is a promise about what a government will have decided, and the only thing that can keep that promise current is updating the data. Option 2 would at least be loud; the actual failure is silent, which is worse.

Q6. A meeting invitation says "every Tuesday at 09:00, Europe/Paris", and it must survive the next daylight-saving change. What should the system store?

  1. The next occurrence as a UTC instant, and recompute the following one after each occurrence, storing the local time and the zone name
  2. A list of the next fifty-two occurrences precomputed as UTC instants
  3. The local wall-clock time 09:00 with no zone, and let each attendee's client interpret it
  4. The UTC offset that applies today, +02:00, stored alongside 09:00
Show answer

Answer: A. The next occurrence as a UTC instant, and recompute the following one after each occurrence, storing the local time and the zone name

This is the case where UTC alone is not enough, and it is worth being precise about why. The user's intent is a wall-clock intent — nine in the morning in Paris, whatever the offset happens to be that week — so the durable record has to be the local time plus the ZONE NAME, from which the next instant is computed. Storing the next occurrence as a UTC instant is right for the scheduler, because that is what fires exactly once; recomputing after each occurrence is what makes it survive both the transition and a later database update. Option 1 fails precisely at the transition: fifty-two precomputed instants encode today's rules, and every one after a rule change is an hour out with nothing to correct it. Option 3 is the same mistake in miniature — an offset is a property of a zone at a moment, not of the zone. Option 2 gives up on identifying an instant at all, which is fine for an alarm clock and useless for a meeting between cities.

Q7. What does datetime.strptime("2026-10-25 01:30:00 BST", "%Y-%m-%d %H:%M:%S %Z") do, and what is the general lesson?

  1. Returns an aware datetime in Europe/London, since BST identifies that zone
  2. Returns a naive datetime and discards the zone name
  3. Raises ValueError on this machine, because %Z accepts almost nothing — and even where it succeeds it returns a naive datetime, because an abbreviation cannot identify a zone
  4. Returns an aware datetime with a fixed +01:00 offset that will be wrong in winter
Show answer

Answer: C. Raises ValueError on this machine, because %Z accepts almost nothing — and even where it succeeds it returns a naive datetime, because an abbreviation cannot identify a zone

Run it and it raises ValueError: %Z matched only UTC and GMT on the authoring machine and rejected both BST and EST. That is the small trap. The large one is what happens when it succeeds — parsing "UTC" works and yields a datetime whose tzinfo is None, so the zone information you carefully included has been read and thrown away, leaving a naive value that looks parsed and is not located. And this is not a Python defect so much as an honest reflection of reality: an abbreviation cannot identify a zone, because IST is India, Ireland and Israel, and CST is used by at least three places. The abbreviation is a display label, never an identifier. Use %z with a numeric offset when you must parse legacy text, prefer fromisoformat on RFC 3339 text, and store the IANA zone NAME whenever you need to know where somebody was.

Q8. A fraud model is trained on events from three regions. The training-and-validation split is made at "midnight on 1 June" using timestamps that each region recorded in its own local time. Validation accuracy is excellent; production accuracy is not. What is the most likely cause?

  1. The model is overfitting, and the split should be random rather than by time
  2. The regions have different fraud rates, so the validation set is not representative
  3. The split is a different instant in each region, so hours of post-cutoff events leaked into training — a time-zone bug producing textbook temporal leakage
  4. Daylight saving added a duplicate hour of events, and the duplicates inflated the training set
Show answer

Answer: C. The split is a different instant in each region, so hours of post-cutoff events leaked into training — a time-zone bug producing textbook temporal leakage

One boundary expressed as a local wall clock is three different instants. "Midnight on 1 June" in three zones can be many hours apart, so events that came AFTER the cutoff in one region were filed as before it — and the model was trained on some of the future it was then asked to predict. The evaluation is measuring memorisation of leaked labels, so it reports a number the model cannot reproduce on live data, and nothing in the pipeline errors. Option 0 gets the diagnosis exactly backwards: a random split would be worse, because for time-ordered data it leaks the future everywhere rather than at one seam. Option 3 names a real phenomenon of the wrong size — a duplicated hour is one hour, not the multi-hour skew described here — though it is worth knowing that the same night genuinely produces duplicate wall-clock labels, and a pipeline keyed on local time will silently overwrite one hour with another. The rule that removes all of it: store instants in UTC, cut at an instant, and compute every feature strictly as of that instant.

Glossary

Instant
A single moment in the history of the universe, independent of any calendar, clock or place. Everything else in this lesson is a way of writing one down. An epoch count is an instant, a UTC timestamp is an instant, an aware datetime is an instant; a naive datetime is not.
Naive datetime
A datetime whose tzinfo is None, or whose tzinfo returns None from utcoffset(). It carries year, month, day, hour, minute, second and microsecond and no offset, so it names a wall-clock reading rather than an instant. The standard library allows it because plenty of times genuinely have no zone — an alarm at 07:00 wherever you wake up, a shop that opens at 09:00 in every branch — and forbidding it would make those unrepresentable. The cost is that a naive value converted, compared or stored as though it were an instant is silently wrong.
Aware datetime
A datetime whose tzinfo is not None and whose tzinfo.utcoffset() returns a value. It carries a wall-clock reading and the offset that was in force, which together name exactly one instant. The documentation gives those two conditions explicitly; anything else is naive.
UTC
Coordinated Universal Time — the reference against which every offset is stated. It has no daylight saving and never jumps, which is what makes it the right storage format: an instant recorded in UTC means the same thing on every machine, in every year, under every government. In Python it is timezone.utc, and datetime.now(timezone.utc) is the correct way to ask for the current instant.
Offset
The signed difference between a local wall clock and UTC at a given instant, returned by utcoffset() as a timedelta. Not a property of a zone but of a zone at a moment: Europe/London is +00:00 in January and +01:00 in July. Offsets are not always whole hours — Asia/Kolkata is +05:30 and Asia/Kathmandu +05:45 — which is why the type is a timedelta rather than an integer count of hours.
Time zone
A named region with a shared history of offsets and a shared set of rules for changing them — Europe/London, America/New_York. A zone is not an offset: it is the whole rule set that decides which offset applies at which instant, including every change the region has ever made. The name is the durable identifier; an abbreviation such as IST or CST is not, because several places use each one.
IANA time zone database
The public-domain database of every recorded and scheduled change of local time worldwide, maintained collaboratively and published by IANA under version names such as 2026c. It is updated whenever a government changes its mind, sometimes with a few weeks' notice. Python's zoneinfo contains none of this data: it reads whatever your operating system has installed, which means a stale container image holds a stale opinion about the future.
zoneinfo
The standard-library module, added in Python 3.9 through PEP 615, that reads the system IANA database. ZoneInfo("Europe/London") loads and caches a zone, zoneinfo.TZPATH lists the directories searched in order, available_timezones() returns every key available, and ZoneInfoNotFoundError — a subclass of KeyError — is raised when nothing on the path provides the requested zone.
DST transition
The instant at which a region changes its offset, usually by an hour and usually twice a year. The two directions are not symmetric in their consequences: going forward deletes a stretch of wall-clock readings, going back duplicates one. Not every transition is an hour — Lord Howe Island moves by thirty minutes — so code that special-cases plus or minus exactly one hour is already wrong somewhere.
Fold
The single-bit datetime attribute, added in Python 3.6 through PEP 495, that disambiguates a wall-clock reading which occurs twice. fold=0 means the earlier of the two instants and fold=1 the later. On a nonexistent reading it selects instead between the offset in force before the gap and the one after it. It is ignored when two aware datetimes with the same tzinfo are compared, which is documented and is the source of a memorable class of bug.
Ambiguous time
A wall-clock reading that occurred twice, because the clocks went back across it. 01:30 on 25 October 2026 in Europe/London names two instants an hour apart — epoch 1792888200 and 1792891800 — and the string alone cannot tell you which. Detect it by converting both folds to UTC and testing whether the fold=0 instant is the earlier of the two.
Nonexistent time
A wall-clock reading that never occurred, because the clocks jumped over it. 01:30 on 29 March 2026 in Europe/London is one: no clock in the country showed it. Python builds the object without complaining, because a wall reading plus a zone is a request rather than a fact; the failure shows in the round trip, where converting to UTC and back does not return the value you started with.
Wall-clock arithmetic
Adding a timedelta to an aware datetime, which adds to the calendar fields and then re-derives the offset from the result. Two hours of wall-clock arithmetic can be one, two or three hours of elapsed time across a transition. It is the correct behaviour for "the meeting is at 09:00 next Tuesday" and the wrong one for "the token expires in one hour" — for elapsed time, convert to UTC, add, and convert back.
ISO 8601
The international date and time standard, first published in 1988 and revised several times since, which orders fields from the most significant leftwards so that lexicographic order corresponds to chronological order. It is large: it also permits the basic form with no separators, week dates, ordinal dates, durations and intervals, so "valid ISO 8601" is a much weaker statement than people mean by it.
RFC 3339
The 2002 internet profile of ISO 8601 by Klyne and Newman, which narrows it to a date, a separator, a time and a mandatory offset to UTC. Z denotes a zero offset. Every RFC 3339 timestamp is valid ISO 8601 and the reverse is not true — 2026-W43-7 is ISO 8601 and is not a timestamp at all. RFC 3339 with a Z is the intersection that every parser reads, and it is what to write.
Epoch
A count of seconds since a fixed instant, conventionally 1970-01-01T00:00:00Z. Unambiguous by construction, since no zone, offset or wall clock appears in it, and unreadable by construction, since nobody spots that 1792891800 is a month wrong while scanning a log. Two limits are worth carrying: a signed 32-bit count runs out at 2038-01-19T03:14:07Z, and Python's timestamp() returns a float, so microseconds survive a round trip and nanoseconds do not.
Monotonic clock
A clock that only ever moves forward and is never adjusted, exposed as time.monotonic(). It has no relationship to any calendar and cannot tell you the date, which is the entire design. Use it for every duration, timeout and retry window, because the wall clock is adjustable and can move — or go backwards — between two readings of it.
Wall clock
time.time() and the local clock generally: the reading a person would see, adjustable by NTP, by an administrator, by a laptop waking from sleep and by daylight saving. time.get_clock_info("time") reports it as adjustable and not monotonic, which is the documented statement of exactly that hazard. Correct for recording when something happened; wrong for measuring how long anything took.
Leap second
An extra second inserted into UTC to keep it in step with the Earth's rotation, which is neither constant nor predictable. During one, a UTC minute genuinely contains sixty-one seconds and is labelled 23:59:60. Python's datetime cannot represent it — datetime(2016, 12, 31, 23, 59, 60) raises ValueError — because it implements an idealised time in which every day has exactly 86400 seconds. Many large operators now smear the extra second across a day so no clock ever shows :60.
POSIX time
The time model Python's datetime implements: every day contains exactly 86400 seconds, by definition rather than by observation. It is why leap seconds are unrepresentable, and why an epoch count silently repeats or stretches a second when one occurs. Accurate enough for everything except metrology and the very highest-precision reconciliation.
timedelta
A duration, stored internally as days, seconds and microseconds only. It deliberately has no months or years field, because those have no fixed length: a month is 28, 29, 30 or 31 days depending on which one and which year. So "add one month" is not a timedelta but a policy — clamp to the month end, overflow into the next month, or refuse — and the standard library provides calendar.monthrange to inform the choice while making none.
Temporal leakage
The failure mode where information from after a prediction's cutoff reaches the features used to make it, so an evaluation reports a score the model cannot reproduce in production. A time-zone bug is one of its quietest causes: a split at "midnight local" against timestamps recorded in another zone moves hours of the future into the past, and the resulting model looks excellent and is worthless.

Sources and further reading


Kept in this browser, no account needed. Your progress page turns the whole record into one link you can bookmark or open on another device.