Skip to content
Timezone Handling for Backend Developers — Store UTC, Render Local

Dates & Time

Timezone Handling for Backend Developers — Store UTC, Render Local

Timezones are where junior-senior experience shows up. The full discipline: store UTC, render local, handle DST, and the database column types that prevent silent corruption.

Timezone handling is one of the clearest dividers between junior and senior backend engineers. The rule is short, the discipline is long, and the edge cases are real. This guide is the full picture, organized around one sentence: store UTC, render local.

The one rule

Store timestamps as UTC in the database. Render them in the user’s local timezone at the last possible moment — typically in the UI, or in the API response if the UI does not do formatting.

That is the rule. Everything else is nuance.

Why UTC specifically: it has no daylight saving, no political changes (ignoring the leap-second pedantry — see the Unix timestamps guide), and every timezone in the world has a well-defined offset from it. If you store UTC, converting to any other zone is a one-line operation. If you store Europe/Madrid local time, converting to another zone requires you to also know the exact daylight-saving rules in effect at that instant, which change.

For interactive zone conversion while debugging, the timezone converter is the quickest way to sanity-check offsets.

The database column types

Picking the wrong column type is where this discipline breaks down. Here are the options in the three major OLTP databases.

DatabaseBest columnStoresNotes
PostgrestimestamptzUTC instantAccepts any timezone on write, normalizes to UTC; renders in session TZ on read
MySQL (InnoDB)TIMESTAMPUTC instant (with session TZ conversion)Limited to 1970-2038 by default; DATETIME stores wall clock without zone
SQL ServerdatetimeoffsetUTC instant + offsetPreserves original offset; use datetime2 if you handle conversion yourself
SQLiteTEXT (ISO 8601) or INTEGER (Unix seconds)No native type; discipline yourselfstrftime('%Y-%m-%dT%H:%M:%fZ', 'now') is the idiomatic UTC string
MongoDBDate (BSON)UTC millisecondsNo timezone concept in the type

In Postgres specifically: always use timestamptz, never timestamp. The name timestamp (without tz) is a deceptive friend — it stores a wall clock with no zone information and gives you no conversion help. timestamptz stores UTC internally and handles the conversion on read and write. The “tz” part does not mean the column stores a timezone; it means it is zone-aware.

MySQL’s TIMESTAMP converts between session TZ and UTC automatically, which is what you want. But it is limited to 1970-2038 on most installations (Year 2038 problem), so for dates outside that range use DATETIME and handle conversion in the application.

Never store local time

The antipattern: storing 2026-04-17 14:30:00 with no zone information, assuming the reader will “know” it is Europe/Madrid.

Three reasons this fails:

  1. Servers move. Data gets replicated to a machine in another region. Code refactored to run in a Kubernetes cluster defaults to UTC. Your timestamps silently shift by 1-2 hours.
  2. DST makes “local time” ambiguous twice a year. The hour from 02:00 to 03:00 on the last Sunday of March in Europe/Madrid does not exist (spring forward). The hour from 02:00 to 03:00 on the last Sunday of October happens twice (fall back). A “local timestamp” during those transitions is not well-defined.
  3. Users are in multiple zones. A database that stores “local” time cannot answer “show me all events across all users in the last hour” without reparsing every row.

Store UTC. Always.

The DST edge cases

Daylight Saving Time transitions cause three specific bugs you need to handle:

Spring forward — nonexistent times. 2026-03-29 02:30 in Europe/Madrid does not exist. The clock jumps from 01:59:59 to 03:00:00. If a user tries to schedule a reminder for 02:30 on that date, you have to pick: round up to 03:00, round down to 01:30, or reject. Most scheduling libraries round up.

Fall back — ambiguous times. 2026-10-25 02:30 in Europe/Madrid happens twice (once at UTC+2, then again at UTC+1). If a user stores a local time without disambiguation, you do not know which instant they meant. Pick a convention (usually “the first occurrence”) and apply it consistently.

Fixed-time recurring events. “Every day at 09:00 local time” means the UTC time shifts by one hour twice a year. Do not store this as a UTC time — store it as (hour, minute, zone) and compute the next UTC occurrence on demand.

from zoneinfo import ZoneInfo
from datetime import datetime

# Correct: local wall-clock → UTC instant
tz = ZoneInfo("Europe/Madrid")
local = datetime(2026, 4, 17, 14, 30, tzinfo=tz)
utc = local.astimezone(ZoneInfo("UTC"))
# 2026-04-17 12:30:00+00:00

# Correct: UTC instant → local wall-clock for display
now_utc = datetime.now(ZoneInfo("UTC"))
now_madrid = now_utc.astimezone(tz)

IANA zone names, not offsets

Use IANA zone identifiers like Europe/Madrid, America/New_York, Asia/Tokyo. Not UTC+2 or CEST or EST.

  • Abbreviations like CST are ambiguous (Central Standard Time US, China Standard Time, Cuba Standard Time — all different).
  • Fixed offsets like UTC+2 do not handle DST. Europe/Madrid is UTC+1 in winter and UTC+2 in summer. Storing UTC+2 on a record erases that it should follow Madrid’s transitions.
  • IANA names have a living database (tzdata) that tracks political changes — countries adopt or drop DST, shift their offset, etc. Your OS and language runtime update this regularly.

The authoritative list is in tzdata, shipped with every Unix system at /usr/share/zoneinfo/. Python’s zoneinfo, Java’s ZoneId, and modern JavaScript Intl.DateTimeFormat all use it.

The API contract

When you ship timestamps over an API, two reasonable formats:

  • ISO 8601 string with offset: 2026-04-17T14:30:00Z or 2026-04-17T16:30:00+02:00. Human-readable, unambiguous, consumed by every client library.
  • Unix milliseconds integer: 1776732600000. Compact, unambiguous, no parsing required. Document the unit in the field name (created_at_ms).

Do not ship “local time without zone”. Do not ship time-zone-abbreviated strings (2026-04-17 14:30 CEST).

If your API needs to express a “floating” time (a time that should remain attached to a specific wall clock regardless of zone — like a recurring event), represent it as separate fields: hour: 9, minute: 0, zone: "Europe/Madrid".

{
  "event_id": "evt_123",
  "starts_at": "2026-04-17T14:00:00Z",
  "recurring": {
    "rule": "FREQ=DAILY",
    "local_time": "09:00",
    "zone": "Europe/Madrid"
  }
}

The testing discipline

Two cheap tests that catch most bugs:

  1. Run your test suite in a non-UTC timezone. CI runs on UTC, which hides many bugs. Add a test job that runs in America/Los_Angeles or Asia/Tokyo. Bugs that depend on timezone will surface.
  2. Test across a DST transition. The usual offenders: scheduling, duration calculations, “start of day”. For libraries and cron-like schedulers, add explicit tests for the spring-forward nonexistent hour and the fall-back ambiguous hour.

Takeaways

Store UTC. Render local. Use timestamptz in Postgres, TIMESTAMP in MySQL, Unix milliseconds or ISO 8601 Z strings at the API boundary. IANA zone names, not abbreviations. Plan for DST edge cases. For the scalar-math view of time, see the Unix timestamps guide; for the specific cron syntax that schedules these timestamps, see the cron syntax guide.

Related tools

By ·