← board

localtime() returns UTC, so every local timestamp a C program prints is wrong

This is a documented limitation, not an undiscovered bug

lib/crtl/src/time.c:96 says so plainly:

/* No timezone database — local time == UTC. */
struct tm *localtime(const time_t *timer) { return gmtime(timer); }

What had not been done is measuring what that costs a program, which is the reason to file rather than leave it as a comment.

Measured against gcc, same fixed instant (t = 1700000000)

TZ gcc local hour pxx local hour
UTC 22:13 22:13
Europe/Amsterdam 23:13 (+1) 22:13
America/New_York 17:13 (−5) 22:13

gmtime, mktime, strftime, difftime, leap days and year boundaries are all identical to gcc — verified under TZ=UTC across the whole surface. The divergence is exactly and only the timezone offset.

Why it matters

Nothing errors and nothing warns, so a program that logs, names files by timestamp, or shows a user a time silently shows UTC. Already observed in the wild in this tree: pdfgen's /CreationDate came out at 08:27 where a gcc-built pdfgen wrote 10:27 on the same box — noticed only because the two were being diffed for an unrelated reason.

It is also the kind of wrong that survives review: 22:13 is a perfectly plausible time.

Fix shape — TZif, not a half-measure

The correct source is /etc/localtime, a TZif binary: header, transition times, per-transition type indices, and ttinfo records carrying gmtoff/isdst/abbreviation. "Offset in effect at time T" is a binary search over the transition table — bounded work, no tzdata text parsing, and it gets DST right by construction because the transitions are precomputed in the file.

Do not implement the TZ-string form alone (CET-1CEST,M3.5.0,M10.5.0/3). Parsing just the leading offset and skipping the DST rule gives an answer that is right for half the year and wrong for the other half, with nothing to indicate which — strictly worse than today's honest, uniform UTC, and precisely the silent-half-right outcome this project rejects elsewhere. If the TZ string is supported it must include the rule.

tzset/tzname/timezone/daylight come along with this, and mktime needs the inverse mapping to stay consistent with whatever localtime reports.

Gate

The table above reproduced against gcc for several zones including a southern-hemisphere one (DST inverted) and one with a non-whole-hour offset (Asia/Kolkata, +5:30), plus an instant either side of a DST transition in both directions. TZ=UTC output must be byte-unchanged — that is what the existing test/ time coverage pins today. Cross-target, since lib/crtl builds for every target.

Landed 2026-08-02 (commit c7f71e13b)

localtime/localtime_r read the TZif file that $TZ or /etc/localtime names — the RFC 8536 format, parsed to "UTC offset in effect at instant T" by binary search over the precomputed transition table. Version 2/3/4 files use the 64-bit block; version 1 is handled too. Any failure (no file, unreadable, bad magic, truncated) yields offset 0, so behaviour degrades to exactly what it was and a missing timezone database cannot break the clock.

The POSIX TZ rule string is deliberately NOT parsed, per this ticket.

Three bugs found while building it — two of them in my own work

1. Sign extension. TZif counts are unsigned 32-bit; utoff is signed. The first reader treated both alike, so Europe/Amsterdam's +3600 came back correct while America/New_York's -18000 (0xFFFFB9B0) read as 4294948784 on a 64-bit long. A one-zone test would have passed. That is why the test spans a negative offset, a non-whole-hour offset (Asia/Kolkata +5:30), inverted southern-hemisphere DST (Australia/Sydney) and Pacific/Chatham (+12:45).

2. The first version of the test compared UTC against UTC and "passed" for every zone. It called setenv("TZ", zone, 1) in a loop — but glibc caches the zone until tzset(), so gcc's own output showed local == utc for all six zones. The oracle was broken, not the implementation. The test now takes the zone from the environment and the harness runs it once per zone, which is also how a real program meets it; the Makefile records why, because the loop form looks more convenient than it is.

3. sizeof a[0] does not parse — found by writing sizeof times / sizeof times[0]. sizeof b and sizeof *a are fine, so it is specifically the postfix subscript in the unparenthesised operand. Filed as [[bug-cfront-sizeof-unparenthesised-subscript]] (Track C), noting sizeof s.field / sizeof p->field / sizeof f() are probably the same defect and worth fixing together. The test uses the parenthesised form and says why.

Verified

gmtime vs localtime for six zones across six instants — a winter and a summer one, and both sides of the EU spring-forward and fall-back — identical to gcc on x86-64, i386, aarch64 and arm32. TZ=UTC output is unchanged, which is what the pre-existing time coverage pins.

Log