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.

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 atimestamptzvalue 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+00The session at 08:30 AEST stored as2024-03-09 21:30:00+00falls 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
WHEREclause. 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.
