{"article":{"slug":"postgres-at-time-zone-utc-does-not-do-what-you-think-it-does","title":"Postgres AT TIME ZONE 'UTC' does NOT do what you think it does","subtitle":null,"summary":"A practical Postgres footgun: AT TIME ZONE 'UTC' converts timestamptz to timestamp without time zone—so month math and equality break unless you apply AT TIME ZONE twice.","content_type":"tutorial","language":"en","canonical_url":"https://bookofrevenue.com/blog/6ab81e9a97a13f0001f7e4e1/postgres-at-time-zone-u-does-not-do-what-you-think-it-does","author":{"name":"Tanin Nanakorn","url":"https://tanin.nanakorn.com/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Book of Revenue","url":"https://bookofrevenue.com/","listing_slug":null,"listing":null},"topics":[{"name":"Databases","slug":"databases","url":"https://listedarticles.com/topics/databases"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Tutorials","slug":"tutorials","url":"https://listedarticles.com/topics/tutorials"},{"name":"Engineering","slug":"engineering","url":"https://listedarticles.com/topics/engineering"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":538,"reading_minutes":2,"published_at":"2026-09-27T05:42:15.000Z","added_at":"2026-09-28T15:26:07.397Z","updated_at":"2026-09-28T15:26:07.397Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/postgres-at-time-zone-utc-does-not-do-what-you-think-it-does","markdown_url":"https://listedarticles.com/articles/postgres-at-time-zone-utc-does-not-do-what-you-think-it-does.md","example":false,"citation":"Tanin Nanakorn, Book of Revenue. \"Postgres AT TIME ZONE 'UTC' does NOT do what you think it does.\" 27 Sept 2026. https://bookofrevenue.com/blog/6ab81e9a97a13f0001f7e4e1/postgres-at-time-zone-u-does-not-do-what-you-think-it-does (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://bookofrevenue.com/blog/6ab81e9a97a13f0001f7e4e1/postgres-at-time-zone-u-does-not-do-what-you-think-it-does"},"body_markdown":"_And why you may need to repeat`AT TIME ZONE 'UTC'` twice._\n\nSUMMARY\n\n  * `AT TIME ZONE 'UTC'` converts the data type from `timestamptz` to `timestamp` ([example](https://onecompiler.com/postgresql/454fuzgeu?ref=tanin.nanakorn.com)). \n  * Now you are inadvertently using the `timestamp without time zone` (aka `timestamp`) data type, which is [markedly discouraged](https://wiki.postgresql.org/wiki/Don't_Do_This?ref=tanin.nanakorn.com#Don't_use_timestamp_\\(without_time_zone\\)_to_store_UTC_times).\n  * There are tons of footguns with `timestamp`. \n  * For example, the equality of `timestamp` and `timestamptz` will always be false.\n  * Adding a month with `+ INTERVAL '1 months'` is timezone-dependent. If your product operates in UTC, you must ensure the timezone is in UTC before adding a month with `<timestamptz_column> AT TIME ZONE 'UTC' + INTERVAL '1 months'`.... but now the result is a `timestamp`, not a `timestamptz`.\n  * To convert the above back to `timestamptz` in order to avoid issues with equality, you will need to invoke `AT TIME ZONE 'UTC` again. The final form is: `(<timestamptz_column> AT TIME ZONE 'UTC' + INTERVAL '1 months') AT TIME ZONE 'UTC'`\n\n\n\nPostgres has these 2 timestamp types: `timestamp` and `timestamptz`.\n\n`timestamp` doesn't contain the timezone information. Technically, it doesn't represent a time in the real world. Thinking about it deeply, when we say \"5pm\" in the real world, it actually means \"5pm in our timezone\". Even Postgres Wiki says: [Don't use timestamp without time zone](https://wiki.postgresql.org/wiki/Don't_Do_This?ref=tanin.nanakorn.com#Don't_use_timestamp_\\(without_time_zone\\)_to_store_UTC_times).\n\nI already know this for quite a while. What I didn't realize is that I might not be able to completely avoid `timestamp without time zone`.\n\nIn many time-series products, you may be building a chart that shows the deltas from month to month and have a SQL like below:\n    \n    \n    SELECT\n      a.month_start,\n      (a.value - b.value) AS delta\n    FROM data a\n    JOIN data b\n    ON a.month_start = b.month_start + INTERVAL '1 months'\n\nNow the first issue is: **adding months is actually timezone-dependent.**\n\nFor example, if your timezone is PT, and your product works in UTC, `2026-03-01 00:00:00+00` is equal to `2026-02-28 16:00:00-08`. Your machine's default setting is PT. This means Postgres will use `2026-02-28 16:00:00-08`, and `'2026-02-28 16:00:00-08'::timestamptz + INTERVAL '1 months'` will yield `2026-03-28 16:00:00-08`. That's not what we want.\n\nSince we operate in UTC, we should convert the timestamp to UTC before adding a month. We modify the SQL to be:\n    \n    \n    SELECT\n      a.month_start,\n      (a.value - b.value) AS delta\n    FROM data a\n    JOIN data b\n    ON a.month_start = (b.month_start AT TIME ZONE 'UTC') + INTERVAL '1 months'\n\nIt turns out the SQL still doesn't produce the correct result because:\n\n  1. `'2026-02-28 16:00:00-08'::timestamptz AT TIME ZONE 'UTC'` will convert the data type from `timestamptz` (with time zone) to `timestamp` (_without_ time zone) and produces `2026-03-01 00:00:00` (Notice there's no `+00` at the end). [Example](https://onecompiler.com/postgresql/454fuzgeu?ref=tanin.nanakorn.com)\n  2. Comparing a `timestamp` and `timestamptz` will always result in `false`.\n     1. It makes sense because `timestamp` is not a real-world point in time. The comparison is technically absurd.\n     2. It gets even more confusing because, if you perform `EXTRACT(EPOCH FROM <timestamp>)`, they will both produce the same value.\n\n\n\nIn order to fix it, we have to convert `timestamp` back to `timestamptz` with another `AT TIME ZONE 'UTC'` like below:\n    \n    \n    ON a.month_start = ((b.month_start AT TIME ZONE 'UTC') + INTERVAL '1 months') AT TIME ZONE 'UTC'\n\nNow the equality will work as you expect it to work.","body_html":"<p><em>And why you may need to repeat<code>AT TIME ZONE &#39;UTC&#39;</code> twice.</em></p>\n<p>SUMMARY</p>\n<ul><li><code>AT TIME ZONE &#39;UTC&#39;</code> converts the data type from <code>timestamptz</code> to <code>timestamp</code> (<a href=\"https://onecompiler.com/postgresql/454fuzgeu?ref=tanin.nanakorn.com\" rel=\"nofollow ugc noopener\">example</a>). </li><li>Now you are inadvertently using the <code>timestamp without time zone</code> (aka <code>timestamp</code>) data type, which is <a href=\"https://wiki.postgresql.org/wiki/Don&#39;t_Do_This?ref=tanin.nanakorn.com#Don&#39;t_use_timestamp_(without_time_zone)_to_store_UTC_times\" rel=\"nofollow ugc noopener\">markedly discouraged</a>.</li><li>There are tons of footguns with <code>timestamp</code>. </li><li>For example, the equality of <code>timestamp</code> and <code>timestamptz</code> will always be false.</li><li>Adding a month with <code>+ INTERVAL &#39;1 months&#39;</code> is timezone-dependent. If your product operates in UTC, you must ensure the timezone is in UTC before adding a month with <code>&lt;timestamptz_column&gt; AT TIME ZONE &#39;UTC&#39; + INTERVAL &#39;1 months&#39;</code>.... but now the result is a <code>timestamp</code>, not a <code>timestamptz</code>.</li><li>To convert the above back to <code>timestamptz</code> in order to avoid issues with equality, you will need to invoke <code>AT TIME ZONE &#39;UTC</code> again. The final form is: <code>(&lt;timestamptz_column&gt; AT TIME ZONE &#39;UTC&#39; + INTERVAL &#39;1 months&#39;) AT TIME ZONE &#39;UTC&#39;</code></li></ul>\n<p>Postgres has these 2 timestamp types: <code>timestamp</code> and <code>timestamptz</code>.</p>\n<p><code>timestamp</code> doesn&#39;t contain the timezone information. Technically, it doesn&#39;t represent a time in the real world. Thinking about it deeply, when we say &quot;5pm&quot; in the real world, it actually means &quot;5pm in our timezone&quot;. Even Postgres Wiki says: <a href=\"https://wiki.postgresql.org/wiki/Don&#39;t_Do_This?ref=tanin.nanakorn.com#Don&#39;t_use_timestamp_(without_time_zone)_to_store_UTC_times\" rel=\"nofollow ugc noopener\">Don&#39;t use timestamp without time zone</a>.</p>\n<p>I already know this for quite a while. What I didn&#39;t realize is that I might not be able to completely avoid <code>timestamp without time zone</code>.</p>\n<p>In many time-series products, you may be building a chart that shows the deltas from month to month and have a SQL like below:</p>\n<pre><code>SELECT\n  a.month_start,\n  (a.value - b.value) AS delta\nFROM data a\nJOIN data b\nON a.month_start = b.month_start + INTERVAL &#39;1 months&#39;</code></pre>\n<p>Now the first issue is: <strong>adding months is actually timezone-dependent.</strong></p>\n<p>For example, if your timezone is PT, and your product works in UTC, <code>2026-03-01 00:00:00+00</code> is equal to <code>2026-02-28 16:00:00-08</code>. Your machine&#39;s default setting is PT. This means Postgres will use <code>2026-02-28 16:00:00-08</code>, and <code>&#39;2026-02-28 16:00:00-08&#39;::timestamptz + INTERVAL &#39;1 months&#39;</code> will yield <code>2026-03-28 16:00:00-08</code>. That&#39;s not what we want.</p>\n<p>Since we operate in UTC, we should convert the timestamp to UTC before adding a month. We modify the SQL to be:</p>\n<pre><code>SELECT\n  a.month_start,\n  (a.value - b.value) AS delta\nFROM data a\nJOIN data b\nON a.month_start = (b.month_start AT TIME ZONE &#39;UTC&#39;) + INTERVAL &#39;1 months&#39;</code></pre>\n<p>It turns out the SQL still doesn&#39;t produce the correct result because:</p>\n<ol><li><code>&#39;2026-02-28 16:00:00-08&#39;::timestamptz AT TIME ZONE &#39;UTC&#39;</code> will convert the data type from <code>timestamptz</code> (with time zone) to <code>timestamp</code> (<em>without</em> time zone) and produces <code>2026-03-01 00:00:00</code> (Notice there&#39;s no <code>+00</code> at the end). <a href=\"https://onecompiler.com/postgresql/454fuzgeu?ref=tanin.nanakorn.com\" rel=\"nofollow ugc noopener\">Example</a></li><li>Comparing a <code>timestamp</code> and <code>timestamptz</code> will always result in <code>false</code>.<ol><li>It makes sense because <code>timestamp</code> is not a real-world point in time. The comparison is technically absurd.</li><li>It gets even more confusing because, if you perform <code>EXTRACT(EPOCH FROM &lt;timestamp&gt;)</code>, they will both produce the same value.</li></ol></li></ol>\n<p>In order to fix it, we have to convert <code>timestamp</code> back to <code>timestamptz</code> with another <code>AT TIME ZONE &#39;UTC&#39;</code> like below:</p>\n<pre><code>ON a.month_start = ((b.month_start AT TIME ZONE &#39;UTC&#39;) + INTERVAL &#39;1 months&#39;) AT TIME ZONE &#39;UTC&#39;</code></pre>\n<p>Now the equality will work as you expect it to work.</p>","headings":[]}}