How to Convert Timestamps to Local Timezone in PostgreSQL
If your report groups events by date using a UTC timestamp, events that happen at 11pm local time silently land on the wrong day - because 11pm Eastern is already the next UTC date. The fix is to apply AT TIME ZONE before the date cast.
The bug: grouping by UTC date instead of local date
Events are stored in UTC - which is correct. But when you cast directly to a date for grouping, PostgreSQL uses the UTC value:
-- Wrong: casts the UTC timestamp to a date
SELECT
created_at::date AS event_date,
COUNT(*) AS event_count
FROM events
GROUP BY created_at::date
ORDER BY event_date;
An event at 2024-03-04 23:45:00 America/New_York is stored as
2024-03-05 04:45:00 UTC. The ::date cast gives 2024-03-05 - a full
day ahead of the user's local date. Events near midnight are the only ones
affected, so the bug hides until someone checks closely.
The fix: AT TIME ZONE before the date cast
Convert to local time first, then cast to a date:
SELECT
(created_at AT TIME ZONE 'America/New_York')::date AS event_date,
COUNT(*) AS event_count
FROM events
GROUP BY (created_at AT TIME ZONE 'America/New_York')::date
ORDER BY event_date;
AT TIME ZONE shifts the timestamp to the named zone, then ::date extracts
the local calendar date. The GROUP BY must use the same expression.
Parameterize the timezone
Hard-coding the timezone string works, but a better approach reads it from a variable so the same query works for any region:
import psycopg2, os
TIMEZONE = os.environ["REPORT_TIMEZONE"] # e.g. "America/New_York"
query = """
SELECT
(created_at AT TIME ZONE %s)::date AS event_date,
COUNT(*) AS event_count
FROM events
GROUP BY (created_at AT TIME ZONE %s)::date
ORDER BY event_date;
"""
cur.execute(query, (TIMEZONE, TIMEZONE))
rows = cur.fetchall()
Pass the timezone name twice - once per %s placeholder. PostgreSQL's AT
TIME ZONE accepts any IANA zone name (America/Chicago, Europe/London,
Asia/Tokyo, etc.) and handles Daylight Saving Time transitions automatically.
How to verify the fix
Inspect raw rows to compare UTC vs local dates before and after:
SELECT
id,
created_at AS utc_time,
created_at AT TIME ZONE 'America/New_York' AS eastern_time,
created_at::date AS wrong_date,
(created_at AT TIME ZONE 'America/New_York')::date AS correct_date
FROM events
ORDER BY created_at;
Any row where wrong_date != correct_date is an event that was
mis-counted before the fix - typically events between midnight local and
the UTC midnight that follows.
Store UTC, convert for display and aggregation
The rule: store timestamps in UTC everywhere. Convert to local time only at
report or display time. This keeps the data portable across timezones and
correct through DST changes. For Python-side conversion, zoneinfo (stdlib
since 3.9) or pytz both accept the same IANA zone names PostgreSQL does.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Applying AT TIME ZONE before a date cast in a GROUP BY query
- Parameterizing the timezone name with psycopg2 placeholders
- Comparing UTC vs local dates to identify mis-counted midnight-boundary events
FAQ
Why do events near midnight show up on the wrong day in my report?
If your timestamps are stored in UTC and you group by date without converting timezone first, a 11pm Eastern event is already 4am the next UTC day. Cast to date after applying AT TIME ZONE so grouping uses the local calendar date, not UTC.
How do I convert a UTC timestamp to local time in PostgreSQL?
Use AT TIME ZONE with an IANA zone name: created_at AT TIME ZONE 'America/New_York'. This returns a timestamp in the target zone, handling DST automatically. Then cast to date if you need the local date.
Do I need to write the timezone name twice in the GROUP BY?
Yes - the GROUP BY expression must exactly match the SELECT expression. So if you write (created_at AT TIME ZONE %s)::date in the SELECT, repeat that same expression (with the same parameter) in the GROUP BY.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →