Timezones and wrong local time in Postgres
Times off by a few hours usually come from timestamp without time zone, or converting twice. How to store times as timestamptz, convert for display, and group by a user's local day.
What you see
- A booking made for 09:00 shows as 08:00 or 14:00 in the app.
now()in the SQL editor doesn't match your clock.- A daily report groups late-evening orders into the next day.
Why it happens
Postgres has two timestamp types, and they behave very differently:
| Type | What it stores | What you get back |
|---|---|---|
timestamptz (timestamp with time zone) |
An exact instant, kept internally as UTC | That instant, shown in the session's timezone setting |
timestamp (timestamp without time zone) |
A date and clock time with no zone | Exactly what was stored, whatever zone the reader is in |
Most "wrong time" bugs come from one of these:
- Using
timestamp. Your app sends2026-11-20T09:00:00+01:00; atimestampcolumn throws away the+01:00, so the value no longer says which 09:00 it was. - Converting twice. The server converts to local time, then the browser converts again.
- Grouping by date in UTC.
date(created_at)uses the session timezone, not your user's.
To see what your session uses:
show timezone;
select now(), now() at time zone 'Europe/Paris' as paris_wall_clock;
How to fix it
1. Store instants as timestamptz.
alter table public.bookings
alter column starts_at type timestamptz
using starts_at at time zone 'UTC';
The using clause says how to read the old values. Use 'UTC' if your app wrote UTC into them, or the zone it actually used. On a large table this rewrites every row; see ALTER TABLE takes forever.
2. Send ISO 8601 with an offset, and convert only for display.
await supabase.from('bookings').insert({
room_id: 3,
starts_at: new Date('2026-11-20T09:00:00+01:00').toISOString(), // stored as 08:00 UTC
})
const { data } = await supabase.from('bookings').select('starts_at')
const label = new Date(data![0].starts_at).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })
The Data API returns timestamptz values with an offset, so the browser can convert them correctly.
3. Group by the user's local day in SQL with at time zone:
select (created_at at time zone 'America/New_York')::date as local_day,
count(*) as orders
from public.orders
group by 1
order by 1;
For per-user zones, store an IANA name such as Europe/London on the profile (not a fixed offset like +01:00, which breaks across daylight saving changes) and pass it in.
4. Use timestamp or date only for wall-clock values that aren't an instant, like "the shop opens at 09:00" or a birthday.
Changing the timezone setting
You can set it for one session or transaction (set timezone = 'Asia/Tokyo';) to make output easier to read. Avoid changing it at the role level: on SupaHost all projects share one Postgres server, so a role setting like alter role postgres set timezone ... affects every project on the instance. Converting in queries or in the app is safer.
On SupaHost
- Cron schedules in the Cron tab and in
cron.schedule()are in UTC. A job meant for 03:00 in New York needs0 8 * * *in winter and0 7 * * *in summer. - The maintenance window for automatic upgrades, 03:00-05:00, is also UTC.