Handling Timezone and DST Edge Cases in Inspection Scheduling
This guide sits under Syncing Inspector Calendars with Permit Milestones in the Inspection Scheduling & Field Operations track, and covers the one class of bug that silently moves an inspection by an hour: mishandled time zones and daylight-saving transitions.
Permalink to this section The Focused Problem
An inspection appointment is a promise made in local time — “framing inspection, Tuesday at 9:00 AM” — but a computer that stores it naively has no idea which 9:00 that is. Get the time zone wrong and the failure is not a crash; it is an inspector arriving an hour early to a locked site, a contractor’s crew standing down, and a permit system that swears the appointment was kept. Twice a year, on the daylight-saving transitions, the same code that worked all season produces appointments that drift, duplicate, or vanish. In a multi-jurisdiction platform the risk compounds: a state agency in one offset schedules inspections for counties in another, and every boundary is a chance to be sixty minutes wrong.
The compliance stakes are concrete. A code that requires a re-inspection “within 72 hours” is counting wall-clock hours in a specific locale, and a DST spring-forward makes one calendar day only 23 hours long. If your window arithmetic assumes every day is 24 hours, you can compute a deadline that is an hour off and either miss it or falsely flag a timely inspection as late. The input characteristics that trigger these bugs are narrow and predictable — appointments near 2:00 AM on transition nights, jurisdictions that observe DST differently, and any datetime that reached your system without an offset attached — which is exactly why they can be handled defensively once you know where to look.
This guide fixes the times; deciding which inspector and where is handled in routing inspectors by geographic proximity with Python. The rule underneath everything here is simple: store UTC, compute in aware datetimes, and display in the jurisdiction’s zone.
Permalink to this section Step 1 — Attach a Zone; Never Trust a Naive Datetime
A datetime with no tzinfo is a landmine: Python will happily compare and subtract it, producing answers that are silently wrong across a DST boundary. Use the standard-library zoneinfo to bind every scheduling time to the jurisdiction’s IANA zone at the moment it enters the system.
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
def localize(naive_wall_clock: datetime, iana_zone: str) -> datetime:
"""Attach a real zone to a wall-clock time a clerk typed in local terms."""
if naive_wall_clock.tzinfo is not None:
raise ValueError("expected a naive wall-clock datetime")
return naive_wall_clock.replace(tzinfo=ZoneInfo(iana_zone))
# A clerk books "framing at 9:00 AM" on 2026-03-08 in central time.
appt = localize(datetime(2026, 3, 8, 9, 0), "America/Chicago")
Always use an IANA key such as America/Chicago, never a fixed offset like UTC-6 and never the abbreviation CST — abbreviations are ambiguous (there are several "CST"s worldwide) and a fixed offset cannot follow the zone across a DST change. The IANA key carries the full rule set, so the same key gives the correct offset in January and in July.
Permalink to this section Step 2 — Store UTC, Compute in Instants
The datastore should hold a single unambiguous instant, and UTC is that instant. Convert the aware local time to UTC on the way in, do all interval arithmetic on aware datetimes, and never persist a naive value. This is what keeps a “72-hour” window correct even when the local calendar contains a 23-hour day.
from datetime import timedelta, timezone
def to_utc(aware_local: datetime) -> datetime:
if aware_local.tzinfo is None:
raise ValueError("refuse to store a naive datetime")
return aware_local.astimezone(timezone.utc)
def deadline_after(start_utc: datetime, hours: int) -> datetime:
"""Add real elapsed hours; DST-safe because UTC has no transitions."""
return start_utc + timedelta(hours=hours)
stored = to_utc(appt) # persist this
reinspect_by = deadline_after(stored, 72) # a true 72 elapsed hours
Adding a timedelta to a UTC instant adds real elapsed time, which is exactly what a statutory hour-count means. If you instead added 72 hours to a local wall-clock time across a spring-forward, you would land an hour off. The permit records that carry these timestamps follow the platform’s JSON schema for building permits, which should type every timestamp field as UTC to keep this invariant system-wide.
Permalink to this section Step 3 — Resolve Ambiguous and Imaginary Local Times
Twice a year the mapping between wall-clock and instant breaks down. On fall-back night a local time like 01:30 happens twice; on spring-forward night a local time like 02:30 never happens. zoneinfo models both, but you must decide the policy explicitly rather than let a wrong hour slip through.
def resolve_ambiguous(aware_local: datetime) -> datetime:
"""Detect a fall-back repeat; policy here = the FIRST occurrence (pre-transition)."""
# fold=0 is the first pass through the repeated hour, fold=1 the second.
first = aware_local.replace(fold=0)
second = aware_local.replace(fold=1)
if first.utcoffset() != second.utcoffset():
return first # ambiguous: choose the earlier real instant
return aware_local # unambiguous
def resolve_imaginary(aware_local: datetime) -> datetime:
"""Detect a spring-forward gap; shift an imaginary time forward out of the gap."""
# A time in the gap round-trips through UTC to a DIFFERENT wall-clock value.
normalized = aware_local.astimezone(timezone.utc).astimezone(aware_local.tzinfo)
if normalized.replace(tzinfo=aware_local.tzinfo) != aware_local:
return normalized # e.g. 02:30 -> 03:30, out of the nonexistent gap
return aware_local
The fold attribute is the standard-library mechanism for the repeated hour: fold=0 selects the first occurrence and fold=1 the second. Pick a written policy — most jurisdictions treat the earlier instant as canonical — and apply it consistently so an ambiguous appointment is never left to chance. For imaginary times, shifting forward out of the gap matches how people intend a “2:30” that the clock skipped.
Permalink to this section Step 4 — Emit ICS With an Explicit TZID
When the scheduler writes the calendar event described in the parent guide’s serialization step, the DTSTART must carry a TZID that names the zone, and the calendar must include the matching VTIMEZONE definition. A bare local DTSTART with no zone is a “floating” time that every client interprets in its own locale — the classic reason an inspector three counties away sees the wrong hour.
from icalendar import Calendar, Event
def event_with_tzid(uid: str, start_local: datetime, end_local: datetime,
summary: str) -> bytes:
if start_local.tzinfo is None:
raise ValueError("DTSTART must be zone-aware")
cal = Calendar()
cal.add("prodid", "-//municipalpermit.org//inspection//EN")
cal.add("version", "2.0")
ev = Event()
ev.add("uid", uid)
ev.add("summary", summary)
# icalendar attaches TZID automatically from the aware datetime's ZoneInfo.
ev.add("dtstart", start_local)
ev.add("dtend", end_local)
cal.add_component(ev)
return cal.to_ical() # includes DTSTART;TZID=America/Chicago:...
Passing an aware datetime backed by ZoneInfo lets icalendar emit DTSTART;TZID=America/Chicago and generate the corresponding VTIMEZONE block, so Google, Outlook, and CalDAV clients all place the appointment on the correct offset regardless of the device’s own zone. Never render the event by first stripping the zone to a local string; that is precisely how a floating time escapes.
Permalink to this section Parameter and Flag Reference
| Parameter | Type | Default | Notes |
|---|---|---|---|
jurisdiction_tz |
str |
"America/Chicago" |
IANA key per jurisdiction; the display and localization zone. |
storage_tz |
str |
"UTC" |
Always UTC; the single instant of record. |
ambiguous_policy |
str |
"earliest" |
On fall-back, earliest picks fold=0; document whichever you choose. |
imaginary_policy |
str |
"shift_forward" |
On spring-forward, move a gap time to the next real instant. |
window_unit |
str |
"elapsed_hours" |
Count statutory windows as real elapsed hours in UTC, not wall-clock. |
emit_vtimezone |
bool |
True |
Include the VTIMEZONE block so clients resolve TZID correctly. |
reject_naive |
bool |
True |
Refuse to store or serialize any datetime lacking tzinfo. |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section Naive datetime compared across a transition
Subtracting two naive datetimes that straddle a DST change gives an answer off by an hour, quietly corrupting window math. Enforce awareness at the boundary and fail loudly on a naive value rather than coercing it.
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
Permalink to this section Fixed offset used instead of an IANA zone
Storing timezone(timedelta(hours=-6)) freezes the offset, so an appointment booked in winter shows an hour off all summer. Bind to ZoneInfo("America/Chicago") so the offset follows the DST rules automatically.
Permalink to this section TZID missing from the calendar event
A DTSTART written as a plain local string floats, and every client renders it in the device’s own zone — the top cause of “the inspector showed up an hour early.” Always serialize from an aware datetime so TZID and VTIMEZONE are emitted together.
Permalink to this section Multi-jurisdiction offset assumed uniform
A regional platform that hard-codes one zone schedules a neighboring county an hour wrong at every boundary, and worse where jurisdictions observe DST differently. Carry the IANA zone as a per-jurisdiction field on the record and localize against that field, never against a platform-wide constant.
Permalink to this section utcnow() used as if it were aware
datetime.utcnow() returns a naive datetime that merely holds UTC values, so attaching a local zone to it double-counts the offset. Use datetime.now(timezone.utc) to get a genuinely aware UTC instant.
Permalink to this section Audit and Logging Guidance
Log every scheduling timestamp in two forms: the UTC instant of record and the localized wall-clock string with its IANA zone, so an auditor sees both the canonical truth and what the clerk and inspector actually saw. When you resolve an ambiguous or imaginary time, log the resolution explicitly — the input wall-clock, the policy applied, and the resulting instant — because a disputed appointment on a transition night is exactly the record a compliance officer will pull. Record the zone key used for each statutory-window calculation alongside the computed deadline, so a “was this inspection timely?” question can be re-derived rather than re-argued. Keep these entries for the jurisdiction’s full retention period and store the zone key as data, not as a rendered offset, so a historical record survives future changes to a zone’s DST rules. Aligning this trail with the audit schema in the parent scheduling guide lets a timing record join cleanly to the calendar event it produced.
Permalink to this section Frequently Asked Questions
Permalink to this section Should I store appointment times in local time or UTC?
Store UTC — a single unambiguous instant that no DST transition can move — and keep the jurisdiction’s IANA zone as a separate field for display. Computing windows and comparing times in UTC keeps arithmetic correct across transitions, and localizing only at the display and calendar-emit boundary means the stored value never drifts.
Permalink to this section How do I handle the hour that repeats on fall-back night?
Use the fold attribute: fold=0 is the first pass through the repeated local hour and fold=1 the second. Adopt a written policy — most jurisdictions take the earlier instant — and apply it whenever an appointment lands in that hour, so the same wall-clock time never resolves two different ways on different runs.
Permalink to this section Why does my calendar event show the wrong time on some devices?
Almost always the DTSTART was written without a TZID, making it a floating time each client interprets in its own zone. Serialize from a zone-aware datetime so the iCalendar output carries DTSTART;TZID=... and a matching VTIMEZONE, and every client will place the appointment on the correct offset.
Permalink to this section Related
- Syncing inspector calendars with permit milestones — the parent guide whose ICS events these rules keep on the right offset.
- Routing inspectors by geographic proximity with Python — assign the inspector before the appointment time is localized.
- Designing JSON schemas for building permits — type every timestamp field as UTC to keep this invariant platform-wide.
- Inspection Scheduling & Field Operations — the track connecting scheduling, routing, and notification.