Skip to main content

Command Palette

Search for a command to run...

Why DATE(updated_at) Broke Timezones and Killed PostgreSQL Index Performance

A real-world deep-dive into how a single SQL wrapper caused wrong dates for international users and made indexes completely useless — and the SARGable fix that resolved both.

Updated
7 min read
Why DATE(updated_at) Broke Timezones and Killed PostgreSQL Index Performance

I recently resolved two separate production bugs that turned out to share the same root cause — a seemingly innocent DATE() call wrapped around a timestamptz column. What made it worse: the pattern had propagated across the entire codebase — 50+ queries touching multiple tables, all sharing the same anti-pattern, all silently doing the wrong timezone math and bypassing indexes. This post explains exactly what went wrong, why it's subtle, and how to fix it properly.


The Setup

Consider a set of high-volume tables — activity logs, coding sessions, review events — each with an updated_at column of type timestamptz (timestamp with time zone). Queries across these tables drive dashboards and reports for users across multiple timezones.

A common query pattern looks like this:

-- Filter activity by date range
WHERE DATE(updated_at) BETWEEN :start_date AND :end_date

The intent is clear: group and filter activity by calendar day. The implementation, however, hides two serious problems inside those parentheses.


Problem 1 — Wrong Dates for Non-UTC Users

To understand the first issue, consider a hypothetical: a developer in Sydney completes a coding session at 08:30 AEST on 10th March. Here's what happens step by step.

Timeline of the problem

Moment What happened
10 Mar, 08:30 AEST Developer in Sydney completes a coding session
Stored in DB updated_at = 2024-03-09 21:30:00+00 (UTC)
DATE() runs DATE('2024-03-09 21:30:00+00') → truncates in UTC → returns 2024-03-09
Dashboard shows 9th March — wrong by a full day

The fundamental issue: updated_at stores an absolute moment in time (UTC). To answer "what day was this in Sydney?" you must first convert to AEST/AEDT, then truncate. DATE() skips the conversion step entirely.

Key insight: PostgreSQL's DATE() function converts a timestamptz value to a date using the session's timezone — and in most server deployments, that session timezone defaults to UTC. The timestamp stored is correct. The truncation is wrong.

This isn't an edge case — it affects any user whose local time doesn't align with UTC, which is most of the world for most of the day.


Problem 2 — Indexes Ignored Entirely

The second problem is a performance disaster at scale. On large tables, queries using DATE(updated_at) will time out. Running EXPLAIN ANALYZE confirms sequential scans even when updated_at has a B-tree index.

The reason is a fundamental property of how database query planners work, called SARGability.

SARGable (Search ARGument Able): a predicate is SARGable when the database engine can use an index to evaluate it — meaning the indexed column appears alone on one side of the comparison, without being wrapped in a function.

When you write DATE(updated_at) BETWEEN x AND y, the planner sees a derived value on the left side, not the raw column. To evaluate it, PostgreSQL must call DATE() on every single row in the table, compute the result, and then compare. The index on updated_at is completely bypassed because the index stores raw timestamp values, not their DATE() equivalents.

Approach Index used? Planner sees Cost on large table
DATE(updated_at) BETWEEN x AND y ❌ No Function result — opaque to planner Sequential scan, O(n)
updated_at >= x AND updated_at < y ✅ Yes Raw column comparison Index seek, O(log n)

The Fix — SARGable Range with Timezone Awareness

The solution rewrites the predicate so the indexed column is bare on the left-hand side. The timezone conversion happens on the bound values, not on the column itself.

Before — non-SARGable, timezone-unaware

WHERE DATE(updated_at) BETWEEN :start_date AND :end_date
-- Wraps the column in a function: index skipped, truncates in UTC

After — SARGable, timezone-correct

WHERE updated_at >= (:start_date AT TIME ZONE :user_timezone)
  AND updated_at <  (:end_date   AT TIME ZONE :user_timezone) + INTERVAL '1 day'
-- Column is bare: index seeks directly. Bounds converted to UTC before comparison.

What's happening under the hood

The AT TIME ZONE operator on a date or timestamp without time zone input produces a timestamptz — PostgreSQL interprets the date as midnight in the given zone and converts it to the equivalent UTC moment.

Using our Sydney example:

  • '2024-03-10' AT TIME ZONE 'Australia/Sydney'2024-03-09 13:00:00+00
  • '2024-03-11' AT TIME ZONE 'Australia/Sydney'2024-03-10 13:00:00+00 The session at 08:30 AEST stored as 2024-03-09 21:30:00+00 falls squarely within that range — correctly attributed to 10th March in Sydney.

Side-by-Side Summary

DATE(updated_at) — what you get

  • Truncates in UTC by default
  • Wrong day for non-UTC users
  • Wraps the column — non-SARGable
  • Full sequential scan on every query
  • Timeouts on large tables

Range with AT TIME ZONE — what you get

  • Converts bounds to the user's actual timezone
  • Correct day for every timezone worldwide
  • Column is bare — fully SARGable
  • Index seek, millisecond response times
  • Scales to any table size

Additional Recommendations

Pass the timezone from your application layer

Store each user or organisation's timezone (IANA format, e.g. Australia/Sydney) in your database and pass it as a query parameter. Never derive dates server-side from a session default that may not match the user's locale.

-- Application passes :user_timezone = 'Australia/Sydney'
WHERE updated_at >= (:start_date AT TIME ZONE :user_timezone)
  AND updated_at <  (:end_date   AT TIME ZONE :user_timezone) + INTERVAL '1 day'

Use IANA timezone names, not UTC offsets

UTC offsets like +05:30 are fixed and do not account for DST transitions. IANA names like Asia/Kolkata are DST-aware and always produce the correct local midnight.

-- Fragile: ignores DST
(:start_date AT TIME ZONE '+11:00')
 
-- Correct: DST-aware
(:start_date AT TIME ZONE 'Australia/Sydney')

Add a composite index for query coverage

If your queries also filter by a tenant or user identifier, a composite index with the high-cardinality column first gives maximum selectivity:

CREATE INDEX idx_activity_org_updated_at
    ON activity (org_id, updated_at DESC);

Audit existing queries

Search your codebase for any pattern matching DATE( or date_trunc( applied directly to a timestamptz column in a WHERE clause. Each one is a potential index bypass and a timezone correctness hazard.

grep -rn -E "DATE\s*\(|date_trunc\s*\(|::(date|timestamp)\b" \
  --include="*.sql" --include="*.java" --include="*.py" \
  . > non_sargable_candidates.txt

Remediation Cheat Sheet

Original pattern Fixed pattern
DATE(col) = :d col >= (:d AT TIME ZONE :tz) AND col < (:d AT TIME ZONE :tz) + INTERVAL '1 day'
DATE(col) BETWEEN :s AND :e col >= (:s AT TIME ZONE :tz) AND col < (:e AT TIME ZONE :tz) + INTERVAL '1 day'
date_trunc('day', col) = :d Same as above
col::date = :d Same as above
EXTRACT(epoch FROM col) > :ts col > to_timestamp(:ts)

TL;DR

DATE(updated_at) is one of those patterns that looks obviously correct, is easy to write, and fails silently — the query runs, returns results, and you only notice when a user in a non-UTC timezone reports a data discrepancy, or when the table grows large enough for the sequential scan to cause timeouts.

The rule of thumb:

If a column has an index, it should appear bare on one side of a comparison in your WHERE clause. Timezone conversion belongs on the constant bounds, not on the column.

Replace DATE(updated_at) BETWEEN x AND y with:

updated_at >= (x AT TIME ZONE :tz)
AND updated_at <  (y AT TIME ZONE :tz) + INTERVAL '1 day'

Your indexes work again. Your international users see the right dates.


The issue described here is a real class of bug that affects multi-tenant systems where users span multiple timezones. The fix is conceptually simple — but in practice it can mean auditing and rewriting dozens of queries spread across multiple tables in the codebase. Understanding why it works requires knowing how PostgreSQL stores, casts, and indexes timestamptz data.

More from this blog

Badmanji Builds

2 posts

Backend engineer writing about distributed systems, database performance, and production failures — and what they taught me.