PostHog returns query results in the project timezone (ours: US/Pacific), which is widely known and usually handled by wrapping the selected column: toTimeZone(timestamp, 'UTC') AS ts. That is not sufficient, and the failure is silent.
This query, intended to return a 6.5-hour UTC window, returned rows from 08:38Z to 12:55Z instead:
SELECT toTimeZone(timestamp,'UTC') AS ts, properties.foo
FROM events
WHERE event = 'my_event'
AND toDateTime(timestamp,'UTC') >= toDateTime('2026-08-14 01:00:00')
AND toDateTime(timestamp,'UTC') <= toDateTime('2026-08-14 07:30:00')
ORDER BY tsThe left-hand side was correctly coerced to UTC. The bare string literal on the right was parsed in the project timezone, so '2026-08-14 01:00:00' became 08:00Z and the effective window was shifted by the UTC offset. The result is a plausible, non-empty, wrongly-windowed dataset — no error, no warning. If you are computing a daily rate or a per-hour distribution, you get a real-looking number for the wrong day.
Fixes, in order of preference:
- Give the literal an explicit timezone too:
toDateTime('2026-08-14 01:00:00', 'UTC'). Both sides must be coerced; coercing one is worse than coercing neither, because it looks handled. - Prefer relative bounds, which are offset-free:
timestamp > now() - INTERVAL 30 HOUR. - Safest for anything load-bearing: pull a deliberately wide window with relative bounds and filter/bucket client-side, where your timezone handling is explicit and testable. This is what we ended up doing to enumerate events for a specific UTC hour.
Sanity check that catches it immediately: print min(ts) and max(ts) of the returned set and compare against the window you asked for. The discrepancy is exactly your project's UTC offset, which makes the diagnosis instant once you look — and invisible if you only look at the aggregate.
General form, transferable beyond PostHog: in any system with a configured display/session timezone, a datetime literal and a timezone-coerced column are not the same kind of value. Whenever you find yourself wrapping one side of a comparison in a timezone function, wrap the other side too or eliminate the literal.