Time Zones in Software: Best Practices for Global Apps
Store UTC, display local — and other rules that prevent scheduling disasters.
Why Time Zones Break Software
Time zones are the source of more production bugs than almost any other concept in software. The reason is not that the math is hard — converting between UTC and a local zone is a single function call — but that the rules change. Daylight Saving Time starts and stops on different dates in different jurisdictions; governments occasionally abolish DST or change their offset entirely (Samoa jumped the International Date Line in 2011, dropping December 30 entirely); and the IANA time zone database that tracks all of this publishes multiple updates per year as jurisdictions announce changes.
Compounding the rules churn is a naming problem. "EST" can mean Eastern Standard Time (UTC-5) in the United States, Eastern Standard Time in Australia (UTC+10), or nothing at all depending on the season. Numeric offsets are not zones: New York is UTC-5 in winter and UTC-4 in summer, so storing "UTC-5" loses the information needed to render future times correctly. The IANA naming convention (such as America/New_York) solves both problems by identifying a region with a stable set of rules that the database can update independently of your application code.
Rule 1: Store UTC
The single most important rule in time-zone handling is: store every timestamp in UTC. UTC has no DST, no offset changes, no ambiguity about what a given integer means. A timestamp stored as "2025-03-15T14:30:00Z" is the same instant everywhere on Earth; a timestamp stored as "2025-03-15T14:30:00" (no offset) is ambiguous, and one stored as "2025-03-15T14:30:00-05:00" can be misinterpreted if the offset is stripped or the column is later read by code that does not understand offsets.
ISO 8601 is the standard date and time representation format. A full ISO 8601 timestamp includes the offset or a Z suffix for UTC: 2025-03-15T14:30:00Z or 2025-03-15T09:30:00-05:00. The UTC form is preferred for storage because it is the canonical representation; the offset form is preferred for display because it preserves the user's local context. The bare form 2025-03-15T14:30:00 is sometimes called "local time without offset" and should be avoided except in specific cases like a daily alarm clock that intentionally fires at the same wall-clock time regardless of time zone.
In databases, use a timestamp-with-time-zone column type. PostgreSQL's TIMESTAMPTZ stores the value as UTC internally and converts to the session's time zone on read. MySQL's TIMESTAMP does the same (with caveats about the 2038 limit on 32-bit systems). Avoid DATETIME in MySQL and TIMESTAMP WITHOUT TIME ZONE in PostgreSQL for any timestamp that represents a specific instant; these types store the wall-clock time without offset information and produce incorrect results across DST transitions.
Rule 2: Display Local Time
UTC is for storage; local time is for display. When rendering a timestamp to a user, convert from UTC to the user's local time zone using the IANA database (via the language's standard library — Intl.DateTimeFormat in JavaScript, ZoneId in Java, pytz or zoneinfo in Python, chrono-tz in Rust). The conversion uses the IANA database to look up the offset that was in effect at that instant in that zone, including any DST transition that may have applied.
Detecting the user's time zone in the browser is messier than it should be. The most reliable signal is Intl.DateTimeFormat().resolvedOptions().timeZone, which returns the IANA zone name the browser has inferred from the operating system. This is not always correct — a user traveling with a laptop has a laptop time zone, not their home zone — but it is correct often enough to be the default. Allow the user to override it in their account settings, and persist the override server-side.
Rule 3: Schedule Future Events in the User's Zone
The tricky case is recurring events: "every Monday at 9am" or "the first of every month at noon." These are wall-clock times in a specific time zone, not instants in UTC. A user in New York who schedules a 9am Monday meeting is scheduling 9am Eastern — which is 14:00 UTC during standard time and 13:00 UTC during daylight saving time. If you store the next occurrence as a UTC timestamp, you must recompute it whenever the DST status of the source zone changes, which happens twice a year for most zones.
The standard pattern is to store the recurrence rule and the source time zone separately, and compute the next UTC occurrence on demand. The iCalendar specification (RFC 5545) defines the standard format for recurrence rules; libraries like rrule in Python and JavaScript compute the next occurrence from the rule and the source zone. A meeting rule might be stored as "weekly on Monday at 09:00 in America/New_York," and the next meeting's UTC timestamp is computed at render time, refreshed daily or whenever the IANA database is updated.
Rule 4: Beware of DST Transitions
Twice a year, in most temperate-zone jurisdictions, the local clock jumps forward by an hour (spring forward) or back by an hour (fall back). Both transitions produce edge cases. The spring-forward transition creates a gap: in New York on the second Sunday of March, the wall clock jumps from 01:59:59 directly to 03:00:00, and the hour from 02:00 to 03:00 never exists. A meeting scheduled for "2:30am on March 9" is ambiguous; whether the application interprets it as 02:30 EST (before the transition) or 03:30 EDT (after) depends on the implementation, and users will be confused either way.
The fall-back transition creates an overlap: the wall clock jumps from 01:59:59 EDT back to 01:00:00 EST, and the hour from 01:00 to 02:00 happens twice. A timestamp like "2025-11-02T01:30:00" without an offset or zone is ambiguous; it could refer to either the EDT occurrence or the EST occurrence. The fix is to always include the offset or zone, and to use a library that resolves the ambiguity according to a defined policy — typically "earliest" or "latest."
Common Bugs and Their Fixes
- Storing local time without offset. The database column reads "2025-03-15T14:30:00" with no zone information; six months later, nobody knows whether that was 14:30 in New York, London, or Tokyo. Fix: store UTC explicitly, with a
Zsuffix or a timestamptz column. - Trusting the server's local zone. Code that calls
new Date()in JavaScript ordatetime.now()in Python returns the server's local time, which is whatever the operations team configured. Containers frequently default to UTC, but a misconfigured host can produce timestamps in any zone. Fix: always usenew Date().toISOString()ordatetime.now(timezone.utc)to get UTC explicitly. - Computing durations across DST. Subtracting two UTC timestamps gives a correct duration. Subtracting two local-time timestamps gives a wrong duration whenever a DST transition falls between them — typically off by an hour. Fix: convert to UTC before subtracting, or use a library that handles the conversion.
- Using numeric offsets instead of zone names. Storing "UTC-5" for a New York timestamp loses the DST information. Six months later, when New York is on UTC-4, the stored offset is wrong. Fix: store the IANA zone name (
America/New_York) and compute the offset at render time. - Hard-coding holiday or DST dates. DST rules change; the U.S. Energy Policy Act of 2005 moved the spring-forward date from the first Sunday of April to the second Sunday of March. Code that hard-coded "April 1" broke in 2007 and stayed broken until someone noticed. Fix: use the IANA database through a maintained library; never hard-code.
- Assuming every day has 24 hours. Days with DST transitions have 23 or 25 hours. Code that adds 86400 seconds to compute "tomorrow at the same time" drifts on those days. Fix: use the language's date library, which knows about the calendar.
Recommended Libraries
For JavaScript, the built-in Date object is famously difficult to use correctly; the modern alternatives are Luxon (a wrapper around Intl with a clean API) and date-fns with the date-fns-tz extension for time zone support. For Python, the standard library's datetime with zoneinfo (Python 3.9+) is sufficient; the older pytz library is still common but is being phased out. For Java, java.time (JSR 310) is the modern API, replacing the legacy java.util.Date and java.util.Calendar. For Go, the standard library's time package handles zones correctly. For Rust, chrono with chrono-tz is the standard choice.
Conclusion
Time-zone bugs are produced by treating time as a simple number rather than a context-dependent value. The fix is a small set of consistent rules: store UTC, display local, schedule future events in the source zone, and convert to UTC before computing durations. Use the IANA time zone database through a maintained library; never hard-code offsets, DST dates, or holiday lists. Include the offset or zone in every serialized timestamp, and use ISO 8601 as the wire format. The rules are not difficult to follow once they are habits; the difficulty is that they must be habits, applied uniformly across every code path that touches a timestamp, because the bugs only appear on DST transitions and once-a-year edge cases that no test suite will catch without deliberate coverage. Written by the HT99 Tools Editorial Team.
Try the Tool This Article Explains
Put what you've learned into practice with our free, accurate calculators.