UTC, GMT, and Time Zones: A Developer's Guide to Avoiding Bugs

Rahmat Ullah profile photoRahmat Ullah
12 min readTime, Developer Tools, Debugging

A recurring scheduled job in one of our production systems stopped running exactly twice a year. Not crashing, not erroring, just silently skipping one execution in March and firing twice in November. It took me a week to figure out that the cron expression was being evaluated in local time while the application server thought in UTC, and the daylight saving transition was landing exactly on the scheduled minute. The fix was four characters long. The debugging was humbling. If you have ever shipped code that touches dates, you have a story like this waiting to happen. This post is what I wish I had read before writing that cron expression.

Every codebase I have worked on has had a timezone bug hidden somewhere. Sometimes it is an off-by-one-hour display issue that only shows up for users in Arizona. Sometimes it is a financial report that double-counts the last transaction of October. Sometimes it is a "we skip this day in Samoa" story that should not exist and does. The deeper you look, the more you realize that most of these bugs come from the same handful of misconceptions repeated over and over. If you need a quick translation between two zones while you debug, the StackConvert time zone converter handles it in the browser. The rest of this guide is about how to stop writing the bugs in the first place.

UTC vs GMT: Not Quite the Same Thing

Open any five blog posts about timezones and three of them will tell you that UTC and GMT are identical. That is close to true, and for most code it is functionally true, but it is not quite right and the distinction occasionally matters.

GMT (Greenwich Mean Time) is based on the solar day at the Royal Observatory in Greenwich. It is tied to the Earth's rotation. Because the Earth wobbles a bit and is slowly slowing down, GMT drifts away from atomic time at irregular rates.

UTC (Coordinated Universal Time) is based on atomic clocks. Specifically, it is International Atomic Time (TAI) with leap seconds inserted to keep it within 0.9 seconds of the astronomical day. When a leap second is added, UTC briefly pauses or repeats. GMT does not have leap seconds; it is just a continuous solar time reference.

In practice, for 99.99 percent of applications, treating "GMT" and "UTC" as the same thing produces no bugs. But a few specific contexts care about the difference:

  • Astronomical software often uses UT1, a version of solar time, not UTC. The difference can matter when pointing a telescope.
  • Legacy BBC and UK government documents historically said GMT when they meant civil time, which since 1972 has actually been UTC. You may see the label on old data that predates the distinction.
  • Some database drivers will happily accept "GMT" as a timezone name and silently map it to UTC. That usually works. Sometimes it matters when ordering timestamps across a leap second boundary.

The pragmatic rule is: write UTC in new code, treat GMT as an alias, and know that if a scientific or navigation-adjacent customer asks which one you use, the correct answer is UTC with leap seconds.

The IANA Time Zone Database Is the Only Source of Truth

Every correct piece of timezone software on Earth is backed by the same data file: the IANA Time Zone Database, also known as the Olson database, tzdata, or zoneinfo. It is a plain text file maintained by volunteers and updated four to six times a year as governments change their minds about DST or draw new time zone boundaries.

The database does two things that no other data source does reliably. First, it gives every location on Earth a canonical name of the form Region/City: America/New_York, Europe/London, Asia/Tokyo, Australia/Sydney. These names are stable. "EST" and "PST" are not; they are ambiguous and change with DST. Second, it records every historical change a timezone has ever made, so that a timestamp from 1987 in Brazil is interpreted with the DST rules that were in force in 1987, not today's rules.

Recent updates that matter:

  • Mexico, October 2022. Most of Mexico abolished DST. The fix landed in tzdata 2022f. If your system uses a tzdata version older than that, Mexico City timestamps for 2022 and later will be off by an hour in the spring and fall.
  • Kazakhstan, March 2024. Kazakhstan merged its two time zones into a single UTC+5. Applications that still treat Almaty and Oral as different zones are now wrong.
  • Greenland, March 2023. Most of Greenland shifted from UTC-3 to UTC-2 and stopped observing DST, using tzdata 2023a.
  • Proposed US DST changes. The Sunshine Protection Act passed the Senate in 2022 but never passed the House. Nothing has actually changed in US DST rules as of April 2026. Do not pre-code for it.
  • Mexico DST for northern border, 2027 proposal. Northern Mexican states that kept DST to align with US trading hours may drop it if the US drops DST. Watch tzdata release notes.

Pro tip: Audit the tzdata version bundled with your runtime. In Node, check Intl.DateTimeFormat().resolvedOptions() against the current IANA release notes. In Python, zoneinfo uses your OS's tzdata by default, so a server that has not been patched in a year is running old rules. Container images built six months ago will silently get DST wrong for any country that changed rules since the image was built.

DST Transitions: The Twice-a-Year Bug Factory

Daylight saving time is where most timezone bugs actually ship. Two specific moments per year create an entire category of failure modes.

Spring forward: non-existent times

In most US locations, the clock jumps from 01:59:59 on the second Sunday of March directly to 03:00:00. The time 02:30 does not exist on that day. Ever. Not for one microsecond. If a user asks your system to schedule something for "March 9, 2025 at 02:30 America/New_York," you have three options:

  1. Throw an error and force the user to pick a real time.
  2. Silently shift to 03:30 (treat the skipped hour as elapsed).
  3. Silently shift to 01:30 (treat the ambiguous hour as the target).

Different libraries pick different defaults. Python's zoneinfo raises no error by default; it uses the "fold" attribute. JavaScript's Intl API just picks one. Java's ZonedDateTime has an explicit strategy parameter called ZoneRulesProvider. None of this is documented where you will actually look for it, so test it explicitly in your stack before assuming.

Fall back: ambiguous times

In November, 01:59:59 runs back to 01:00:00, and the hour from 01:00 to 02:00 happens twice. If a user says "schedule at 01:30 America/New_York on the first Sunday of November," which 01:30 do they mean? The first one (still in DST, UTC-4) or the second (back on standard time, UTC-5)? The two are a full hour apart.

import datetime
from zoneinfo import ZoneInfo

# Two different moments, same wall-clock time
ny = ZoneInfo("America/New_York")
first  = datetime.datetime(2024, 11, 3, 1, 30, tzinfo=ny, fold=0)
second = datetime.datetime(2024, 11, 3, 1, 30, tzinfo=ny, fold=1)

print(first.utcoffset())   # -4:00:00
print(second.utcoffset())  # -5:00:00
print((second - first).total_seconds())  # 3600.0

That fold parameter was added in Python 3.6 specifically to disambiguate these moments. Most developers have never used it. Most code silently picks fold=0 (the first occurrence) and moves on. If your business logic actually cares, you have to opt in.

Watch out: Scheduled jobs that fire at 02:30 local time will either skip in March (time does not exist) or double-fire in November (time happens twice), unless you explicitly pick a timezone that does not observe DST (UTC is the easy choice) or schedule at a time that does not overlap the transition window.

Store UTC, Display Local, No Exceptions

This is the one rule that prevents roughly 90 percent of the timezone bugs I see in code review. Store every timestamp in your database as UTC. Convert to local time only at the moment you display it. Never store a local timestamp without its timezone attached, and never store "the user's current timezone" mixed in with wall-clock times.

There is exactly one exception I accept to this rule. If you are storing a future scheduled event in a specific location ("meeting at 3 PM in the Berlin office on July 14"), store the wall clock time and the IANA zone, not the UTC equivalent. This is because the UTC offset for that wall clock moment might change between now and then. Germany could join the US in abolishing DST. Berlin's rules could shift. By storing the local time and zone, the computed UTC always reflects the latest tzdata at read time.

Kind of timestampWhat to storeWhy
Something that already happenedUTC instantIt is a fixed moment; timezone rules cannot change the past
Future event in a specific placeWall clock time plus IANA zone nameLocal rules may shift before the event happens
Future event in UTC (server cron)UTC instantNo local rules to worry about
Date without time (birthday, deadline date)ISO date string onlyAttaching any time gives it a timezone, which you do not want
Duration between eventsSeconds or milliseconds as an integerAvoids wall-clock arithmetic entirely

Column types matter. PostgreSQL's timestamp with time zone internally stores UTC microseconds; the "with time zone" name is misleading but the storage is correct. MySQL's TIMESTAMP stores UTC seconds but is limited to 1970 through 2038. MySQL's DATETIME is a wall-clock type with no timezone awareness; avoid it for events.

Framework Gotchas: JavaScript, Python, and Java

Every language handles timezones a little differently, and the differences are where most of my bugs have come from. Here is the honest short version for the three I use most.

JavaScript Date

The Date object is a wrapper around a UTC timestamp but exposes getters that silently use the browser's or server's local time. d.getHours() returns local hours. d.getUTCHours() returns UTC hours. This is the source of endless confusion.

const d = new Date('2025-06-15T12:00:00Z');

// On a server in America/New_York:
d.getHours();     // 8  (local)
d.getUTCHours();  // 12 (UTC)

// Format correctly regardless of server timezone:
d.toLocaleString('en-US', {
  timeZone: 'America/New_York',
  dateStyle: 'full',
  timeStyle: 'short'
});

For anything non-trivial, reach for Luxon, date-fns-tz, or wait for Temporal. The TC39 Temporal proposal reached Stage 3 in 2021 and is shipping progressively in browsers; it has proper Temporal.ZonedDateTime and Temporal.PlainDateTime types that separate the concepts cleanly. Moment.js is in maintenance mode as of 2020 and its maintainers explicitly recommend against using it for new projects.

Python datetime

Python's standard datetime has two flavors: naive (no timezone attached) and aware (timezone attached). Mixing them raises TypeError. Comparing two naive datetimes assumes they are in the same timezone, which is where bugs slip in. Since Python 3.9, the built-in zoneinfo module uses the system's IANA database.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

# Always make datetimes aware
now_utc = datetime.now(timezone.utc)

# Convert to a user's local zone for display
user_zone = ZoneInfo("Asia/Tokyo")
print(now_utc.astimezone(user_zone).isoformat())

# Never do this:
# bad = datetime.now()  # naive, interpretation depends on context

Pre-3.9 code pins pytz, which has a famously counterintuitive API (you cannot pass a pytz timezone to the datetime constructor directly; you have to use .localize()). If you are still on pytz, migrate to zoneinfo as soon as you can.

Java java.time

Java 8 (2014) introduced java.time, which is genuinely well-designed: Instant for UTC points in time, LocalDateTime for wall clock without a zone, ZonedDateTime for wall clock plus zone, OffsetDateTime for wall clock plus fixed offset. Pick the right type for the concept and the compiler will catch many of the mistakes for you. The legacy java.util.Date and Calendar classes from 1995 should be treated as deprecated in any new code.

import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;

Instant now = Instant.now();
ZonedDateTime inTokyo = now.atZone(ZoneId.of("Asia/Tokyo"));
System.out.println(inTokyo);  // 2026-04-23T21:30:00+09:00[Asia/Tokyo]

Real Timezone Bug Stories from Production

Enough theory. Here are four incidents, all from real postmortems, that capture the specific shapes that timezone bugs take when they reach production.

The Samoa day that did not exist (December 30, 2011)

Samoa moved from UTC-11 to UTC+13 by skipping December 30, 2011 entirely. The clock went from 23:59:59 on Thursday December 29 straight to 00:00:00 on Saturday December 31. Every airline booking system, payroll system, and SLA monitor with a naive "day after day" loop broke that weekend. The fix was to upgrade tzdata and reload.

The Twitter outage from a leap year birthday (2020)

Twitter's Hack programming language had a date-validation routine that stored years as Hack integers and computed "next year's birthday" by just adding 1 to the year. For users born on February 29, "next year" landed on February 29 of a non-leap year, which the validator rejected as an invalid date, which crashed the surrounding request. This is not strictly a timezone bug, but it is a cousin: calendar arithmetic assumptions that work 364 days out of 365.

GitHub's stars count "time travel" (2019)

GitHub displayed "starred X seconds ago" using client-side JavaScript that subtracted server UTC from browser local, forgetting that the browser's local was ahead of UTC for half the world. Events showed negative durations until they were more than 12 hours old. The fix was to always compare UTC to UTC and only localize the final display string.

My own cron that skipped March

The story from the intro. The server was Ubuntu with a system timezone of America/New_York because nobody thought to set it to UTC. The application's cron library interpreted schedule expressions in the system timezone. Every March, 02:30 did not exist, so the scheduler silently dropped that run. Every November, 02:30 existed twice, so the scheduler silently ran it twice, which then caused downstream idempotency violations. The fix was changing the server's timezone to UTC and scheduling everything against that. Two years later the same codebase is boring again.

A Practical Checklist for New Code

Every time I pick up a codebase that touches dates, I run through this checklist. It catches most of the bugs before they ship.

  • Set the process timezone to UTC. TZ=UTC in the environment, not left to whatever the host defaults to.
  • Store every instant as UTC in the database. Use a timezone-aware column type.
  • Store future events in a specific location as wall clock + IANA zone, not as a precomputed UTC instant.
  • Never concatenate a wall clock time with a local timezone assumption in logs. Always log UTC (or at least ISO 8601 with offset).
  • When displaying to users, use their preferred IANA zone, not the browser's offset. The browser offset changes with DST; the zone name does not.
  • Pin your tzdata version in reproducible builds but update it at least quarterly. Container images from last year are running old rules today.
  • Avoid scheduling anything at 02:30 local time in countries that observe DST. 03:30 or 01:00 is fine; 02:30 is the one hour that can go missing or happen twice.
  • Test the DST transition days explicitly. Set your test clock to the Saturday before the spring-forward Sunday and watch how your code behaves across midnight and through the transition.
  • In user-facing APIs that accept a datetime, require either an explicit Z suffix or an offset field. Rejecting ambiguous inputs at the boundary is cheaper than debugging them in prod.

The StackConvert Time Zone Converter

When you are debugging a timestamp in a log file or deciding what time to send a calendar invite across continents, the StackConvert timezone converter handles the conversion in your browser with IANA zone names. You pick the source and target zones, paste or type the moment, and it shows the equivalent in the destination along with the UTC value. Nothing is sent to a server, which matters when the timestamp you are debugging is attached to a customer record or a production incident report.

For related conceptual background, the Unix timestamps deep dive covers the representation side of time (epoch, integer precision, leap seconds), and the time zone converter walkthrough goes through the tool step by step for non-developer audiences.

Frequently Asked Questions

Is UTC ahead of or behind my local time?

It depends on where you are. UTC is the reference; every other zone is expressed as a positive or negative offset from it. New York is UTC-5 in winter and UTC-4 in summer, so UTC is 4 or 5 hours ahead of New York. Tokyo is UTC+9 year-round, so UTC is 9 hours behind Tokyo. A quick rule of thumb: west of Greenwich, UTC is ahead; east of Greenwich, UTC is behind.

Why is my JavaScript Date showing different values on different machines?

Because Date methods like getHours(), toString(), and the default format of toLocaleString() use the machine's local timezone. The underlying instant is the same everywhere. Fix the display by passing a timeZone option to toLocaleString, or by using getUTC* methods when you want UTC.

Should I use abbreviations like EST, PST, or IST?

No. Almost every timezone abbreviation is ambiguous. EST might mean Eastern Standard Time (UTC-5) in the US or Eastern Standard Time (UTC+10) in Australia. IST could be Indian Standard Time, Irish Standard Time, or Israel Standard Time, and the three are in three different offsets. Use IANA names like America/New_York or Asia/Kolkata. They are unambiguous and handle DST automatically.

How often does the IANA timezone database change?

The database is updated four to six times a year, with more updates in spring and fall when DST rules shift. Releases are named after the year and a letter: 2024a, 2024b, up to 2024h or so. You can check the current version on the IANA website or in your OS's tzdata package. Critical fixes are pushed within days of a government announcement.

What is the difference between UTC+5 and Asia/Karachi?

UTC+5 is a fixed offset. It never changes. Asia/Karachi is a timezone with a rule set that currently says "always UTC+5," but that rule set can change: Pakistan has flirted with DST a few times in the past two decades. Using the IANA name future-proofs you against rule changes; using the raw offset locks you to today's rules forever.

Can I just use offsets instead of timezone names?

For logging and sorting, yes. An ISO 8601 timestamp like 2026-04-23T10:30:00+05:00 is unambiguous and needs no database to interpret. For storing the rules of a timezone (DST transitions, historical changes), no. The offset at a given moment is derived from the zone name, not the other way around. Store the IANA name and compute the offset when you need it.

Does UTC have a summer time or winter time?

No. UTC never observes DST. That is one of its main advantages. It is the one reference frame that is continuous and predictable all year. Every other zone's offset to UTC may change twice a year, but UTC itself does not change.

How do I handle users who have their device clock set wrong?

Design around it. Server-issued timestamps (database insert time, API response time) should come from the server. Client-generated timestamps (form submission time) should be treated as display-only unless you have a specific reason to trust them. If you need accurate client time for auditing, capture the offset between the server's Date header and the client's perceived time at session start, then adjust every client timestamp by that delta.