Skip to content
architecture backend database developer-experience 14 min read

Timezones: The Bug That Ships on Every Project

Rachid Al Maach ·

You will ship a timezone bug. It will pass code review. It will pass your tests. It will work perfectly on your machine. And then a user in a different timezone will file a ticket that makes no sense until you stare at it for two hours and realize their "today" is not your "today."

This is not a beginner mistake. Seasoned developers who have shipped production systems for years keep getting burned by timezones. Not because they are careless, but because timezone bugs are uniquely good at hiding.

Why timezone bugs survive

Most bugs announce themselves. A null pointer crashes immediately. A missing import fails on startup. A broken query returns zero results.

Timezone bugs are different. They return almost correct results. An order shows up on the wrong day. A report is off by one. A scheduled payment fires early. The data looks plausible enough that nobody questions it. Until the numbers stop adding up weeks later.

The second problem: your entire development team is probably in the same timezone. Your laptop, your CI server, your staging database. When every clock agrees, timezone bugs are invisible. The code isn't wrong in your timezone. It's wrong in someone else's.

The bugs that burn you

Bug 1: The midnight boundary

This is the most common timezone bug in production. A user performs an action near midnight, and the system records it on the wrong date.

# A user in Amsterdam places an order at 23:30 CET (March 15)
# Your server runs in UTC. It's already March 16 there.

order = Order.create(
    user_id=user.id,
    placed_at=datetime.now(),  # UTC: 2026-03-16 22:30:00
)

# The daily sales report for March 15?
# This order doesn't show up. It "happened" on March 16.
# The user sees "March 15" in their UI. Finance sees "March 16."
# Nobody notices until the monthly reconciliation fails.

The root cause is datetime.now() without a timezone. It returns whatever the server clock says. If your server is in UTC and your user is in CET, every action between 00:00 and 01:00 CET lands on "tomorrow" in your database. Your sales reports, your analytics, your invoicing, all slightly wrong. Always in the same direction. Nobody notices because the totals are close enough.

Bug 2: The early payment

Date-only fields are a trap. When a user picks "March 15" on a calendar widget, they mean March 15 in their timezone. But a DATE column has no timezone. Your cron job interprets it in UTC. For anyone west of Greenwich, their "March 15" arrives early.

# User schedules a payment for "March 15"
# They mean March 15 in their timezone: America/New_York

payment = ScheduledPayment.create(
    amount=499.00,
    scheduled_date=date(2026, 3, 15),  # But whose March 15?
)

# Cron job runs at midnight UTC.
# For the New York user, it's still 7 PM on March 14.
# Their payment fires a day early.
# They get charged before payday. Card declines. Customer gone.

This is the bug that costs real money. A payment processor that charges a day early because the scheduled date was interpreted in the wrong timezone. An e-commerce platform that starts a flash sale at midnight UTC instead of midnight in the customer's timezone. A subscription that renews before the billing cycle ends.

Bug 3: The test that lies

Timezone bugs don't just survive testing. Tests actively hide them.

# This test passes on your machine in Amsterdam
def test_order_shows_correct_date():
    order = create_order(placed_at=datetime(2026, 3, 15, 23, 30))
    assert order.display_date == "March 15, 2026"  # ✓ Passes

# Same test, CI server in us-east-1 (UTC-5 in winter, UTC-4 in summer)
# Also passes. Different reason.

# Production server in UTC:
# order.placed_at = 2026-03-15 23:30:00 UTC
# User in CET sees: "March 16, 2026" (CET = UTC+1)
# The test never caught this because it never ran cross-timezone.

The test passes because the test environment's timezone matches the developer's assumption. CI passes because it runs in the same region. Nobody writes a test that says "create an order at 23:30 CET and verify it shows as March 15 for a CET user and March 16 for a UTC user." That test doesn't exist because the developer didn't think about it. They wrote the code, it worked on their machine, the test confirmed it. Case closed.

Bug 4: Daylight Saving Time

If the midnight boundary is the most common timezone bug, DST is the most vicious. It happens twice a year, it affects different countries on different dates, and it breaks one of the most intuitive assumptions in programming: that a day has 24 hours.

# Daylight Saving Time: the silent destroyer

from datetime import datetime, timedelta

# Amsterdam, last Sunday of March 2026: clocks jump from 02:00 to 03:00
# You schedule something "24 hours from now"

start = datetime(2026, 3, 29, 10, 0)  # 10:00 CET (UTC+1)
next_day = start + timedelta(hours=24)  # 10:00 the next day... right?

# Wrong. On March 29, Amsterdam switches to CEST (UTC+2).
# 24 hours later in UTC is correct, but in local time
# the user sees 11:00 instead of 10:00.
# Your "daily 10 AM reminder" now fires at 11 AM.
# Or worse: the reverse in autumn. It fires twice.

DST breaks recurring events, scheduled tasks, time-interval calculations, and anything that adds or subtracts hours to shift between days. The spring transition creates a 23-hour day. The autumn transition creates a 25-hour day. If your code assumes 24, it drifts.

The worst part: DST bugs only manifest twice a year, on specific dates, for specific timezones. You can run your app for six months without seeing one. Then one Sunday in March, your daily report runs twice or not at all, and you spend the morning figuring out why.

The fix is not a library

Every timezone article eventually recommends a library. Use pytz. Use date-fns-tz. Use ZoneInfo. And yes, you should use proper timezone libraries. But the library only helps if you know where to apply it.

The real fix is a set of rules that your entire team follows. Rules that are documented, enforced, and tested. Here they are.

Rule 1: Store UTC. Always.

Every timestamp in your database should be in UTC. No exceptions. Use TIMESTAMPTZ in PostgreSQL, not TIMESTAMP.

-- WRONG: storing local time without timezone
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    placed_at TIMESTAMP NOT NULL  -- No timezone. Which timezone is this?
);

-- RIGHT: always store UTC with timezone type
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    placed_at TIMESTAMPTZ NOT NULL  -- PostgreSQL stores as UTC internally
);

-- When inserting: always send UTC
INSERT INTO orders (placed_at) VALUES ('2026-03-15T22:30:00Z');

-- When querying for display: convert at query time
SELECT placed_at AT TIME ZONE 'Europe/Amsterdam' AS local_time
FROM orders
WHERE id = 1;

PostgreSQL's TIMESTAMPTZ stores the value in UTC internally and converts on output based on the session's timezone. This means you get correct conversions without any application logic. The database handles it.

The key insight: TIMESTAMP (without timezone) doesn't mean "the timezone doesn't matter." It means "the timezone is unknown." That's worse. An unknown timezone is a bug waiting for the right user to trigger it.

Rule 2: Naive datetimes are bugs

In Python, a datetime without timezone info is called "naive." It has no idea what timezone it represents. It could be UTC, local time, the user's timezone, or the timezone of the server that created it. You can't tell by looking at it.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

# WRONG: naive datetime (no timezone info)
now_naive = datetime.now()
# >>> datetime(2026, 3, 15, 14, 30, 0)
# This could be ANY timezone. Python doesn't know. You don't know.
# It works on your laptop. It breaks on the server.

# RIGHT: timezone-aware datetime
now_utc = datetime.now(timezone.utc)
# >>> datetime(2026, 3, 15, 13, 30, 0, tzinfo=datetime.timezone.utc)

# Convert for display in user's timezone
user_tz = ZoneInfo("Europe/Amsterdam")
now_local = now_utc.astimezone(user_tz)
# >>> datetime(2026, 3, 15, 14, 30, 0, tzinfo=ZoneInfo("Europe/Amsterdam"))

The same principle applies in every language. JavaScript's new Date() is timezone-aware (it uses the system timezone), but that doesn't help if you serialize it without an offset. TypeScript with date-fns or luxon gives you explicit timezone handling. Use it.

The rule: if a datetime doesn't have timezone information attached, treat it as a bug. Not a style preference. A bug.

Rule 3: Convert at the edges

Your API is the boundary between "UTC everywhere" (backend) and "local time for the user" (frontend). The API contract must be explicit.

// API response: always return ISO 8601 with UTC offset
{
  "order": {
    "id": 12345,
    "placed_at": "2026-03-15T22:30:00Z",  // Always UTC
    "amount": 499.00
  }
}

// Frontend: convert to user's local timezone for display
const placedAt = new Date(order.placed_at);
const formatted = placedAt.toLocaleDateString('nl-NL', {
  timeZone: 'Europe/Amsterdam',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit'
});
// "15 maart 2026 23:30"

The backend sends UTC. The frontend converts for display. The user never sees "Z" or "+00:00." They see their local time, formatted in their locale. This is the only conversion point. Everywhere else in your system, it's UTC.

Rule 4: Date boundaries need a timezone

When a user asks for "today's orders," that's a timezone-dependent question. "Today" starts at midnight in the user's timezone, not midnight UTC.

# WRONG: filtering by date using naive date boundaries
orders_today = db.query(Order).filter(
    Order.placed_at >= date.today(),
    Order.placed_at < date.today() + timedelta(days=1)
).all()
# Whose "today"? Server's today. Not the user's.

# RIGHT: convert user's date boundaries to UTC
user_tz = ZoneInfo("Europe/Amsterdam")
user_today_start = datetime.combine(
    date.today(),
    time.min,
    tzinfo=user_tz
).astimezone(timezone.utc)
user_today_end = user_today_start + timedelta(days=1)

orders_today = db.query(Order).filter(
    Order.placed_at >= user_today_start,
    Order.placed_at < user_today_end
).all()

This is the fix for Bug 1. Instead of using the server's date as a boundary, you compute the boundary from the user's timezone, convert to UTC, and query in UTC. The database has UTC timestamps. The query boundaries are in UTC. But the boundaries represent the user's local day.

Rule 5: Cron jobs are not timezone-free

A cron job that runs "at midnight" is meaningless without specifying whose midnight. If your cron job produces user-facing output (daily reports, scheduled notifications, billing cycles), it must account for the user's timezone.

# WRONG: cron job runs at midnight UTC for "daily" tasks
# 0 0 * * * python run_daily_report.py

# For users in UTC+1, "yesterday" ends at 23:00 UTC.
# For users in UTC-8, "yesterday" doesn't end until 08:00 UTC.
# A single midnight-UTC cron catches neither correctly.

# RIGHT: run the job after the latest timezone closes the day
# The last timezone to hit midnight is Baker Island (UTC-12).
# So "yesterday" is only fully over everywhere at 12:00 UTC.

# Better: run per-timezone for user-facing reports
from zoneinfo import ZoneInfo
from datetime import datetime, timezone

def get_yesterday_range(tz_name: str):
    tz = ZoneInfo(tz_name)
    now_local = datetime.now(timezone.utc).astimezone(tz)
    yesterday_local = now_local.date() - timedelta(days=1)
    start = datetime.combine(yesterday_local, time.min, tzinfo=tz)
    end = datetime.combine(yesterday_local, time.max, tzinfo=tz)
    return start.astimezone(timezone.utc), end.astimezone(timezone.utc)

For global applications, this often means running the job multiple times: once per timezone cohort. For simpler setups where all users share a timezone, set the cron to that timezone explicitly and document it.

Rule 6: Test in a hostile timezone

Set your CI server's timezone to something far from your team's timezone. Pacific/Auckland (UTC+12/+13) is a good choice because it's ahead of most development timezones, meaning date boundaries differ for almost everyone.

Add test cases specifically for:

  • Midnight boundary crossing: an event at 23:30 in one timezone that falls on a different date in UTC
  • DST transition dates: both spring (lose an hour) and autumn (gain an hour)
  • Date-only fields: verify that a "March 15" in UTC-8 and "March 15" in UTC+9 produce different UTC ranges
  • Year boundary: December 31 at 23:30 in a positive-offset timezone (already January 1 in UTC)

If these test cases don't exist in your codebase, your timezone handling is untested, regardless of what your coverage tool says.

The ARCHITECTURE.md section

Rules that live only in people's heads get forgotten. Rules that live in documentation get followed. Here is a complete timezone policy section you can drop into your ARCHITECTURE.md. It covers storage, API contracts, application rules, DST handling, and testing requirements.

## Timezone Policy

### Storage
- All timestamps stored as `TIMESTAMPTZ` in PostgreSQL (UTC internally)
- Application code uses timezone-aware datetimes exclusively
- Naive datetimes are a bug. Linters enforce this.

### API Contract
- All API responses return ISO 8601 with UTC offset: `2026-03-15T22:30:00Z`
- All API requests accept ISO 8601. If no offset is provided, reject the request
  (do not assume UTC silently).
- The `Accept-Timezone` header or user profile stores the user's IANA timezone
  (e.g., `Europe/Amsterdam`). Never derive timezone from IP or browser offset.

### Application Rules
- `datetime.now()` is banned. Use `datetime.now(timezone.utc)`.
- `timedelta(days=1)` is banned for "next day" calculations involving local time.
  Use `ZoneInfo` and compute the next calendar day, then convert back to UTC.
- Cron jobs that produce user-facing date-based output must account for the user's
  timezone. "Yesterday's report" means yesterday in the user's timezone.

### Date Boundaries
- "Today" means today in the user's timezone, converted to UTC for database queries.
- Date-only fields (`DATE` columns) represent a calendar date in the user's timezone.
  Store the IANA timezone alongside: `scheduled_date DATE`, `scheduled_tz TEXT`.
- For recurring events, store the local time + timezone, not a UTC timestamp.

### DST Transitions
- Never add/subtract hours to shift between days. Always use calendar arithmetic
  via `ZoneInfo` or equivalent, which handles DST automatically.
- Test with DST transition dates: last Sunday of March, last Sunday of October (EU),
  second Sunday of March, first Sunday of November (US).

### Testing
- CI timezone is set to a non-UTC timezone (e.g., `Pacific/Auckland`, UTC+12/+13)
  to catch assumptions about server timezone.
- Every date-boundary query has a test case that crosses midnight in a non-UTC timezone.
- DST transition dates are in the test fixture data.

### Frontend
- Display times in the user's timezone. Never show raw UTC to end users.
- Use `Intl.DateTimeFormat` or a library that respects IANA timezone identifiers.
- Relative times ("2 hours ago") are timezone-safe. Absolute times ("March 15")
  must specify the timezone context.

This section does three things. First, it prevents new code from introducing timezone bugs by making the rules explicit. Second, it gives AI coding assistants the context they need to generate timezone-correct code. Third, it gives your team a reference when they hit a timezone edge case and need to decide how to handle it.

The timezone section is small. Eighteen lines of rules, a few lines of rationale. But those eighteen lines prevent an entire category of bugs that have been silently corrupting data in production systems for decades.

The bottom line

Timezones are not a technical problem you solve once. They are a policy decision your team makes and enforces continuously. The bugs are not caused by bad developers. They are caused by missing rules.

Store UTC. Ban naive datetimes. Convert at the edges. Make date boundaries timezone-aware. Test in a hostile timezone. Write it all down in your ARCHITECTURE.md.

Your next timezone bug is already in your codebase. It passed your tests last week. It will show up when a customer in a timezone you've never tested files a ticket you can't reproduce. Unless you prevent it now.

Read the full lesson: Documentation as Code →