C
<time.h> — time_t vs struct tm, the year-1900/month-0 breakdown, and process-global zones.
C’s date and time support is the standard header <time.h>, and it is deliberately minimal. There are two core types: time_t, an arithmetic instant (in practice Unix seconds since 1970-01-01 UTC), and struct tm, a broken-down civil breakdown — year, month, day, hour, minute, second, plus derived fields. There is no date-only type, no zone-aware type, and no calendar-arithmetic API. time(NULL) gives you the current time_t.
The detail that echoes through this whole guide lives in struct tm: its tm_year counts from 1900 and its tm_mon counts from 0. Those two off-by conventions are the direct ancestors of the legacy traps documented on the Java and JavaScript pages — java.util.Date’s deprecated Date(126, 5, 17) constructor inherited both (year from 1900, month from 0), and JavaScript’s Date inherited the 0-indexed month (plus, for two-digit years, the 1900 offset).
Converting between the two types is where zones enter, and they enter globally. gmtime(t) breaks a time_t down as UTC; localtime(t) breaks it down in the zone named by the process-wide TZ environment variable (consulted after tzset()). Going back, mktime(&tm) reads a struct tm as local time and returns a time_t — there is no standard call that reads a struct tm as UTC (POSIX adds timegm). See Time zones vs offsets and Instant vs civil time.
Everything hangs off the process zone
Because localtime/mktime consult one global TZ, a program has exactly one “local” zone at a time, changed only by mutating an environment variable — awkward and unsafe when different data belong to different zones. Worse, localtime and gmtime return a pointer to a single shared static struct tm, so two calls (or two threads) clobber each other.
Pitfall: localtime and gmtime return a pointer to a shared static buffer, and the “local” zone is a process-global set by TZ/tzset(). Holding a localtime result across another call — or across threads — silently corrupts it, and code in different zones cannot run concurrently. Use the reentrant localtime_r/gmtime_r, and treat the zone as global state you must set deliberately.
Pitfall: mktime silently normalizes an out-of-range or nonexistent struct tm instead of rejecting it. A DST gap time like 02:30 on a spring-forward day is silently rewritten to a different instant — which side of the gap you land on is unspecified by the standard and varies by libc; tm_mday = 31 in a 30-day month rolls into the next month. Its result also depends on tm_isdst: leave it -1 to let mktime resolve DST, because a stale or wrong value shifts the resulting instant by an hour.
See also: C++ for the modern <chrono> alternative, and Parsing user input.
Go deeper: timegm, strftime/strptime, and monotonic time
Reading a struct tm as UTC. Standard C only offers mktime (local). POSIX adds timegm, which reads the breakdown as UTC — the symmetric partner to gmtime:
struct tm tm = { .tm_year = 2026 - 1900, .tm_mon = 6 - 1, .tm_mday = 5,
.tm_hour = 14, .tm_isdst = 0 };
time_t utc = timegm(&tm); // POSIX: struct tm interpreted as UTC
Formatting and parsing. strftime renders a struct tm with printf-style specifiers (%Y-%m-%d); its inverse strptime (POSIX) parses text back into a struct tm. Both are locale-sensitive, and strptime does no validation beyond the format — the parsing hazards of Parsing user input apply.
Precision and monotonic time. time() gives whole seconds; clock_gettime (POSIX) fills a struct timespec (seconds + nanoseconds). Crucially, pass CLOCK_MONOTONIC — not CLOCK_REALTIME — when measuring elapsed time, because the realtime clock can jump (NTP, DST, manual set). See Clocks and Precision.
A note on time_t. The standard only says it is an arithmetic type; it was historically a 32-bit signed integer, the source of the 2038 problem. Modern platforms use 64-bit time_t.
Compiling the POSIX calls. setenv, tzset, timegm, strptime, and clock_gettime are POSIX, not ISO C. Under a strict -std=c17 a conforming libc (musl, for instance) hides their declarations, so you must request them with a feature-test macro — #define _POSIX_C_SOURCE 200809L before the includes, or compile with -std=gnu17.