UTC Versus Local Time, DST, and the Timezone Database
Explain the difference between UTC and local time, understand how Daylight Saving Time creates ambiguous and nonexistent local times, and apply the best practice of storing timestamps in UTC and converting to local time only for display.
Timezones are one of the most consistently underestimated sources of bugs in software. A date/time value is meaningless without knowing which timezone it's expressed in, and the moment Daylight Saving Time (DST) enters the picture, naive local-time arithmetic starts producing hours that don't exist, hours that happen twice, and off-by-one-hour bugs that only show up twice a year. This lesson explains UTC, DST, the IANA timezone database that makes correct timezone math possible at all, and the storage strategy that sidesteps almost all of these problems: keep everything in UTC internally, and only convert to a local zone at the very last moment, for display.
UTC as the Universal Reference
UTC (Coordinated Universal Time) is the timezone-independent reference point that every other timezone is defined relative to, as a fixed offset (e.g. UTC+9 for Tokyo, UTC-5 for New York's winter time). Unlike local times, UTC never observes Daylight Saving Time — a UTC timestamp always advances at a constant, unambiguous rate. This makes it the only sane choice for internal representation: two events that happened five minutes apart in UTC are always exactly five minutes apart, no exceptions, no seasonal adjustments, no ambiguity.
"Local time" by contrast is UTC shifted by whatever offset applies to a specific place at a specific moment — and that offset itself can change throughout the year because of DST. This is the fundamental reason local time is unsuitable as an internal representation for anything that needs to be compared, sorted, or have arithmetic performed on it reliably.
• UTC is the fixed, DST-free reference time that all timezones are offsets from. • Local time = UTC + a place's current offset, which itself changes with DST. • UTC is monotonic and unambiguous — ideal for storage, comparison, and arithmetic. • Local time is what a human wants to *see*, not what a program should compute with.
Daylight Saving Time and Its Bugs
DST is the practice, in many (not all) regions, of shifting clocks forward by an hour in spring ('spring forward') and back by an hour in autumn ('fall back'), to shift more daylight into waking hours. This produces two very real bugs at the transition boundaries. In spring, when clocks jump from, say, 1:59:59 AM directly to 3:00:00 AM, the entire hour from 2:00 AM to 2:59:59 AM never happens in local time — it simply does not exist. A naive scheduler that tries to fire a job at 2:30 AM on that date will either skip it or throw, depending on the library.
In autumn, the opposite happens: clocks fall back from 1:59:59 AM to 1:00:00 AM, so the entire hour from 1:00 AM to 1:59:59 AM happens twice in the same calendar day. A local timestamp like '01:30:00' on that date is genuinely ambiguous — it could refer to either of two distinct real-world instants, exactly one hour apart. Code that stores or compares raw local times without also recording which of the two occurrences (or the UTC offset in effect) is meant will get this wrong roughly twice a year, which is exactly the kind of intermittent, hard-to-reproduce bug that's expensive to track down.
Not every region observes DST (much of Arizona, Japan, and most of the tropics do not), and the specific transition dates vary by country and even change by legislation over time — which is precisely why you cannot hardcode a fixed offset and expect it to remain correct.
• Spring forward: an hour of local time (e.g. 2:00–2:59 AM) is skipped entirely and does not exist. • Fall back: an hour of local time occurs twice, making a bare local timestamp ambiguous. • DST rules and transition dates vary by country/region and change over time by legislation. • Some regions (Arizona, Japan, most of the tropics) don't observe DST at all.
The IANA Timezone Database
The IANA Time Zone Database (often called 'tz database' or 'zoneinfo', and formerly the 'Olson database') is the authoritative, regularly updated dataset of every timezone's full history of UTC offsets and DST transition rules, region by region, going back decades. PHP's DateTimeZone class is built directly on this database, which is why you should always identify a timezone with a named zone like 'America/New_York' or 'Europe/Berlin' instead of a fixed offset like '-05:00' or an ambiguous abbreviation like 'EST'.
The reason named zones matter is that they encode *behavior*, not just a number: 'America/New_York' knows that it's UTC-5 in winter, UTC-4 in summer, and precisely when the switch happens each year — including any historical rule changes for past dates. A fixed offset of '-05:00' captures only a snapshot that's wrong for half the year. Fixed abbreviations like 'EST' are even worse, since the same abbreviation is reused ambiguously across multiple, unrelated timezones around the world.
The tz database is updated several times a year as governments change DST rules or timezone boundaries, and PHP ships and updates its own copy independently of the OS, so keeping PHP (or at least its tzdata/timezonedb extension) reasonably current matters for long-term date accuracy, especially for dates far in the future.
• IANA/tz database: authoritative record of every region's UTC offset history and DST rules. • Always identify timezones by IANA name, e.g. 'Asia/Kolkata', never a bare numeric offset. • Named zones encode DST *behavior*; fixed offsets are only correct for part of the year. • Keep PHP's timezone data updated so future-dated DST calculations stay correct.
Best Practice: Store UTC, Convert for Display
The practical rule that avoids nearly every DST and timezone bug: store every timestamp internally — in the database, in session state, in logs, in APIs — as UTC, and only ever convert to a specific local timezone at the point of display, using the specific user's or client's known preferred timezone. Comparisons, sorting, duration math, and scheduling logic all operate purely on UTC values, where there is no ambiguity and no skipped or duplicated hour to worry about.
In PHP, this typically means constructing and storing DateTimeImmutable values with an explicit UTC DateTimeZone (or storing them as UTC-based integer timestamps / ISO 8601 strings with a 'Z' suffix), and only calling setTimezone(new DateTimeZone($user->timezone)) right before formatting for a template or API response. The user's preferred timezone itself should be stored as an IANA name (e.g. in a user profile setting) rather than assumed from the server's location.
• Store: UTC everywhere internally — database columns, logs, queues, caches. • Convert: only at the final display/formatting step, using the viewer's known IANA timezone. • Persist each user's timezone preference as an IANA name, not a raw offset. • This one convention eliminates almost every DST-related and cross-timezone comparison bug.
Storing UTC, displaying local
setTimezone() re-displays the same instant; it never changes what moment is represented. Keep the user's IANA timezone name in their profile, not a fixed numeric offset.
// Store: always construct/persist in UTC
$eventUtc = new DateTimeImmutable('now', new DateTimeZone('UTC'));
// Display: convert only at the last moment, using the viewer's zone
$userZone = new DateTimeZone($user->timezone); // e.g. 'America/Chicago'
$localForDisplay = $eventUtc->setTimezone($userZone);
echo $localForDisplay->format('Y-m-d H:i T');Code Examples
Requesting a local time that never actually existed (because DST skipped it) doesn't throw in PHP — the engine silently normalizes it forward by the DST gap, which is exactly the kind of surprising, easy-to-miss behavior that makes constructing dates directly in a DST-affected local zone risky. Expected result: 2024-03-10 03:30:00 -04:00 2024-03-10 07:30:00
$attempt = new DateTimeImmutable('2024-03-10 02:30:00', $tz);
Common mistake: Assuming every local wall-clock string is a valid, real instant. Around spring-forward transitions, some local times simply don't exist and get silently shifted.
Both UTC instants, exactly one hour apart, display as '01:30:00' in America/New_York local time — the only thing distinguishing them is the UTC offset (-04:00 vs -05:00). This is exactly why storing the ambiguous local string '2024-11-03 01:30:00' alone, without the offset or the UTC instant, loses information you can never recover. Expected result: 2024-11-03 01:30:00 -04:00 2024-11-03 01:30:00 -05:00 Keeping the source of truth in UTC (as this example does) sidesteps the ambiguity entirely — the UTC values are always distinct and orderable, even though their local displays collide.
echo $firstOccurrence->setTimezone($nyTz)->format('Y-m-d H:i:s P') . PHP_EOL;
The same named zone, America/New_York, correctly reports a different UTC offset depending on the time of year because it carries full DST rules. A fixed numeric offset has no such awareness and is simply wrong for part of the year — this is the concrete reason to always prefer IANA zone names. Expected result: -05:00 -04:00 2024-07-15 12:00:00 -05:00 echo $summer->format('P'). PHP_EOL; // -04:00 (EDT)
<?php
$tz = new DateTimeZone('America/New_York');
// DST began at 2:00 AM on 2024-03-10 in America/New_York; 2:30 AM never happened locally
$attempt = new DateTimeImmutable('2024-03-10 02:30:00', $tz);
echo $attempt->format('Y-m-d H:i:s P') . PHP_EOL;
// PHP normalizes the nonexistent local time forward, e.g. to 2024-03-10 03:30:00 -04:00
$beforeUtc = $attempt->setTimezone(new DateTimeZone('UTC'));
echo $beforeUtc->format('Y-m-d H:i:s') . PHP_EOL;<?php
// On 2024-11-03, America/New_York clocks fall back at 2:00 AM, so 1:30 AM happens twice:
// once at UTC-4 (EDT, first occurrence) and once at UTC-5 (EST, second occurrence)
$firstOccurrence = new DateTimeImmutable('2024-11-03 05:30:00', new DateTimeZone('UTC'));
$secondOccurrence = new DateTimeImmutable('2024-11-03 06:30:00', new DateTimeZone('UTC'));
$nyTz = new DateTimeZone('America/New_York');
echo $firstOccurrence->setTimezone($nyTz)->format('Y-m-d H:i:s P') . PHP_EOL;
echo $secondOccurrence->setTimezone($nyTz)->format('Y-m-d H:i:s P') . PHP_EOL;<?php
$winter = new DateTimeImmutable('2024-01-15 12:00:00', new DateTimeZone('America/New_York'));
$summer = new DateTimeImmutable('2024-07-15 12:00:00', new DateTimeZone('America/New_York'));
echo $winter->format('P') . PHP_EOL; // -05:00 (EST)
echo $summer->format('P') . PHP_EOL; // -04:00 (EDT)
// A hardcoded '-05:00' offset would be WRONG for the summer date:
$wrongSummer = new DateTimeImmutable('2024-07-15 12:00:00', new DateTimeZone('-05:00'));
echo $wrongSummer->format('Y-m-d H:i:s P') . PHP_EOL; // stays fixed at -05:00, doesn't match real local timeFall-back DST: one local hour, two UTC instants
Two distinct UTC instants both display as '01:30' local time in America/New_York on the day clocks fall back — only the UTC value (or the offset) disambiguates them.
UTC: 04:30 05:30 06:30 07:30
| | | |
NY local: 00:30 --- 01:30(EDT) 01:30(EST) --- 02:30
^ same wall clock, ^
different real instants,
one hour apartImportant Rules
Store all timestamps internally as UTC — database columns, logs, message queues, caches.
Convert to local time only at the final display/formatting step, using the specific viewer's IANA timezone.
Never hardcode a fixed UTC offset for a timezone; always use its IANA name so DST is handled automatically.
Treat a bare local time near a DST boundary as potentially ambiguous or nonexistent — keep the UTC instant as the source of truth.
Store each user's timezone preference as an IANA name (e.g. 'Europe/Paris'), not a raw offset.
Global scheduling and calendar apps
A meeting scheduler that lets users in different countries book a call has to store the meeting time as a single UTC instant and render it in each attendee's own local timezone — anything else leads to attendees seeing different, disagreeing times, or a meeting that silently shifts by an hour when one region's DST kicks in but another's (like Arizona's) doesn't. Airlines, calendar apps, and cron-like job schedulers all follow the same UTC-storage pattern for exactly this reason; a scheduler that stores 'run daily at 9:00 AM America/New_York' rather than a fixed UTC time is deliberately choosing to track the named zone's DST shifts, since a 9 AM meeting should probably stay 9 AM local even as the UTC offset moves under it.
// Storing and comparing local times directly, with a fixed offset baked in $eventLocal = new DateTimeImmutable('2024-11-03 01:15:00', new DateTimeZone('-04:00')); // Wrong for the second occurrence of 1:15 AM, which is actually -05:00 that day
// Store the UTC instant; derive local display only when needed $eventUtc = new DateTimeImmutable('2024-11-03 05:15:00', new DateTimeZone('UTC')); $eventLocal = $eventUtc->setTimezone(new DateTimeZone('America/New_York'));
Baking a specific fixed offset into a stored date assumes that offset is correct forever, but DST means the correct offset for the same named zone changes twice a year — and on the fall-back day, the same local wall-clock time maps to two different offsets. Storing the UTC instant and converting on demand is unambiguous.
Why does storing timestamps in UTC internally avoid most DST bugs? A. Because UTC times are always displayed to users directly B. Because UTC never observes Daylight Saving Time, so it advances at a constant, unambiguous rate C. Because UTC timestamps take up less storage space D. Because PHP cannot represent local times at all
B. Because UTC never observes Daylight Saving Time, so it advances at a constant, unambiguous rate — UTC has no DST transitions, so a UTC value is never ambiguous (unlike a fall-back local hour) and never skips a value (unlike a spring-forward local hour). Local time is only computed from it at the display step.
What happens during a 'fall back' DST transition? A. An hour of local time is skipped and never occurs B. The IANA timezone database is updated automatically C. An hour of local time occurs twice, making a bare local timestamp within it ambiguous D. UTC time itself shifts by one hour
C. An hour of local time occurs twice, making a bare local timestamp within it ambiguous — When clocks fall back, the local clock repeats an hour (e.g. 1:00–1:59 AM happens twice), so a local time within that hour alone doesn't uniquely identify which of the two real instants is meant.
Why is new DateTimeZone('America/New_York') preferred over new DateTimeZone('-05:00')? A. The named zone is shorter to type B. Fixed offsets are not supported in PHP 8 C. The named zone encodes the full history of DST transitions, so it's correct year-round; a fixed offset is only correct part of the year D. There is no difference between them
C. The named zone encodes the full history of DST transitions, so it's correct year-round; a fixed offset is only correct part of the year — A named IANA zone like America/New_York knows when and by how much its offset changes for DST; a fixed offset is a snapshot that's wrong for whichever part of the year it doesn't match.
Write a function toDisplayTimezone(DateTimeImmutable $utcInstant, string $ianaZone): string that takes a UTC DateTimeImmutable and an IANA timezone name, and returns a formatted string 'Y-m-d H:i T' in that timezone, without mutating the input.
function toDisplayTimezone(DateTimeImmutable $utcInstant, string $ianaZone): string { return $utcInstant->setTimezone(new DateTimeZone($ianaZone))->format('Y-m-d H:i T'); }
Use setTimezone() on DateTimeImmutable — it returns a new object rather than mutating $utcInstant.