← Back to Blog

UTC In the Database Was the Easy Part

Storing UTC is the easy part. Display, recurring events, DST, date-only fields, and scheduled jobs: the real bugs live in the conversion layer, not the storage.

The first time zone bug I shipped to production was the day after we launched a "remind me at 9am" feature. A user in Sydney told us their reminder for Monday's 9am meeting fired Sunday at 6pm. We were storing everything in UTC. We were converting to local time on display. We had not thought hard enough about what "their 9am" actually meant.

The conventional wisdom about time zones is: store everything in UTC, convert on display, you are done. This is correct as far as it goes. It is also where most engineers stop thinking, which is why time zone bugs keep shipping. UTC in the database is the easy part. The hard parts are everywhere else.

UTC Is Not the Hard Part

Pick a database column type that stores timezone-aware timestamps. In PostgreSQL, that is timestamptz. Pick a serialization format that preserves the offset (ISO 8601 with offset, or RFC 3339). Always store in UTC, always serialize with the offset attached. On the database side, time zones are genuinely a solved problem.

Bugs live in the conversion layer between the database and the user, and in the question of what your application actually means when it stores a moment versus a calendar concept. Those are different things, and treating them the same is the source of most of the bugs that ship.

Display Is the First Hard Part

Showing a UTC timestamp to a user requires knowing the user's time zone. The browser knows. Your iOS or Android client knows. Your backend often does not, unless you ask the user or store it on their profile.

The first failure mode is showing UTC to the user directly. They see "14:30 UTC" on a meeting invite and have to do math. Pacific time. Subtract eight hours. Forget that DST changes the offset. Show up an hour late.

A second failure mode is converting on the wrong side. Server-side rendering of a date in "America/Los_Angeles" from a server running in Frankfurt looks fine until daylight saving switches in the US (a different week than in the EU) and your timestamps are wrong for two weeks every year because the offset is hardcoded somewhere.

Send timestamps to the client with their offset attached, and let the client format them in the user's local time zone. Browsers know about DST. Your server probably does too if you use a real time zone library, but pushing the conversion to the client is one less centralized thing to maintain.

Recurring Events Are the Real Hard Part

This is the bug that taught me to take time zones seriously. The "every Monday at 9am" meeting.

If you store a recurring event as a UTC timestamp and a recurrence rule, you are in trouble. The 9am meeting in Sydney is at 23:00 UTC during standard time and 22:00 UTC during daylight saving. The same UTC time means a different local time at different points in the year. Storing 9am Sydney as 23:00 UTC and recurring weekly means the meeting drifts to 8am or 10am every six months.

The fix is to store the user's intended local time and the IANA time zone identifier, not just a UTC timestamp. Save the pair ("2026-03-15 09:00", "Australia/Sydney") and compute the actual UTC moment whenever you need to display or notify. Wall-clock time stays at 9am. Conversion accounts for whatever DST rules apply on that specific date.

Not trivial. Resolving ("2026-03-15 09:00", "Australia/Sydney") to a UTC instant requires a time zone library that keeps its IANA database current. Time zone rules change. Russia changed its DST rules. Brazil abolished DST. Samoa changed sides of the international date line. Your time zone library needs updates, and you need to deploy them.

Storing recurrence as a UTC timestamp plus a rule is correct only if the recurrence is genuinely in UTC, which is rare. Almost everything users care about is in their local time. Build for that.

Date-Only Fields Are a Trap

A user's birthday is not a moment in time. It is a calendar concept. February 19, 1985 has no time zone attached, because birthdays do not happen at a specific UTC instant.

The trap is storing date-only values as timestamps. You store the birthday as "1985-02-19 00:00:00 UTC" because it is convenient. The user accesses your service from Sydney, the timestamp is converted to their local time zone, and now their birthday displays as February 18 because midnight UTC is 11am the previous day in Sydney.

Use date-only types for date-only concepts. PostgreSQL has DATE. Most languages have a date-only type. Use it. Do not use a timestamp where a date is what you mean. Birthdays, anniversaries, contract dates, holidays, tax deadlines: all of these are dates, not moments.

The reverse mistake is storing a moment as a date. "When did the user log in?" is a moment. If you only store the date, you have lost the actual time, and "logged in today" versus "logged in yesterday" depends on the time zone you compute it in. Two engineers running the same query against the same data will get different counts depending on what their session time zone is set to.

DST Will Find You

Daylight saving time is the bug factory inside time zones. Twice a year, in most jurisdictions, an hour disappears or repeats.

The repeating hour is the worse one. In the US fall transition, 1:30 AM happens twice. If your code stores a moment as a wall-clock string ("2026-11-01 01:30:00") without time zone information, you cannot tell which 1:30 AM it was. Two events at the same nominal time are actually an hour apart. Your sorting is wrong. Your duration calculations are wrong. You will not catch this in unit tests unless you specifically test the transition date.

The disappearing hour is similar. In the US spring transition, 2:30 AM does not exist. If a user schedules an event at 2:30 AM on the day of the transition, what happens? Most libraries do something reasonable (round up to 3:00, round down to 1:30, throw an exception). You should know which one yours does. You should test it.

These are not exotic bugs. They happen every year, twice. They affect anyone scheduling things near transition times. They are easy to forget about until you ship a feature that schedules events near 2 AM and a user reports something strange the second weekend in March.

Scheduled Jobs and the Server Clock

If you have a cron job that runs "at 3 AM," which 3 AM does it run at? If your server is configured for UTC and your business runs in New York, the job runs at 11 PM Eastern in winter and 10 PM Eastern in summer. Did you mean for that to drift?

The right answer is to be explicit. If the job needs to run at 3 AM local time, configure the scheduler with the time zone. cron itself does not support this directly; you need a wrapper, an environment variable, or a different scheduler that handles time zones (Quartz on the JVM, APScheduler in Python, Kubernetes CronJobs with the timeZone field).

If the timing is irrelevant to local time (a backup that just needs to happen sometime overnight), pick UTC and document it. Do not mix server-local time and time zone math in the same scheduling system. You will hit a DST boundary and the job will run twice or zero times, and the bug report will arrive six months later from someone trying to reconcile the missing data.

What to Actually Do

A short checklist that will catch most of the bugs:

  • Store timestamps as UTC with timezone-aware types (timestamptz in PostgreSQL).
  • Store date-only concepts as DATE, not as timestamps.
  • Store recurring events as wall-clock time plus IANA time zone, not as UTC timestamps.
  • Convert to user-local time at the display layer, using a real time zone library.
  • Run scheduled jobs with explicit time zones, not whatever the server happens to be set to.
  • Test DST transitions at least once. Pick a fall and a spring transition for a relevant time zone and step through the code.
  • Keep your IANA time zone database current. Update it when you patch dependencies.

UTC is the easy part. Everything else is where the bugs live, and most of them only show up months after launch when a user in Sydney or Tokyo or São Paulo notices the math is off. Time zone bugs do not get caught under load. They get caught under calendar.

Share
X LinkedIn HN
UI

Umur Inan

Principal Software Engineer

Backend engineer focused on JVM systems, distributed architecture, and the failure modes that only show up in production. I write about what I learn building and breaking things at scale.

👁 0 6 min read

Comments (0)