TRENDING
Close view of the Urania World Clock in Berlin at night, with city names grouped by time zone above a band of gold hour numerals from 17 to 24
October 14, 2026
How to Handle Daylight Saving Time in Python With zoneinfo and Stop Scheduling, Billing, and Logging Bugs
Rows of red, blue and white railway signal levers with numbered name plates inside a signal box, with a train visible through the windows
October 14, 2026
Cloudflare’s D1 Control Plane Turns Millions of Databases Into a Lag Budget the Post Never Adds Up
Base of a revolving door seen from above, with glass panels meeting at the central axis over a granite floor
October 14, 2026
Ivanti Says It Fixed Part of a Connect Secure Flaw in July and Was Still Backporting When Attackers Struck
A black and white wooden lock-gate balance beam beside a canal lock chamber on the Stratford-upon-Avon Canal, with a sign reading Keep boat forward of cill marker
October 14, 2026
How to Fix SQLite “database is locked” Errors in Python With WAL Mode, BEGIN IMMEDIATE, and Checkpoints
Handwritten Going Out of Business banner hung on a closed metal shop shutter above a Clearance sign
October 14, 2026
Spirit Airlines’ Proposed Data Sale to Google Turns Privacy Review Into a Customer Check That Leaves Out Employees
14 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A small wooden label cabinet with three drawers, the lower two pulled open to show compartments of paper labels, with loose labels scattered on a grey surface beside it
How to Stop Mass Assignment and Excessive Data Exposure in a FastAPI App
October 14, 2026
A dark wooden abacus with two rows of black beads on copper-colored rods, resting on a clear acrylic stand against a beige wall
OpenAI’s EU Agentic Report Turns Token Share Into a Country Ranking Uncorrelated With Eurostat’s AI Data
October 14, 2026
Several conical thatched haystacks in a green field beside a gravel track under a cloudy sky
Microsoft Fixes a Record 1,413 Vulnerabilities in October, and Only One Is Known to Be Under Attack
October 14, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 242 Posts
News 245 Posts
Learning Hub 215 Posts
Home/Learning Hub/How to Handle Daylight Saving Time in Python With zoneinfo and Stop Scheduling, Billing, and Logging Bugs
Learning Hub

How to Handle Daylight Saving Time in Python With zoneinfo and Stop Scheduling, Billing, and Logging Bugs

Reproduce the daylight saving bugs that skip or double scheduled jobs, miscount durations and misorder logs, then fix them in Python with zoneinfo, a small helper module and 21 tests.

October 13, 2026 39 Min Read
9

What you will build, and why daylight saving time breaks code that looks correct

On Sunday, October 25, 2026, clocks in the United Kingdom go back one hour. A week later, on Sunday, November 1, clocks in New York go back too. During that repeated hour a wall clock shows every minute twice, so “01:30” no longer names a single moment. In spring the reverse happens: on March 8, 2026, New York’s clocks jumped from 01:59 straight to 03:00, and “02:30” did not exist at all. Code that treats a local time as if it were a point in time runs fine for most of the year, then quietly skips a job, runs it twice, bills an hour too many or sorts a log the wrong way round on those nights.

Table Of Content

  • What you will build, and why daylight saving time breaks code that looks correct
  • Ten terms to know before we start
  • Prerequisites
  • Create the project
  • Step 1: Make zoneinfo work, and meet the Windows trap
  • Step 2: Naive versus aware datetimes
  • Step 3: See the clock changes for yourself
  • Step 4: The hour that never happens and the hour that happens twice
  • Step 5: Date arithmetic that ignores clock changes
  • Step 6: Watch a scheduler break
  • Step 7: Build the toolkit
  • Classify a wall-clock time, then resolve it on purpose
  • Measure real time and move through the calendar
  • A daily job that runs once a day
  • Try the helpers on the problem days
  • Step 8: Decide what to save, an instant or a plan
  • Step 9: When governments change the rules
  • Keep your time zone data fresh
  • Step 10: Lock it in with tests
  • Check that everything works end to end
  • Common mistakes
  • Where to go next
  • Sources

This year there is a second reason to care. The release notes of the tz database, the shared collection of time zone rules that Python reads, record that British Columbia, Alberta and the Northwest Territories have stopped changing their clocks, and that Manitoba will do so from October 31. A server running older time zone data will still “fall back” on November 1 in places that will not. You will see the exact effect in Step 9.

In this tutorial you will reproduce each of these problems on purpose, so that you recognise them in your own code, and then fix them with Python’s zoneinfo module. You will build a small helper module, localtime.py, that turns a wall-clock time into a real moment on purpose, measures real elapsed time, schedules a daily job that runs exactly once a day, and saves times in a form that survives rule changes. You will finish with a suite of 21 tests that includes a check that your time zone data is recent enough.

Everything uses the Python standard library, the tzdata package that Windows needs, and pytest. Every output below came from running the code in this article on Windows 11 with Python 3.13.14 and tzdata 2026.5. Apart from the platform line in Step 1, none of the output depends on your own machine’s clock or time zone setting, so yours should match line for line when you use the same tzdata release.

Ten terms to know before we start

Instant. One exact moment in the history of the world, such as the moment a payment was recorded. It is the same everywhere, whatever the clocks on the walls say.

UTC. Coordinated Universal Time, the reference time that does not change with the seasons. Software uses it to name instants.

UTC offset. How far a local clock is from UTC at a given moment, written like -05:00 (five hours behind) or +05:30.

Wall-clock time. What a clock on the wall shows, such as 01:30 on November 1. On its own it does not identify an instant, because you also need to know the place and, near a clock change, which side of it you mean.

Time zone. A named set of rules that says what the UTC offset is at every moment, in the past and the future. The names come from the IANA database and look like America/New_York. A time zone is not an offset: an offset is one number, while a zone is the whole schedule of numbers.

Daylight saving time (DST). The practice of moving the clocks, usually by one hour, for part of the year. Clock changes also happen for other reasons, and the shift is not always an hour, as Step 3 shows.

Naive and aware datetimes. A naive datetime has no time zone attached, so it names no instant. An aware one carries a zone or an offset and does.

Gap and fold. PEP 495, the proposal that added the fold attribute to Python, puts the terms like this: “When clocks are moved back, we say that a fold is created in time. When the clocks are moved forward, a gap is created. A local time that falls in the fold is called ambiguous. A local time that falls in the gap is called missing.”

The fold attribute. A value of 0 or 1 on a datetime that says which of two repeated moments you mean. The datetime documentation puts it this way: “The values 0 and 1 represent, respectively, the earlier and later of the two moments with the same wall time representation.”

The tz database. The shared collection of time zone rules, maintained by volunteers and published by IANA. Python reads it through zoneinfo. Governments change their rules, so the database is re-released several times a year; the 2026 releases so far are named 2026a to 2026e.

Prerequisites

  • Python 3.10 or newer. The zoneinfo module itself arrived in Python 3.9, but the pinned pytest 9.1.1 needs 3.10, and this tutorial ran on Python 3.13.14.
  • pytest 9.1.1 for the test suite in Step 10. Everything before that needs only the standard library plus the tzdata package, which Step 1 installs.
  • No knowledge of dates and times beyond the basics. Each idea is explained when it first appears. You should be comfortable writing a Python function and running a script.
  • A terminal and an empty folder. The commands are for Windows; the differences for PowerShell, macOS and Linux are noted.

Create the project

Make a folder, create a virtual environment inside it, activate it and install pytest. Do not install anything else yet: Step 1 depends on starting without a time zone database.

mkdir dstbook
cd dstbook
python -m venv .venv
.venv\Scripts\activate
python -m pip install pytest==9.1.1

That activation line is for Windows Command Prompt. In PowerShell the script is .venv\Scripts\Activate.ps1, and on macOS and Linux the line is source .venv/bin/activate. Every file in the rest of this tutorial goes in the dstbook folder, and every command is run from there.

Step 1: Make zoneinfo work, and meet the Windows trap

Python’s zoneinfo module does not contain any time zone data. It reads the data from somewhere else, and the documentation says where: “By default, zoneinfo uses the system’s time zone data if available; if no system time zone data is available, the library will fall back to using the first-party tzdata package available on PyPI.” The same page warns about the platform most beginners use: “Some systems, including notably Windows systems, do not have an IANA database available, and so for projects targeting cross-platform compatibility that require time zone data, it is recommended to declare a dependency on tzdata.”

Before you write any real code, check what your Python can see. Create step01_check_zoneinfo.py:

# step01_check_zoneinfo.py - can this Python load the IANA time zone database?
import platform
import sys
import zoneinfo
from importlib import metadata

print("Python", sys.version.split()[0], "on", platform.system())
print("zoneinfo.TZPATH:", zoneinfo.TZPATH)
try:
    print("tzdata package:", metadata.version("tzdata"))
except metadata.PackageNotFoundError:
    print("tzdata package: not installed")
print("zone names found:", len(zoneinfo.available_timezones()))
try:
    zone = zoneinfo.ZoneInfo("America/New_York")
except zoneinfo.ZoneInfoNotFoundError as exc:
    print("ZoneInfoNotFoundError:", exc)
else:
    print("loaded", zone, "with key", zone.key)

The script prints the Python version and platform, the list of folders where zoneinfo looks for a system database (zoneinfo.TZPATH), whether the tzdata package is installed, how many zone names are available, and whether it can load America/New_York. Run it:

python step01_check_zoneinfo.py

On a clean Windows virtual environment the output is:

Python 3.13.14 on Windows
zoneinfo.TZPATH: ()
tzdata package: not installed
zone names found: 0
ZoneInfoNotFoundError: 'No time zone found with key America/New_York'

An empty TZPATH means Python found no system database to read, and with no tzdata package either there are zero names to load. The documentation predicts the result: “If neither system data nor tzdata are available, all calls to ZoneInfo will raise ZoneInfoNotFoundError.” This is the error you get on a Windows machine that has no time zone data. Fix it by installing the package that the documentation recommends:

python -m pip install tzdata

Run the script again. This time it should print:

Python 3.13.14 on Windows
zoneinfo.TZPATH: ()
tzdata package: 2026.5
zone names found: 598
loaded America/New_York with key America/New_York

The tzdata package is installed (these results came from version 2026.5), Python can see 598 zone names, and America/New_York loads. The count includes aliases such as US/Eastern that point at the same rules. If your project must run on Windows, list tzdata among its dependencies so that this step never has to be done by hand.

One consequence is easy to miss. Because zoneinfo reads the system database first when there is one, installing the tzdata package does not change what your program sees on a machine whose operating system already provides its own copy. On such a machine, the way to get newer rules is to update the operating system’s time zone package. Step 9 returns to this, because stale data is a real problem this autumn.

Step 2: Naive versus aware datetimes

A datetime can be created without any time zone, and it can be created with one. The difference decides whether the object names an instant. Create step02_naive_vs_aware.py:

# step02_naive_vs_aware.py - naive versus aware datetimes, and relabelling versus converting
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

NY = ZoneInfo("America/New_York")

naive = datetime(2026, 11, 1, 9, 0)
aware = datetime(2026, 11, 1, 14, 0, tzinfo=timezone.utc)
print("naive:", repr(naive), "| utcoffset():", naive.utcoffset())
print("aware:", repr(aware), "| utcoffset():", aware.utcoffset())
print("naive == aware:", naive == aware)
try:
    print(naive < aware)
except TypeError as exc:
    print("TypeError:", exc)

print()
print("datetime.now() has no zone attached:", datetime.now().tzinfo is None)
print("datetime.now(timezone.utc) is aware:", datetime.now(timezone.utc).tzinfo is not None)

instant = datetime(2026, 11, 1, 14, 0, tzinfo=timezone.utc)
print()
print("instant (UTC):          ", instant.isoformat(sep=" "))
print("astimezone(NY) converts:", instant.astimezone(NY).isoformat(sep=" "))
relabelled = instant.replace(tzinfo=NY)
print("replace(tzinfo=NY) relabels:", relabelled.isoformat(sep=" "))
print("relabelled instant in UTC:  ", relabelled.astimezone(timezone.utc).isoformat(sep=" "))

The script builds a naive value and an aware value, compares them, asks for the current time two ways, and then shows two operations that look similar and are not: astimezone and replace(tzinfo=...). Run it:

python step02_naive_vs_aware.py
naive: datetime.datetime(2026, 11, 1, 9, 0) | utcoffset(): None
aware: datetime.datetime(2026, 11, 1, 14, 0, tzinfo=datetime.timezone.utc) | utcoffset(): 0:00:00
naive == aware: False
TypeError: can't compare offset-naive and offset-aware datetimes

datetime.now() has no zone attached: True
datetime.now(timezone.utc) is aware: True

instant (UTC):           2026-11-01 14:00:00+00:00
astimezone(NY) converts: 2026-11-01 09:00:00-05:00
replace(tzinfo=NY) relabels: 2026-11-01 14:00:00-05:00
relabelled instant in UTC:   2026-11-01 19:00:00+00:00

Read the output from the top. A naive datetime reports utcoffset() as None, because it does not know where it is. An aware one reports an offset. The two are never equal, which the documentation states plainly: “Naive and aware datetime objects are never equal.” An equality test between them returns False without any error, which can hide a bug for a long time. An order comparison is stricter: “Order comparison between naive and aware datetime objects raises TypeError.” You can see that error on the fourth line.

The next two lines show that datetime.now() gives you a naive value. It contains the machine’s own local time and nothing that says which time zone that is, so the same line of code can print different times on different servers. datetime.now(timezone.utc) gives you an aware value.

The last four lines matter most. astimezone(NY) converts: the instant stays the same (14:00 UTC) and the wall clock changes to 09:00 in New York. replace(tzinfo=NY) relabels: the digits stay 14:00 and the instant moves, to 19:00 UTC, five hours later. Use astimezone to show an instant in another zone. Use replace(tzinfo=zone) only to attach a zone to a naive wall-clock time that you know was read in that zone.

That gives the first rule for the rest of the tutorial: inside your program, keep instants as aware datetimes, usually in UTC, and convert to a local zone only at the edges, when a person needs to read the result.

Step 3: See the clock changes for yourself

The tz database records each zone’s clock changes, but Python’s standard library has no public function that lists them. You can find them by asking for the UTC offset at many instants and noticing when it changes. That is what the first part of the helper module does. Create localtime.py with this content; the header already imports everything that later parts will use:

# localtime.py - helpers for handling local times safely with zoneinfo
from __future__ import annotations

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

UTC = timezone.utc


def utc_now() -> datetime:
    return datetime.now(UTC)


def require_aware(dt: datetime) -> datetime:
    if dt.tzinfo is None or dt.utcoffset() is None:
        raise ValueError(f"naive datetime not allowed: {dt!r}")
    return dt


def fmt_offset(offset: timedelta) -> str:
    minutes = int(offset.total_seconds() // 60)
    sign = "+" if minutes >= 0 else "-"
    minutes = abs(minutes)
    return f"{sign}{minutes // 60:02d}:{minutes % 60:02d}"


def transitions(zone: ZoneInfo, year: int) -> list[tuple[datetime, timedelta, timedelta]]:
    """UTC offset changes in a year as (UTC instant, offset before, offset after).

    Samples once a day, then bisects to the second. Assumes at most one change per day.
    """

    def offset_at(ts: int) -> timedelta:
        return datetime.fromtimestamp(ts, UTC).astimezone(zone).utcoffset()

    start = int(datetime(year, 1, 1, tzinfo=UTC).timestamp())
    end = int(datetime(year + 1, 1, 1, tzinfo=UTC).timestamp())
    found = []
    low, off_low = start, offset_at(start)
    for high in range(start + 86400, end + 1, 86400):
        off_high = offset_at(high)
        if off_high != off_low:
            a, b = low, high  # the change happens in (a, b]
            while b - a > 1:
                mid = (a + b) // 2
                if offset_at(mid) == off_low:
                    a = mid
                else:
                    b = mid
            found.append((datetime.fromtimestamp(b, UTC), off_low, off_high))
        low, off_low = high, off_high
    return found

The function transitions returns each offset change in a year as the UTC instant when it happened, the offset before and the offset after. It reads the offset once a day, and when two neighbouring days disagree it narrows the change down to the exact second by bisection, the same idea as a binary search. It assumes at most one change per day, which holds for the 2026 data used here. The three small functions around it are plain helpers: utc_now returns the current aware UTC time, require_aware refuses a naive datetime, and fmt_offset prints an offset like -05:00.

Now use it. Create step03_transitions.py:

# step03_transitions.py - find every clock change in 2026 from the tz database
from collections import Counter
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo, available_timezones

from localtime import UTC, fmt_offset, transitions

YEAR = 2026
print(f"Clock changes in {YEAR}:")
for name in ["America/New_York", "Europe/London", "Australia/Sydney", "Australia/Lord_Howe", "Asia/Kolkata"]:
    zone = ZoneInfo(name)
    changes = transitions(zone, YEAR)
    if not changes:
        offset = datetime(YEAR, 1, 1, tzinfo=UTC).astimezone(zone).utcoffset()
        print(f"{name:20} no change all year, always {fmt_offset(offset)}")
    for when, before, after in changes:
        shown_before = (when - timedelta(seconds=1)).astimezone(zone)
        shown_after = when.astimezone(zone)
        jump = int((after - before).total_seconds() // 60)
        print(
            f"{name:20} {when:%Y-%m-%d %H:%M} UTC  clock {shown_before:%H:%M:%S} -> {shown_after:%H:%M:%S}"
            f"  ({fmt_offset(before)} -> {fmt_offset(after)}, {jump:+d} min)"
        )

print()
print("How far London is ahead of New York, by date (read at 12:00 UTC):")
ny, london = ZoneInfo("America/New_York"), ZoneInfo("Europe/London")
runs = []
day = date(YEAR, 1, 1)
while day.year == YEAR:
    noon = datetime(day.year, day.month, day.day, 12, tzinfo=UTC)
    ahead = (noon.astimezone(london).utcoffset() - noon.astimezone(ny).utcoffset()).total_seconds() / 3600
    if runs and runs[-1][2] == ahead:
        runs[-1][1] = day
    else:
        runs.append([day, day, ahead])
    day += timedelta(days=1)
for first, last, ahead in runs:
    print(f"  {first:%b %d} to {last:%b %d}: {ahead:g} hours")

print()
names = sorted(available_timezones())
changed = {}
for name in names:
    found = transitions(ZoneInfo(name), YEAR)
    if found:
        changed[name] = found
sizes, per_date = Counter(), Counter()
for name, found in changed.items():
    for when, before, after in found:
        sizes[int((after - before).total_seconds() // 60)] += 1
        per_date[when.date()] += 1
print(f"zone names in the database (aliases included): {len(names)}")
print(f"names with at least one clock change in {YEAR}: {len(changed)}")
print("size of each change in minutes -> how many (name, change) pairs:")
for minutes, count in sorted(sizes.items()):
    print(f"  {minutes:+4d} min: {count}")
print(f"different UTC dates on which some zone changes its clocks: {len(per_date)}")
print("busiest dates:", ", ".join(f"{d:%b %d} ({n})" for d, n in per_date.most_common(6)))
odd = sorted(n for n, found in changed.items() if any(abs(a - b) != timedelta(hours=1) for _, b, a in found))
print("names with a change that is not exactly one hour:", ", ".join(odd))

print()
print("names whose 2026 changes do not pair up (different numbers of spring-forwards and fall-backs):")
for name, found in sorted(changed.items()):
    ups = sum(1 for _, b, a in found if a > b)
    downs = sum(1 for _, b, a in found if a < b)
    if ups != downs:
        print(f"  {name:20}", ", ".join(f"{when:%b %d} {int((after - before).total_seconds() // 60):+d}" for when, before, after in found))

It prints the 2026 clock changes for five zones, shows how far London is ahead of New York on each date, and surveys every zone in the database. Run it:

python step03_transitions.py

The first part of the output lists the changes:

Clock changes in 2026:
America/New_York     2026-03-08 07:00 UTC  clock 01:59:59 -> 03:00:00  (-05:00 -> -04:00, +60 min)
America/New_York     2026-11-01 06:00 UTC  clock 01:59:59 -> 01:00:00  (-04:00 -> -05:00, -60 min)
Europe/London        2026-03-29 01:00 UTC  clock 00:59:59 -> 02:00:00  (+00:00 -> +01:00, +60 min)
Europe/London        2026-10-25 01:00 UTC  clock 01:59:59 -> 01:00:00  (+01:00 -> +00:00, -60 min)
Australia/Sydney     2026-04-04 16:00 UTC  clock 02:59:59 -> 02:00:00  (+11:00 -> +10:00, -60 min)
Australia/Sydney     2026-10-03 16:00 UTC  clock 01:59:59 -> 03:00:00  (+10:00 -> +11:00, +60 min)
Australia/Lord_Howe  2026-04-04 15:00 UTC  clock 01:59:59 -> 01:30:00  (+11:00 -> +10:30, -30 min)
Australia/Lord_Howe  2026-10-03 15:30 UTC  clock 01:59:59 -> 02:30:00  (+10:30 -> +11:00, +30 min)
Asia/Kolkata         no change all year, always +05:30

Each row gives the exact UTC instant of the change, what the wall clock does at that instant, and the offsets before and after. In New York the clock goes from 01:59:59 straight to 03:00:00 on March 8, which is the gap, and from 01:59:59 back to 01:00:00 on November 1, which is the repeated hour. London changes on March 29 and October 25, so the United States and Europe are not in step. Sydney’s changes run the other way round, because its seasons do. Australia/Lord_Howe moves by 30 minutes, not an hour. Asia/Kolkata never changes at all. Nothing in your code should assume that a clock change is exactly one hour.

The rules behind the New York and London rows are short. These lines come from the tz database source files, northamerica and europe:

Rule US 2007 max - Mar Sun>=8 2:00 1:00 D
Rule US 2007 max - Nov Sun>=1 2:00 0 S
Rule EU 1981 max - Mar lastSun 1:00u 1:00 S
Rule EU 1996 max - Oct lastSun 1:00u 0 -

Sun>=8 means the first Sunday on or after the 8th, which is the second Sunday of March. Sun>=1 in November is the first Sunday of the month. lastSun is the last Sunday. The u after 1:00 marks a time in UTC. In the tz database America/New_York follows the US rules and, since 1996, Europe/London follows the EU rules, which matches the 07:00 UTC and 06:00 UTC New York changes and the 01:00 UTC London changes in the table.

The second part of the output turns the two calendars into a practical question:

How far London is ahead of New York, by date (read at 12:00 UTC):
  Jan 01 to Mar 07: 5 hours
  Mar 08 to Mar 28: 4 hours
  Mar 29 to Oct 24: 5 hours
  Oct 25 to Oct 31: 4 hours
  Nov 01 to Dec 31: 5 hours

Because the two regions change on different dates, London is four hours ahead of New York, not five, for three weeks in March and for one week at the end of October. This autumn that week runs from October 25 to October 31. A meeting fixed at a UTC time and a meeting fixed at a local time drift apart by an hour for exactly those days, which is an easy way to end up with a call that seems to have moved by an hour.

The last part of the output is the survey of the whole database:

zone names in the database (aliases included): 598
names with at least one clock change in 2026: 201
size of each change in minutes -> how many (name, change) pairs:
  -120 min: 1
   -60 min: 191
   -30 min: 2
   +30 min: 2
   +60 min: 198
  +120 min: 1
different UTC dates on which some zone changes its clocks: 18
busiest dates: Mar 08 (88), Nov 01 (79), Mar 29 (76), Oct 25 (76), Apr 04 (23), Oct 03 (17)
names with a change that is not exactly one hour: Antarctica/Troll, Australia/LHI, Australia/Lord_Howe

names whose 2026 changes do not pair up (different numbers of spring-forwards and fall-backs):
  Africa/Casablanca    Feb 15 -60, Mar 22 +60, Sep 20 -60
  Africa/El_Aaiun      Feb 15 -60, Mar 22 +60, Sep 20 -60
  America/Edmonton     Mar 08 +60
  America/Inuvik       Mar 08 +60
  America/Rainy_River  Mar 08 +60
  America/Vancouver    Mar 08 +60
  America/Winnipeg     Mar 08 +60
  America/Yellowknife  Mar 08 +60
  Canada/Central       Mar 08 +60
  Canada/Mountain      Mar 08 +60
  Canada/Pacific       Mar 08 +60

Of 598 names, 201 change their clocks at least once in 2026. Those changes happen on only 18 different UTC dates, and four of them, March 8, November 1, March 29 and October 25, account for most of the names. Most changes are exactly one hour, but Australia/Lord_Howe and its alias shift by 30 minutes, and Antarctica/Troll shifts by two hours.

The final lines are a surprise worth following. Usually a zone that springs forward in a year also falls back in that year, so the changes pair up. Eleven names do not. Africa/Casablanca and Africa/El_Aaiun have three changes, and nine Canadian names have a spring-forward on March 8 and no fall-back at all. Step 9 explains why, and why it matters to you in November.

Step 4: The hour that never happens and the hour that happens twice

Python lets you write down a wall-clock time that does not exist and one that is ambiguous, and it does not complain about either. Create step04_gap_overlap.py to see what actually happens:

# step04_gap_overlap.py - a wall time that never happens, and one that happens twice
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

from localtime import fmt_offset

NY = ZoneInfo("America/New_York")
UTC = timezone.utc


def show(label, dt):
    print(f"{label:<10}{dt.isoformat(sep=' ')}  fold={dt.fold}  as UTC: {dt.astimezone(UTC).isoformat(sep=' ')}")


print("--- 2026-03-08 02:30 in New York: the clocks jump from 02:00 to 03:00 ---")
skipped = datetime(2026, 3, 8, 2, 30, tzinfo=NY)
show("fold=0", skipped)
show("fold=1", skipped.replace(fold=1))
print("offset with fold=0:", fmt_offset(skipped.utcoffset()))
print("offset with fold=1:", fmt_offset(skipped.replace(fold=1).utcoffset()))
print("fold=0 value after a round trip through UTC:", skipped.astimezone(UTC).astimezone(NY).isoformat(sep=" "))

print()
print("--- 2026-11-01 01:30 in New York: the clocks fall back from 02:00 to 01:00 ---")
first = datetime(2026, 11, 1, 1, 30, tzinfo=NY)
second = datetime(2026, 11, 1, 1, 30, tzinfo=NY, fold=1)
show("first", first)
show("second", second)
print("first == second:", first == second)
print("same instant:", first.astimezone(UTC) == second.astimezone(UTC))
print("first == first.astimezone(UTC):", first == first.astimezone(UTC))
print("first.astimezone(UTC) == first.astimezone(UTC):", first.astimezone(UTC) == first.astimezone(UTC))

print()
print("--- sorting two log entries written in local time during the repeated hour ---")
cause = datetime(2026, 11, 1, 1, 45, tzinfo=NY)  # 01:45 EDT, the first time round
effect = datetime(2026, 11, 1, 1, 10, tzinfo=NY, fold=1)  # 01:10 EST, 25 minutes later
entries = [("effect", effect), ("cause", cause)]
for label, key in [
    ("sorted by local time", lambda row: row[1]),
    ("sorted by UTC", lambda row: row[1].astimezone(UTC)),
]:
    print(f"{label:<22}", [name for name, _ in sorted(entries, key=key)])
print("real gap between them:", effect.astimezone(UTC) - cause.astimezone(UTC))
python step04_gap_overlap.py
--- 2026-03-08 02:30 in New York: the clocks jump from 02:00 to 03:00 ---
fold=0    2026-03-08 02:30:00-05:00  fold=0  as UTC: 2026-03-08 07:30:00+00:00
fold=1    2026-03-08 02:30:00-04:00  fold=1  as UTC: 2026-03-08 06:30:00+00:00
offset with fold=0: -05:00
offset with fold=1: -04:00
fold=0 value after a round trip through UTC: 2026-03-08 03:30:00-04:00

--- 2026-11-01 01:30 in New York: the clocks fall back from 02:00 to 01:00 ---
first     2026-11-01 01:30:00-04:00  fold=0  as UTC: 2026-11-01 05:30:00+00:00
second    2026-11-01 01:30:00-05:00  fold=1  as UTC: 2026-11-01 06:30:00+00:00
first == second: True
same instant: False
first == first.astimezone(UTC): False
first.astimezone(UTC) == first.astimezone(UTC): True

--- sorting two log entries written in local time during the repeated hour ---
sorted by local time   ['effect', 'cause']
sorted by UTC          ['cause', 'effect']
real gap between them: 0:25:00

The missing time. The script creates 02:30 on March 8 in New York, a time that never occurred. Python accepts it, and the fold value decides which offset it uses. PEP 495 sets the rule: for a time in a gap, the rules in effect before the transition are used when fold is 0, and the rules in effect after it when fold is 1. So with fold=0 the value carries the offset -05:00 and means 07:30 UTC, while with fold=1 it carries -04:00 and means 06:30 UTC, an hour earlier. At neither of those instants did New York’s clocks read 02:30. Send the default value through UTC and back and the wall time changes: 02:30 becomes 03:30. The datetime you stored and the datetime you get back are different wall times.

The repeated time. 01:30 on November 1 happened twice. first (with fold=0) is 01:30 at -04:00, which is 05:30 UTC; second (with fold=1) is 01:30 at -05:00, which is 06:30 UTC. They are an hour apart, and yet first == second is True. The documentation explains: “If both comparands are aware, and have the same tzinfo attribute, the tzinfo and fold attributes are ignored and the base datetimes are compared.” Comparing against a value in another zone behaves differently, and in an odd way: first == first.astimezone(UTC) is False, even though both describe the same instant. The documentation covers that too: “datetime instances in a repeated interval are never equal to datetime instances in other time zone.” Convert both sides to UTC before comparing, as the “same instant” line does, and the answers are the ones you would expect.

The log that sorts backwards. The last block shows why this matters. Two log lines are written in local time during the repeated hour. The cause happened at 01:45 the first time round, and its effect was logged 25 minutes later, at 01:10 the second time round. Sorting by local time puts effect before cause. Sorting by UTC puts them in the right order. If you keep timestamps as local wall-clock values, every sort, merge and time-window query in the repeated hour can be wrong without any error.

The lesson is the second rule: never compare, sort or subtract wall-clock values from the same zone when order or length matters; convert to UTC first.

Step 5: Date arithmetic that ignores clock changes

Python’s arithmetic on aware datetimes does not look at the clock changes. The documentation says so for addition: “Note that no time zone adjustments are done even if the input is an aware object.” For subtraction of two values with the same zone it says: “No time zone adjustments are done in this case.” Create step05_arithmetic.py to measure what that means:

# step05_arithmetic.py - date arithmetic that ignores clock changes
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

NY = ZoneInfo("America/New_York")
UTC = timezone.utc

print("--- subtracting two datetimes that share a zone ---")
start = datetime(2026, 11, 1, 0, 30, tzinfo=NY)
end = datetime(2026, 11, 1, 3, 30, tzinfo=NY)
print("start:", start.isoformat(sep=" "), "| end:", end.isoformat(sep=" "))
print("end - start:                ", end - start)
print("end - start, converted first:", end.astimezone(UTC) - start.astimezone(UTC))

print()
print("--- adding a day, or 24 hours ---")
for label, d in [("fall", datetime(2026, 10, 31, 9, 0, tzinfo=NY)), ("spring", datetime(2026, 3, 7, 9, 0, tzinfo=NY))]:
    plus_day = d + timedelta(days=1)
    plus_24h = d + timedelta(hours=24)
    real = plus_day.astimezone(UTC) - d.astimezone(UTC)
    print(f"{label}: {d:%Y-%m-%d %H:%M%z} + 1 day = {plus_day:%Y-%m-%d %H:%M%z}; + 24 hours = {plus_24h:%Y-%m-%d %H:%M%z}")
    print(f"        real time between d and d + 1 day: {real}")

print()
print("--- a timestamp that was stored with only an offset ---")
stored = "2026-10-31T09:00:00-04:00"
parsed = datetime.fromisoformat(stored)
print("parsed tzinfo:", parsed.tzinfo, "| is a ZoneInfo:", isinstance(parsed.tzinfo, ZoneInfo))
tomorrow = parsed + timedelta(days=1)
print("parsed + 1 day:               ", tomorrow.isoformat())
print("what New York's clocks say:   ", tomorrow.astimezone(NY).isoformat())
python step05_arithmetic.py
--- subtracting two datetimes that share a zone ---
start: 2026-11-01 00:30:00-04:00 | end: 2026-11-01 03:30:00-05:00
end - start:                 3:00:00
end - start, converted first: 4:00:00

--- adding a day, or 24 hours ---
fall: 2026-10-31 09:00-0400 + 1 day = 2026-11-01 09:00-0500; + 24 hours = 2026-11-01 09:00-0500
        real time between d and d + 1 day: 1 day, 1:00:00
spring: 2026-03-07 09:00-0500 + 1 day = 2026-03-08 09:00-0400; + 24 hours = 2026-03-08 09:00-0400
        real time between d and d + 1 day: 23:00:00

--- a timestamp that was stored with only an offset ---
parsed tzinfo: UTC-04:00 | is a ZoneInfo: False
parsed + 1 day:                2026-11-01T09:00:00-04:00
what New York's clocks say:    2026-11-01T08:00:00-05:00

Subtraction. A job starts at 00:30 and ends at 03:30 on the night the clocks go back. end - start says three hours. The real elapsed time, found by converting both to UTC first, is four hours, because an hour was repeated in between. If you bill by the hour, or measure how long a task took, the plain subtraction is an hour short on that night, and an hour long on the spring night when an hour is skipped.

Adding a day. Starting from 09:00 on Saturday, October 31, adding one day and adding 24 hours both give 09:00 on Sunday, November 1. Both calculations kept the wall clock and moved the offset from -04:00 to -05:00. The real time between the two moments is 25 hours. In spring it is 23 hours. So + timedelta(days=1) means “the same wall-clock time tomorrow”, and, perhaps surprisingly, + timedelta(hours=24) means the same thing in Python. Neither one means “24 hours of real time later”.

The stored offset. The last block is a trap that catches programs that save timestamps as ISO text. The saved string 2026-10-31T09:00:00-04:00 carries only an offset. After fromisoformat the value has a fixed offset (UTC-04:00) and is not a ZoneInfo, so it has no idea that New York’s clocks will change. Adding a day keeps -04:00 and produces 2026-11-01T09:00:00-04:00. New York’s clocks say that moment is 08:00 on November 1, so the result is an hour off.

The third rule follows: use real elapsed time (arithmetic in UTC) to measure durations, use calendar arithmetic on wall-clock times to move to “the same time tomorrow”, and never do calendar arithmetic on a value that carries only an offset.

Step 6: Watch a scheduler break

Scheduling is where these rules cost money, because a job that depends on a local time has to say what happens when that time is skipped or repeated. Create step06_naive_scheduler.py. It runs two common designs through the clock changes, checking once per real minute:

# step06_naive_scheduler.py - two common ways to schedule a daily job, run through the clock changes
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

NY = ZoneInfo("America/New_York")
UTC = timezone.utc


def wall_clock_matches(zone, hour, minute, start_utc, days):
    """Fire whenever the local clock reads hour:minute, checked once per real minute."""
    fired = []
    for i in range(days * 24 * 60):
        moment = start_utc + timedelta(minutes=i)
        local = moment.astimezone(zone)
        if (local.hour, local.minute) == (hour, minute):
            fired.append(local)
    return fired


def every_24_hours(first_local, count):
    """Fire again 24 hours of real time after the previous run."""
    runs = [first_local]
    for _ in range(count - 1):
        runs.append((runs[-1].astimezone(UTC) + timedelta(hours=24)).astimezone(NY))
    return runs


print("Job A: 'fire when the clock reads 02:30', Mar 7 to Mar 9 2026")
for local in wall_clock_matches(NY, 2, 30, datetime(2026, 3, 7, 5, 0, tzinfo=UTC), 3):
    print("  ", local.isoformat(sep=" "))

print()
print("Job B: 'fire when the clock reads 01:30', Oct 31 to Nov 2 2026")
for local in wall_clock_matches(NY, 1, 30, datetime(2026, 10, 31, 4, 0, tzinfo=UTC), 3):
    print("  ", local.isoformat(sep=" "))

print()
print("Job C: 'start at 02:30 on Mar 6, then every 24 hours'")
for local in every_24_hours(datetime(2026, 3, 6, 2, 30, tzinfo=NY), 5):
    print("  ", local.isoformat(sep=" "))
python step06_naive_scheduler.py
Job A: 'fire when the clock reads 02:30', Mar 7 to Mar 9 2026
   2026-03-07 02:30:00-05:00
   2026-03-09 02:30:00-04:00

Job B: 'fire when the clock reads 01:30', Oct 31 to Nov 2 2026
   2026-10-31 01:30:00-04:00
   2026-11-01 01:30:00-04:00
   2026-11-01 01:30:00-05:00
   2026-11-02 01:30:00-05:00

Job C: 'start at 02:30 on Mar 6, then every 24 hours'
   2026-03-06 02:30:00-05:00
   2026-03-07 02:30:00-05:00
   2026-03-08 03:30:00-04:00
   2026-03-09 03:30:00-04:00
   2026-03-10 03:30:00-04:00

Job A fires whenever the local clock reads 02:30. Over the three days March 7 to 9 it fires only twice. On March 8 the clock never reads 02:30, so the job is silently skipped for the whole day.

Job B fires whenever the local clock reads 01:30. Over the three days October 31 to November 2 it fires four times, including twice on November 1, because the clock reads 01:30 twice. A job that is not safe to repeat, such as one that sends invoices, runs twice.

Job C starts at 02:30 on March 6 and then repeats every 24 hours of real time. It fires at 02:30 on March 6 and 7. From March 8 it fires at 03:30, and it keeps firing at 03:30 afterwards. The job has drifted by an hour, because 24 real hours after 02:30 on March 7 is 03:30 on March 8.

None of these designs is wrong in itself. Each simply failed to say what should happen at a clock change. The next step builds the tools to say it.

Step 7: Build the toolkit

The bugs in Steps 4 to 6 have the same root cause: a wall-clock time was treated as if it named one instant, or a calendar day was treated as if it had 24 hours. The helpers in this step make you choose. Add them to localtime.py, in this order, after the code from Step 3.

Classify a wall-clock time, then resolve it on purpose

First, how can code tell a missing or repeated time from an ordinary one? Ask what offset the time gets with fold=0 and with fold=1. For an ordinary time the two answers agree. For a time in a fold, fold=0 is the earlier occurrence, which still uses the larger, summer offset, so the first answer is larger than the second. For a time in a gap, PEP 495 says fold=0 uses the rules from before the jump and fold=1 the rules from after it, so the first answer is smaller. Append the two exception classes, classify and resolve:

# localtime.py (continued)
class AmbiguousTimeError(ValueError):
    """The wall-clock time happens twice because the clocks went back."""


class NonexistentTimeError(ValueError):
    """The wall-clock time never happens because the clocks jumped forward."""


def classify(wall: datetime, zone: ZoneInfo) -> str:
    """Return 'normal', 'gap' or 'overlap' for a naive wall-clock time."""
    if wall.tzinfo is not None:
        raise ValueError("classify() wants a naive wall-clock datetime")
    before = wall.replace(tzinfo=zone, fold=0).utcoffset()  # offset from before a change
    after = wall.replace(tzinfo=zone, fold=1).utcoffset()  # offset from after a change
    if before == after:
        return "normal"
    return "overlap" if before > after else "gap"


def resolve(
    wall: datetime,
    zone: ZoneInfo,
    *,
    ambiguous: str = "raise",
    nonexistent: str = "raise",
) -> datetime:
    """Turn a naive wall-clock time into an aware datetime, on purpose.

    ambiguous:   'raise', 'first' (the earlier instant) or 'second' (the later one)
    nonexistent: 'raise' or 'shift_forward' (move ahead by the size of the jump)
    """
    kind = classify(wall, zone)
    if kind == "normal":
        return wall.replace(tzinfo=zone, fold=0)
    if kind == "overlap":
        if ambiguous == "raise":
            raise AmbiguousTimeError(f"{wall} happens twice in {zone.key}")
        if ambiguous not in ("first", "second"):
            raise ValueError(f"unknown ambiguous policy: {ambiguous!r}")
        return wall.replace(tzinfo=zone, fold=0 if ambiguous == "first" else 1)
    if nonexistent == "raise":
        raise NonexistentTimeError(f"{wall} never happens in {zone.key}")
    if nonexistent != "shift_forward":
        raise ValueError(f"unknown nonexistent policy: {nonexistent!r}")
    # Read the wall time with the offset from before the jump, then let UTC do the conversion.
    return wall.replace(tzinfo=zone, fold=0).astimezone(UTC).astimezone(zone)

resolve turns a naive wall-clock time into an aware datetime and makes you say what to do in the two awkward cases. With the default "raise" policies it refuses to guess and raises AmbiguousTimeError or NonexistentTimeError. For a repeated time you can ask for the "first" or the "second" occurrence, which sets fold to 0 or 1. For a missing time you can ask for "shift_forward". That policy reads the missing wall time with the offset from before the jump, converts it to UTC, and shows the result in the zone, so 02:30 becomes 03:30: the wall clock moves ahead by the size of the jump. Notice the astimezone(UTC) before the final astimezone(zone). Calling astimezone with the zone the value already has would hand the value back untouched, because the documentation says “no adjustment of date or time data is performed.” Going through UTC forces a real conversion.

Measure real time and move through the calendar

Next come the helpers for arithmetic. Append elapsed, add_elapsed and add_calendar_days:

# localtime.py (continued)
def elapsed(start: datetime, end: datetime) -> timedelta:
    """Real time between two instants, safe across clock changes."""
    return require_aware(end).astimezone(UTC) - require_aware(start).astimezone(UTC)


def add_elapsed(dt: datetime, delta: timedelta) -> datetime:
    """Move dt forward by exactly `delta` of real time and show it in dt's own zone."""
    require_aware(dt)
    return (dt.astimezone(UTC) + delta).astimezone(dt.tzinfo)


def add_calendar_days(
    dt: datetime, days: int, *, ambiguous: str = "first", nonexistent: str = "shift_forward"
) -> datetime:
    """Same wall-clock time `days` calendar days later ('tomorrow at 9:00')."""
    zone = dt.tzinfo
    if not isinstance(zone, ZoneInfo):
        raise ValueError(f"needs a ZoneInfo, got {zone!r}")
    wall = dt.replace(tzinfo=None, fold=0) + timedelta(days=days)
    return resolve(wall, zone, ambiguous=ambiguous, nonexistent=nonexistent)

elapsed converts both ends to UTC before subtracting, so it answers “how much real time passed?”. add_elapsed adds a duration in UTC and shows the result in the original zone, which answers “what will the clock say after exactly this much time?”. add_calendar_days answers a different question: “what is the same wall-clock time N days later?” It removes the zone, adds the days to the plain wall-clock value, where no clock change exists, and then calls resolve again. Its defaults, "first" and "shift_forward", are the policies chosen for scheduling in the next helper.

A daily job that runs once a day

Last, a helper for the daily job that failed in Step 6. Append daily_runs:

# localtime.py (continued)
def daily_runs(
    zone: ZoneInfo,
    at: time,
    first_day: date,
    days: int,
    *,
    ambiguous: str = "first",
    nonexistent: str = "shift_forward",
) -> list[datetime]:
    """One run per calendar day at the local time `at`, however the clocks move."""
    return [
        resolve(
            datetime.combine(first_day + timedelta(days=i), at),
            zone,
            ambiguous=ambiguous,
            nonexistent=nonexistent,
        )
        for i in range(days)
    ]

It builds one wall-clock time for each calendar day and resolves each one with the same policies, so every day produces exactly one run. If the chosen time is missing that day, the run moves to the first valid moment after the jump. If the time is repeated, the run uses the first occurrence. These are choices, not facts about the world: another team may prefer to skip the missing day, or to run at the second occurrence. What matters is that the choice is written in code, where a reviewer can see it.

Try the helpers on the problem days

Create step07_helpers_demo.py, which calls each helper on the days that broke in the earlier steps:

# step07_helpers_demo.py - the localtime.py helpers on the same problem days
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo

from localtime import (
    UTC,
    AmbiguousTimeError,
    NonexistentTimeError,
    add_calendar_days,
    add_elapsed,
    classify,
    daily_runs,
    elapsed,
    resolve,
)

NY = ZoneInfo("America/New_York")

print("--- classify ---")
for wall in [datetime(2026, 6, 1, 12, 0), datetime(2026, 3, 8, 2, 30), datetime(2026, 11, 1, 1, 30)]:
    print(f"{wall:%Y-%m-%d %H:%M} -> {classify(wall, NY)}")

print()
print("--- resolve with each policy ---")
cases = [
    (datetime(2026, 3, 8, 2, 30), [{}, {"nonexistent": "shift_forward"}]),
    (datetime(2026, 11, 1, 1, 30), [{}, {"ambiguous": "first"}, {"ambiguous": "second"}]),
]
for wall, policies in cases:
    for policy in policies:
        try:
            got = resolve(wall, NY, **policy)
            print(f"{wall:%Y-%m-%d %H:%M} {policy or 'no policy'} -> {got.isoformat(sep=' ')} = {got.astimezone(UTC):%H:%M} UTC")
        except (AmbiguousTimeError, NonexistentTimeError) as exc:
            print(f"{wall:%Y-%m-%d %H:%M} {policy or 'no policy'} -> {type(exc).__name__}: {exc}")

print()
print("--- how long is a calendar day? local midnight to the next local midnight ---")
for day in [date(2026, 3, 7), date(2026, 3, 8), date(2026, 10, 31), date(2026, 11, 1)]:
    midnight = resolve(datetime.combine(day, time(0, 0)), NY)
    next_midnight = resolve(datetime.combine(day + timedelta(days=1), time(0, 0)), NY)
    print(f"{day:%a %Y-%m-%d}: {elapsed(midnight, next_midnight).total_seconds() / 3600:g} hours")

print()
print("--- 'tomorrow at 9' versus '24 hours later' from Sat Oct 31 2026 09:00 ---")
start = datetime(2026, 10, 31, 9, 0, tzinfo=NY)
print("add_calendar_days(start, 1):", add_calendar_days(start, 1).isoformat(sep=" "))
print("add_elapsed(start, 24h):    ", add_elapsed(start, timedelta(hours=24)).isoformat(sep=" "))

print()
print("--- one run per calendar day ---")
for label, at, first_day in [("02:30", time(2, 30), date(2026, 3, 7)), ("01:30", time(1, 30), date(2026, 10, 31))]:
    print(f"daily job at {label}")
    for run in daily_runs(NY, at, first_day, 3):
        print(f"   {run.isoformat(sep=' ')}  = {run.astimezone(UTC):%Y-%m-%d %H:%M} UTC")
python step07_helpers_demo.py
--- classify ---
2026-06-01 12:00 -> normal
2026-03-08 02:30 -> gap
2026-11-01 01:30 -> overlap

--- resolve with each policy ---
2026-03-08 02:30 no policy -> NonexistentTimeError: 2026-03-08 02:30:00 never happens in America/New_York
2026-03-08 02:30 {'nonexistent': 'shift_forward'} -> 2026-03-08 03:30:00-04:00 = 07:30 UTC
2026-11-01 01:30 no policy -> AmbiguousTimeError: 2026-11-01 01:30:00 happens twice in America/New_York
2026-11-01 01:30 {'ambiguous': 'first'} -> 2026-11-01 01:30:00-04:00 = 05:30 UTC
2026-11-01 01:30 {'ambiguous': 'second'} -> 2026-11-01 01:30:00-05:00 = 06:30 UTC

--- how long is a calendar day? local midnight to the next local midnight ---
Sat 2026-03-07: 24 hours
Sun 2026-03-08: 23 hours
Sat 2026-10-31: 24 hours
Sun 2026-11-01: 25 hours

--- 'tomorrow at 9' versus '24 hours later' from Sat Oct 31 2026 09:00 ---
add_calendar_days(start, 1): 2026-11-01 09:00:00-05:00
add_elapsed(start, 24h):     2026-11-01 08:00:00-05:00

--- one run per calendar day ---
daily job at 02:30
   2026-03-07 02:30:00-05:00  = 2026-03-07 07:30 UTC
   2026-03-08 03:30:00-04:00  = 2026-03-08 07:30 UTC
   2026-03-09 02:30:00-04:00  = 2026-03-09 06:30 UTC
daily job at 01:30
   2026-10-31 01:30:00-04:00  = 2026-10-31 05:30 UTC
   2026-11-01 01:30:00-04:00  = 2026-11-01 05:30 UTC
   2026-11-02 01:30:00-05:00  = 2026-11-02 06:30 UTC

Read the output in blocks. classify labels the three kinds of wall-clock time. resolve raises with its default policies, and each policy produces the instant you asked for: the missing 02:30 becomes 03:30 (07:30 UTC), and the repeated 01:30 becomes either 05:30 UTC or 06:30 UTC. The day lengths show what a “day” really is: 24 hours on an ordinary Saturday, 23 hours on March 8 and 25 hours on November 1. The next block sets “tomorrow at 9” beside “24 hours later”: from Saturday 09:00 they differ by an hour, exactly as the helper names promise. In the last block each daily job produces one run per day. The 02:30 job runs at 02:30, then at 03:30 on the day when 02:30 does not exist, then at 02:30 again. The 01:30 job runs once on November 1, at 05:30 UTC, which is the first time the clock reads 01:30.

Step 8: Decide what to save, an instant or a plan

Where you save a time decides whether these helpers can protect you, because there are two different kinds of time and each needs a different record:

What you are saving Save this Why
Something that already happened, such as a payment or a log line The instant, in UTC, plus the zone name if you want to show it in local time later The instant can never change, so it never needs correcting
A length of time A number of seconds, or two instants A duration does not depend on a calendar
A plan expressed in local time, such as an appointment or a job at 09:00 The wall-clock time, the zone name and your policy for gaps and repeats The rules can change before the plan happens, so turn the plan into an instant only when you need it
Only an offset such as -05:00 Avoid it for anything about people An offset cannot tell you what the clocks will do next

Two small functions complete the module. Append them:

# localtime.py (continued)
def to_storage(dt: datetime) -> tuple[str, str]:
    """Return (UTC instant as ISO text, IANA zone name)."""
    key = getattr(require_aware(dt).tzinfo, "key", None)
    if key is None:
        raise ValueError("need a ZoneInfo with a name, not a fixed offset")
    return dt.astimezone(UTC).isoformat(), key


def from_storage(utc_iso: str, zone_key: str) -> datetime:
    return require_aware(datetime.fromisoformat(utc_iso)).astimezone(UTC).astimezone(ZoneInfo(zone_key))

to_storage returns the instant in UTC as ISO text together with the zone’s IANA name. It refuses a datetime that carries only a fixed offset, because such a value cannot say what the clocks will do next. from_storage reverses the process and shows the instant in the named zone. Create step08_storage.py to use both ideas:

# step08_storage.py - what to save: an instant, or the intent behind it
from datetime import datetime
from zoneinfo import ZoneInfo

from localtime import UTC, from_storage, resolve, to_storage

NY = ZoneInfo("America/New_York")

print("--- something that already happened: save the instant and the zone name ---")
moment = resolve(datetime(2026, 11, 1, 1, 30), NY, ambiguous="second")
saved = to_storage(moment)
back = from_storage(*saved)
print("saved:   ", saved)
print("restored:", back.isoformat(sep=" "), "| fold =", back.fold, "| same instant:", back == moment)
try:
    to_storage(datetime.fromisoformat("2026-11-01T01:30:00-05:00"))
except ValueError as exc:
    print("an offset alone is refused:", exc)

print()
print("--- something planned in local time: save the wall time, the zone name and your policy ---")
plan = {"wall": "2027-03-14T02:30", "zone": "America/New_York", "ambiguous": "first", "nonexistent": "shift_forward"}
when = resolve(
    datetime.fromisoformat(plan["wall"]),
    ZoneInfo(plan["zone"]),
    ambiguous=plan["ambiguous"],
    nonexistent=plan["nonexistent"],
)
print("plan:      ", plan)
print("happens at:", when.isoformat(sep=" "), "=", when.astimezone(UTC).isoformat(sep=" "))
python step08_storage.py
--- something that already happened: save the instant and the zone name ---
saved:    ('2026-11-01T06:30:00+00:00', 'America/New_York')
restored: 2026-11-01 01:30:00-05:00 | fold = 1 | same instant: True
an offset alone is refused: need a ZoneInfo with a name, not a fixed offset

--- something planned in local time: save the wall time, the zone name and your policy ---
plan:       {'wall': '2027-03-14T02:30', 'zone': 'America/New_York', 'ambiguous': 'first', 'nonexistent': 'shift_forward'}
happens at: 2027-03-14 03:30:00-04:00 = 2027-03-14 07:30:00+00:00

The first block saves something that already happened, the second 01:30 on November 1. The pair ('2026-11-01T06:30:00+00:00', 'America/New_York') is all you need: the restored value is the same instant, and it even comes back with fold = 1, so it is still the second 01:30. A value with only an offset is refused. The second block is a plan: a dictionary of plain text holding a wall-clock time, a zone name and a policy. Nothing in it is an instant yet. When the plan is needed, resolve turns it into one. March 14, 2027, at 02:30 does not exist in New York, so the policy moves it to 03:30 at -04:00, which is 07:30 UTC.

Step 9: When governments change the rules

The reason to save a plan as a wall-clock time and a zone name, not as an instant, is that the rules can change after you save it. This is not a hypothetical. Step 3 found eleven zone names whose clock changes did not pair up in 2026, and the tz database release notes explain them. Release 2026b says: “British Columbia moved to permanent -07 on 2026-03-09.” Release 2026c says: “Alberta moved to permanent -06 on 2026-06-18.” It also says: “Morocco moves to permanent +00 on 2026-09-20.” Release 2026d says: “Canada’s Northwest Territories moved to permanent -06 on 2026-08-21.” The newest, release 2026e, published on September 29, says: “Manitoba moves to permanent -05 on 2026-10-31.” A computer that has not received these releases will still believe that those places fall back on November 1.

You can see the difference without breaking your own setup. The tzdata package for release 2026a, from March 1, predates all four changes. Create step09_rule_change.py. It downloads that older wheel, a wheel being an ordinary zip file, and loads zones straight from it. The documentation describes ZoneInfo.from_file as constructing a zone “from a file-like object returning bytes”, which is what lets the script read the old data without installing it:

# step09_rule_change.py - the same appointment under tz database 2026a and 2026e
import io
import subprocess
import sys
import zipfile
from datetime import datetime, timedelta
from importlib import metadata
from pathlib import Path
from zoneinfo import ZoneInfo

from localtime import UTC, fmt_offset, transitions

WHEELS = Path(__file__).resolve().parent / "wheels"
subprocess.run(
    [
        sys.executable, "-m", "pip", "download", "tzdata==2026.1", "--no-deps", "--only-binary=:all:",
        "-d", str(WHEELS), "-q", "--disable-pip-version-check",
    ],
    check=True,
)
wheel = next(WHEELS.glob("tzdata-2026.1-*.whl"))
archive = zipfile.ZipFile(wheel)
old_version = archive.read("tzdata/zoneinfo/tzdata.zi").splitlines()[0].decode()


def load_old(name):
    return ZoneInfo.from_file(io.BytesIO(archive.read(f"tzdata/zoneinfo/{name}")), key=name)


print(f"old data:     {wheel.name} ({old_version})")
print(f"current data: tzdata {metadata.version('tzdata')}")

NAMES = ["America/Vancouver", "America/Edmonton", "America/Inuvik", "America/Winnipeg", "America/New_York"]
wall = datetime(2026, 11, 2, 9, 0)  # the Monday after the US clocks fall back
print()
print("UTC offset for 2026-11-02 09:00 local time")
print(f"{'zone':<20}{'old data':>10}{'current':>10}")
for name in NAMES:
    old, new = load_old(name), ZoneInfo(name)
    print(f"{name:<20}{fmt_offset(wall.replace(tzinfo=old).utcoffset()):>10}{fmt_offset(wall.replace(tzinfo=new).utcoffset()):>10}")

print()
print("America/Vancouver, offset changes in 2026")
for label, zone in [("old data", load_old("America/Vancouver")), ("current", ZoneInfo("America/Vancouver"))]:
    found = ", ".join(
        f"{when:%b %d %H:%M} UTC ({int((after - before).total_seconds() // 60):+d} min)"
        for when, before, after in transitions(zone, 2026)
    )
    print(f"  {label}: {found}")

vancouver_old, vancouver_new = load_old("America/Vancouver"), ZoneInfo("America/Vancouver")
for label, when in [("Oct 31 12:00", datetime(2026, 10, 31, 12, 0)), ("Nov 02 12:00", datetime(2026, 11, 2, 12, 0))]:
    now = when.replace(tzinfo=vancouver_new)
    print(f"  current data, {label}: tzname {now.tzname()}, offset {fmt_offset(now.utcoffset())}, dst() = {now.dst()}")

print()
print("A customer in Vancouver books 09:00 on Mon 2026-11-02 while the old rules are still in force")
stored_utc = wall.replace(tzinfo=vancouver_old).astimezone(UTC)
print("  saved as a UTC instant:           ", stored_utc.isoformat(sep=" "))
print("  shown today:                      ", stored_utc.astimezone(vancouver_new).isoformat(sep=" "))
today = wall.replace(tzinfo=vancouver_new)
print("  saved as wall time + zone name:   ", today.isoformat(sep=" "), "=", today.astimezone(UTC).isoformat(sep=" "))
print(f"  difference between the two instants, in hours: {(today.astimezone(UTC) - stored_utc).total_seconds() / 3600:+g}")
python step09_rule_change.py
old data:     tzdata-2026.1-py2.py3-none-any.whl (# version 2026a)
current data: tzdata 2026.5

UTC offset for 2026-11-02 09:00 local time
zone                  old data   current
America/Vancouver       -08:00    -07:00
America/Edmonton        -07:00    -06:00
America/Inuvik          -07:00    -06:00
America/Winnipeg        -06:00    -05:00
America/New_York        -05:00    -05:00

America/Vancouver, offset changes in 2026
  old data: Mar 08 10:00 UTC (+60 min), Nov 01 09:00 UTC (-60 min)
  current: Mar 08 10:00 UTC (+60 min)
  current data, Oct 31 12:00: tzname PDT, offset -07:00, dst() = 1:00:00
  current data, Nov 02 12:00: tzname MST, offset -07:00, dst() = 0:00:00

A customer in Vancouver books 09:00 on Mon 2026-11-02 while the old rules are still in force
  saved as a UTC instant:            2026-11-02 17:00:00+00:00
  shown today:                       2026-11-02 10:00:00-07:00
  saved as wall time + zone name:    2026-11-02 09:00:00-07:00 = 2026-11-02 16:00:00+00:00
  difference between the two instants, in hours: -1

The script needs an internet connection for the download. Read the output from the top. The first table asks for the UTC offset at 09:00 on Monday, November 2, the day after the United States falls back. For each of the four Canadian zones the old data and the current data disagree by exactly one hour; New York, the control row, agrees. The second block shows why. With the old data America/Vancouver has two offset changes in 2026, in March and on November 1. With the current data it has only the March one.

The two lines after that show a detail that can surprise you. In the current data, Vancouver’s name changes from PDT to MST and dst() changes from one hour to zero between October 31 and November 2, while the UTC offset stays -07:00. The release notes explain that the database is deliberately modelled this way for now: for British Columbia they say to “temporarily model the change to occur on 2026-11-01 at 02:00 instead”, and they describe it as a temporary hack that works around a limitation in CLDR 48.1. So do your arithmetic with utcoffset() and the helpers from this tutorial, and treat tzname() and dst() as labels for people.

The last block is the cost of saving the wrong thing. A customer books 09:00 on November 2 in Vancouver while the old rules are still in force, and the system saves the instant, 17:00 UTC. Under today’s rules that instant is 10:00 in Vancouver, an hour late. Saving the wall-clock time and the zone name gives 09:00 and 16:00 UTC, which is what the customer asked for. Both records were correct on the day they were made; only the second one survives the rule change.

Keep your time zone data fresh

Saving plans correctly only helps if the machine that turns them into instants has current rules. Three places to look:

  • Where Python reads the data from. As Step 1 showed, zoneinfo reads the system database when there is one and falls back to the tzdata package otherwise. On Windows that means the package: run python -m pip install --upgrade tzdata and record the new version in your dependency file. These results need release 2026e, which is the tzdata package 2026.5. Where the operating system provides its own database, update the operating system’s time zone package instead, because that copy is read first.
  • Running programs. The documentation says that, barring cache invalidation via ZoneInfo.clear_cache(), asking for the same key always returns the same object, so a program that is already running keeps the zones it loaded. Restart long-running services after updating the data.
  • Your tests. The next step adds a test that fails on data older than release 2026e, so a stale build is caught before it ships.

Step 10: Lock it in with tests

The helpers are small, but a time bug costs an hour of confusion twice a year, so test them on the days that matter. Create test_localtime.py:

# test_localtime.py - tests for localtime.py (run: python -m pytest -q)
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo

import pytest

import localtime as lt

NY = ZoneInfo("America/New_York")


def count_kinds(zone, when_utc):
    """Classify every wall-clock minute in the four hours either side of a change."""
    centre = when_utc.astimezone(zone).replace(tzinfo=None)
    kinds = {"normal": 0, "gap": 0, "overlap": 0}
    for i in range(-240, 240):
        kinds[lt.classify(centre + timedelta(minutes=i), zone)] += 1
    return kinds


def test_classify_known_times():
    assert lt.classify(datetime(2026, 6, 1, 12, 0), NY) == "normal"
    assert lt.classify(datetime(2026, 3, 8, 2, 30), NY) == "gap"
    assert lt.classify(datetime(2026, 11, 1, 1, 30), NY) == "overlap"


def test_classify_refuses_an_aware_datetime():
    with pytest.raises(ValueError):
        lt.classify(datetime(2026, 6, 1, 12, 0, tzinfo=NY), NY)


@pytest.mark.parametrize("name", ["America/New_York", "Europe/London", "Australia/Lord_Howe", "Australia/Sydney"])
def test_gap_and_overlap_minutes_match_the_size_of_each_change(name):
    zone = ZoneInfo(name)
    changes = lt.transitions(zone, 2026)
    assert len(changes) == 2
    for when, before, after in changes:
        jump = int((after - before).total_seconds() // 60)
        kinds = count_kinds(zone, when)
        if jump > 0:
            assert (kinds["gap"], kinds["overlap"]) == (jump, 0)
        else:
            assert (kinds["gap"], kinds["overlap"]) == (0, -jump)


def test_resolve_refuses_by_default():
    with pytest.raises(lt.NonexistentTimeError):
        lt.resolve(datetime(2026, 3, 8, 2, 30), NY)
    with pytest.raises(lt.AmbiguousTimeError):
        lt.resolve(datetime(2026, 11, 1, 1, 30), NY)


def test_resolve_policies_pick_the_expected_instant():
    wall = datetime(2026, 11, 1, 1, 30)
    first = lt.resolve(wall, NY, ambiguous="first")
    second = lt.resolve(wall, NY, ambiguous="second")
    assert second.astimezone(lt.UTC) - first.astimezone(lt.UTC) == timedelta(hours=1)
    shifted = lt.resolve(datetime(2026, 3, 8, 2, 30), NY, nonexistent="shift_forward")
    assert shifted.isoformat() == "2026-03-08T03:30:00-04:00"


def test_resolve_rejects_unknown_policy_names():
    with pytest.raises(ValueError):
        lt.resolve(datetime(2026, 11, 1, 1, 30), NY, ambiguous="latest")


def test_day_lengths():
    def length(day):
        start = lt.resolve(datetime.combine(day, time(0, 0)), NY)
        end = lt.resolve(datetime.combine(day + timedelta(days=1), time(0, 0)), NY)
        return lt.elapsed(start, end)

    assert length(date(2026, 3, 8)) == timedelta(hours=23)
    assert length(date(2026, 11, 1)) == timedelta(hours=25)
    assert length(date(2026, 6, 1)) == timedelta(hours=24)


def test_elapsed_differs_from_plain_subtraction_in_one_zone():
    start = datetime(2026, 11, 1, 0, 30, tzinfo=NY)
    end = datetime(2026, 11, 1, 3, 30, tzinfo=NY)
    assert end - start == timedelta(hours=3)
    assert lt.elapsed(start, end) == timedelta(hours=4)


def test_calendar_days_keep_the_wall_time_and_elapsed_keeps_the_hours():
    start = datetime(2026, 10, 31, 9, 0, tzinfo=NY)
    assert lt.add_calendar_days(start, 1).isoformat() == "2026-11-01T09:00:00-05:00"
    assert lt.add_elapsed(start, timedelta(hours=24)).isoformat() == "2026-11-01T08:00:00-05:00"


@pytest.mark.parametrize(
    "at, first_day",
    [(time(2, 30), date(2026, 3, 6)), (time(1, 30), date(2026, 10, 30)), (time(9, 0), date(2026, 10, 30))],
)
def test_daily_runs_fire_once_per_calendar_day_and_in_order(at, first_day):
    runs = lt.daily_runs(NY, at, first_day, 5)
    assert len(runs) == 5
    assert [r.date() for r in runs] == [first_day + timedelta(days=i) for i in range(5)]
    instants = [r.astimezone(lt.UTC) for r in runs]
    assert instants == sorted(set(instants))


def test_storage_keeps_the_instant_and_the_zone_name():
    moment = lt.resolve(datetime(2026, 11, 1, 1, 30), NY, ambiguous="second")
    saved = lt.to_storage(moment)
    assert saved == ("2026-11-01T06:30:00+00:00", "America/New_York")
    back = lt.from_storage(*saved)
    assert back.astimezone(lt.UTC) == moment.astimezone(lt.UTC)
    assert back.tzinfo.key == "America/New_York" and back.fold == 1


def test_storage_refuses_a_fixed_offset():
    from datetime import timezone

    with pytest.raises(ValueError):
        lt.to_storage(datetime(2026, 1, 1, tzinfo=timezone(timedelta(hours=-5))))


def test_naive_datetimes_are_refused():
    with pytest.raises(ValueError):
        lt.elapsed(datetime(2026, 1, 1), datetime(2026, 1, 2))


@pytest.mark.parametrize("name", ["America/New_York", "Australia/Lord_Howe"])
def test_every_quarter_hour_of_2026_survives_a_trip_through_utc(name):
    zone = ZoneInfo(name)
    wall = datetime(2026, 1, 1)
    end = datetime(2027, 1, 1)
    while wall < end:
        kind = lt.classify(wall, zone)
        if kind == "gap":
            wall += timedelta(minutes=15)
            continue
        policy = {"ambiguous": "first"} if kind == "overlap" else {}
        aware = lt.resolve(wall, zone, **policy)
        assert aware.astimezone(lt.UTC).astimezone(zone).replace(tzinfo=None) == wall
        wall += timedelta(minutes=15)


def test_tz_data_knows_four_canadian_zones_stopped_falling_back():
    """Canary: fails on tz data older than release 2026e (the tzdata 2026.5 package)."""
    monday = datetime(2026, 11, 2, 9, 0)
    expected = {
        "America/Vancouver": -7,  # British Columbia, release 2026b
        "America/Edmonton": -6,  # Alberta, release 2026c
        "America/Inuvik": -6,  # Northwest Territories, release 2026d
        "America/Winnipeg": -5,  # Manitoba, release 2026e
    }
    for name, hours in expected.items():
        assert monday.replace(tzinfo=ZoneInfo(name)).utcoffset() == timedelta(hours=hours), name

The tests fall into five groups. The classification tests check the three kinds of wall-clock time and sweep every minute in the four hours around each 2026 clock change in New York, London, Lord Howe and Sydney, checking that the number of missing or repeated minutes equals the size of the jump: 60 minutes, or 30 for Lord Howe. The resolve tests check that the defaults refuse to guess, that each policy picks the instant you expect and that an unknown policy name is an error. The arithmetic tests check that days are 23, 24 or 25 hours long, that elapsed differs from plain subtraction, and that “tomorrow at 9” and “24 hours later” differ across a change. The scheduling tests check that daily_runs gives one run per calendar day, in order, for the 02:30, 01:30 and 09:00 jobs. The storage tests check the round trip and the refusal of a fixed offset.

Two tests are different. test_every_quarter_hour_of_2026_survives_a_trip_through_utc walks through every 15 minutes of the year in two zones and checks that a resolved time converts to UTC and back to the same wall-clock time. The last test is the canary from Step 9. It asks for the offsets of the four Canadian zones on November 2 and fails if your time zone data is older than release 2026e. Delete it, or change its expected values, once the autumn of 2026 is behind you; until then it turns a stale tz database from a surprise in production into a failing build. Run the suite:

python -m pytest -q
..................... [100%]
21 passed in 0.17s

Twenty-one tests pass. If the canary fails on your machine, update the time zone data as described in Step 9 and run it again.

Check that everything works end to end

With the files from the steps above in your folder, run every script and then the tests:

python step03_transitions.py
python step04_gap_overlap.py
python step05_arithmetic.py
python step06_naive_scheduler.py
python step07_helpers_demo.py
python step08_storage.py
python step09_rule_change.py
python -m pytest -q

Each script should print what is shown in its step, and the tests should report 21 passed. A short checklist for your own code follows from what you have seen:

  • Search for datetime.now() and datetime.utcnow() in code that saves or compares times. Replace them with datetime.now(timezone.utc).
  • Search for + timedelta(days=, + timedelta(hours=24) and subtraction of two local datetimes, and decide for each whether you meant calendar time or real elapsed time.
  • Search for fixed-offset zones, such as timezone(timedelta(hours=-5)), used for people’s local times.
  • For every job or reminder defined in local time, write down what should happen when that time is missing and when it repeats.
  • Check where your deployment gets its time zone data and when it was last updated.

Common mistakes

Treating replace(tzinfo=...) as a conversion. It relabels the digits and moves the instant (Step 2). Use astimezone to convert.

Trusting equality and ordering inside one zone. Two values with the same zone compare by wall-clock digits and ignore fold, and a repeated-hour value never equals the same instant in another zone (Step 4). Convert to UTC first.

Assuming a day has 24 hours. It has 23, 24 or 25 in New York in 2026 (Steps 5 and 7), and some zones change by 30 minutes or two hours (Step 3).

Doing calendar arithmetic on a parsed ISO string. After fromisoformat a value with -04:00 knows an offset, not a zone (Step 5). Attach the zone with astimezone(ZoneInfo(name)) before adding days, or save the zone name beside the instant.

Leaving the awkward cases undecided. A job set for 02:30 or 01:30 will hit a missing or repeated time sooner or later (Step 6). Choose a policy and write it in code.

Letting time zone data go stale. Four Canadian zones changed their rules in releases published between April and September 2026, and a machine on older data is wrong for them (Step 9).

Forgetting tzdata on Windows. Without it every ZoneInfo call raises ZoneInfoNotFoundError (Step 1).

Where to go next

  • The tests above use hand-picked days. A property-based test could generate random zones, wall-clock times and policies and check the same rules; the Hypothesis tutorial on this site shows how to set that up.
  • The sweeps here cover 2026 only. Dates in the past follow the same logic, but test the years and zones your own data uses.
  • Read the “Mind the Gap” section of PEP 495 for the exact rules that fold follows, and the zoneinfo documentation for the constructors and the cache.
  • Subscribe to the tz database announcements, or watch the tzdata package, so that the next rule change reaches you before it reaches your users.

Sources

  • zoneinfo, IANA time zone support, Python documentation
  • datetime, basic date and time types, Python documentation
  • PEP 495, Local Time Disambiguation
  • News for the tz database, releases 2026a to 2026e
  • tz database source files northamerica and europe, for the US and EU transition rules

Tags:

Daylight Saving TimepytestPythonReliability EngineeringTime Zones

Share

Rows of red, blue and white railway signal levers with numbered name plates inside a signal box, with a train visible through the windows
Previous Post

Cloudflare’s D1 Control Plane Turns Millions of Databases Into a Lag Budget the Post Never Adds Up

Several conical thatched haystacks in a green field beside a gravel track under a cloudy sky
Next Post

Microsoft Fixes a Record 1,413 Vulnerabilities in October, and Only One Is Known to Be Under Attack

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
14 Oct
How to Stop Mass Assignment and Excessive Data Exposure in a FastAPI App
14 Oct
OpenAI’s EU Agentic Report Turns Token Share Into a Country Ranking Uncorrelated With Eurostat’s AI Data
Trending
October 14, 2026
How to Stop Mass Assignment and Excessive Data Exposure in a FastAPI App
October 14, 2026
OpenAI’s EU Agentic Report Turns Token Share Into a Country Ranking Uncorrelated With Eurostat’s AI Data
October 14, 2026
Microsoft Fixes a Record 1,413 Vulnerabilities in October, and Only One Is Known to Be Under Attack
October 13, 2026
How to Handle Daylight Saving Time in Python With zoneinfo and Stop Scheduling, Billing, and Logging Bugs
October 13, 2026
Cloudflare’s D1 Control Plane Turns Millions of Databases Into a Lag Budget the Post Never Adds Up
October 13, 2026
Ivanti Says It Fixed Part of a Connect Secure Flaw in July and Was Still Backporting When Attackers Struck

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026