| name | datetime-timezone-correctness |
| description | Implements and fixes correct date/time handling — UTC/instant storage, IANA timezone and DST conversion (gaps and overlaps), explicit ISO-8601 parsing/formatting, calendar-vs-elapsed duration math, DST-stable RRULE recurrence, and monotonic-vs-wall-clock duration measurement. |
| when_to_use | Code stores, parses, compares, adds to, or displays timestamps; or a bug is off-by-an-hour/day, a DST transition, a date-boundary or leap-day error, an ambiguous/nonexistent local time, recurrence/expiry, or wall-clock vs monotonic duration. Distinct from regex-build (validating a date *string's* shape) and message-queue-jobs (scheduling/firing the job, not computing its time). |
When to Use
Reach for this skill when the bug or task is about what a timestamp means, not how it looks on screen:
- "Reminder fires an hour early/late twice a year" / "off by one hour after the clock change"
- "Event lands on the wrong day for users in another timezone"
- "Token/trial expires a day early" or "expiry compares a naive datetime to an aware one"
- "Picking
datetime.now() vs utcnow(), naive vs aware, or Date vs Temporal/Luxon/java.time/chrono"
- "Recurring 9am meeting drifts to 8am / 10am" (DST-unstable RRULE)
- "Elapsed-time metric goes negative or huge" (used wall clock, NTP stepped it)
- "Parsing
01/02/2026 flips day and month" / "+0000 got dropped on parse"
- Leap-day / leap-second / Feb-29 arithmetic, "add 1 month to Jan 31"
NOT this skill:
- Validating that a string matches a date format (regex, positive/negative cases) → regex-build
- Scheduling, enqueuing, retrying, or actually firing a job at a time → message-queue-jobs
- Adding type hints so
Aware vs Naive is a compile-time error → type-safety-strict
- Column-level checks that a dataset's date field is non-null/in-range → validate-data-quality
- A concurrency race where two threads read a clock out of order → async-concurrency-correctness
Steps
-
Cardinal rule: store and transport an absolute instant; convert to local only at the display edge. Persist UTC or an offset-bearing instant. Local wall-clock time is for input and output only — never the source of truth.
| Concept stored | Right type | Wrong type | Example |
|---|
| A moment that happened/will happen | UTC instant / timestamptz / Instant | naive local datetime, "string + separate tz column" | log entry, created_at, fired-at |
| A wall-clock appointment a human set | local datetime + IANA zone id (e.g. America/New_York) | UTC instant alone (loses the user's intent across DST law changes) | "9:00am every Mon", future calendar event |
| A pure date with no time | date-only type (LocalDate) | midnight-UTC instant (shifts a day under any offset) | birthday, invoice due date, holiday |
| Elapsed time / a timeout | monotonic duration (see step 7) | difference of two wall-clock timestamps | request latency, cache TTL countdown |
Store the zone id (Europe/London), never a fixed offset (+01:00) or abbreviation (BST/CST — ambiguous, and offset changes at DST). Schema default: TIMESTAMPTZ in Postgres, never TIMESTAMP (Postgres timestamp is naive and silently drops the zone).
-
Audit naive vs aware; forbid the silent-local default. Grep the hotspots and replace every implicit-local call:
| Language | Banned (naive / implicit-local) | Use instead |
|---|
| Python | datetime.now(), datetime.utcnow(), datetime.fromtimestamp(ts), datetime.strptime(...) (naive) | datetime.now(timezone.utc), datetime.fromtimestamp(ts, tz=ZoneInfo("UTC")), attach ZoneInfo |
|
Common Errors
datetime.utcnow() — returns naive; downstream it's treated as local and shifts by the server offset. Fix: datetime.now(timezone.utc).
new Date("2026-06-15") — JS parses a date-only ISO string as UTC midnight, so it prints as the previous day west of UTC. Fix: parse with Temporal/Luxon and an explicit zone, or treat as a date type.
- Storing offset
+01:00 instead of zone id Europe/London — the offset is wrong the other half of the year and can't survive a tzdb law change. Fix: store the IANA id; derive the offset at conversion time.
- Postgres
TIMESTAMP (without TZ) for an instant — drops the zone; reads back in the session's TimeZone. Fix: TIMESTAMPTZ.
- Comparing naive to aware — Python raises
TypeError; some languages compare them as both-local and lie. Fix: normalize both to aware-UTC before comparing.
date_trunc/CAST(ts AS date) for "which day" — truncates in the server zone, so a 23:30-local event lands on the wrong date. Fix: convert to the user zone first (ts AT TIME ZONE 'America/New_York'), then truncate.
+ timedelta(days=1) expecting "same wall time tomorrow" — adds exactly 24h; off by an hour across DST. Fix: do the +1 day on a zoned/local value, then convert to instant.
- Jan 31 + 1 month = Mar 3 — naive 30/31-day math overflows February. Fix: a clamping API (
relativedelta, plusMonths, Luxon plus).
- RRULE expanded as fixed-offset UTC — every occurrence drifts an hour after the next DST change. Fix: expand in the
TZID zone, convert each occurrence individually.
- Elapsed time from wall clock — NTP step makes the delta negative or enormous, poisoning metrics/timeouts. Fix: monotonic clock for all durations.
SimpleDateFormat/dateutil.parser.parse/Date.parse on machine data — locale-dependent MM/DD vs DD/MM guessing silently swaps day and month. Fix: a fixed explicit pattern.
Verify
- Round-trip is stable: parse → store as UTC → format back yields the same instant for a sample including a
+07:00 and a -05:00 input. No value silently re-zoned.
- DST gap handled: constructing
02:30 on the spring-forward date in a DST zone applies the documented policy (shift-forward or reject) — it does not silently produce an arbitrary instant; the local→instant→local round-trip mismatch is detected.
- DST overlap handled: the fall-back
01:30 is resolvable to both instants via fold/disambiguation, and the code picks one deliberately (asserted 1h apart).
- Suite green under multiple zones: the full test run passes under
TZ=UTC, TZ=America/New_York, and TZ=Pacific/Kiritimati (UTC+14) — proving no hidden local-zone assumption.
- Boundary cases pass: Dec 31→Jan 1 in a non-UTC zone, Feb 29 leap day, Jan 31 + 1 month clamps to Feb, and a weekly RRULE crossing a DST date keeps its local wall time.
- Duration uses monotonic: a simulated wall-clock backward step does not produce a negative or absurd elapsed value (proving the monotonic source).
- Grep clean: no
utcnow/SimpleDateFormat/new Date(<string>)/chrono::Local/naive strptime remains in instant-handling paths.
Done = every stored/transported timestamp is a zone-carrying instant (UTC) or an explicit local-time-plus-IANA-zone, all parse/format uses an explicit offset-aware format, all durations use the monotonic clock, and the test suite passes under ≥3 timezones across the gap, overlap, leap-day, month-overflow, and recurrence boundaries.