<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: libersand</title>
    <description>The latest articles on DEV Community by libersand (@libersand).</description>
    <link>https://dev.to/libersand</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081268%2F930d5362-ee32-4bd5-92b9-bc2f0e68e62f.png</url>
      <title>DEV Community: libersand</title>
      <link>https://dev.to/libersand</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/libersand"/>
    <language>en</language>
    <item>
      <title>A Postgres driver silently shifted my timestamps by 8 hours — and every test stayed green</title>
      <dc:creator>libersand</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:14:55 +0000</pubDate>
      <link>https://dev.to/libersand/a-postgres-driver-silently-shifted-my-timestamps-by-8-hours-and-every-test-stayed-green-22f2</link>
      <guid>https://dev.to/libersand/a-postgres-driver-silently-shifted-my-timestamps-by-8-hours-and-every-test-stayed-green-22f2</guid>
      <description>&lt;p&gt;A chunk of the timestamp columns in my database were quietly wrong.&lt;/p&gt;

&lt;p&gt;Not corrupted. Not null. Just — eight hours off. The values looked perfectly reasonable in &lt;code&gt;psql&lt;/code&gt;. They looked reasonable in the app. They &lt;em&gt;were&lt;/em&gt; reasonable, for a timezone I wasn't in.&lt;/p&gt;

&lt;p&gt;Every test passed. CI was green. It had been green the whole time.&lt;/p&gt;

&lt;p&gt;Here's what was actually happening, and why it's invisible to most of the people who could have warned me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Nothing exotic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/porsager/postgres" rel="noopener noreferrer"&gt;postgres.js&lt;/a&gt; as the driver&lt;/li&gt;
&lt;li&gt;Drizzle ORM on top&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The schema looked like this — and if you use Drizzle, yours probably does too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;timestamp('created_at')&lt;/code&gt; is the important part. In Drizzle, &lt;code&gt;timestamp()&lt;/code&gt; with no options generates a Postgres &lt;code&gt;timestamp without time zone&lt;/code&gt; column — not &lt;code&gt;timestamptz&lt;/code&gt;. The default is the unsafe one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that are individually fine
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One: &lt;code&gt;timestamp without time zone&lt;/code&gt; stores no offset.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That isn't a bug, it's the definition of the type. Postgres stores the wall-clock digits you gave it — &lt;code&gt;2026-08-17 10:00:00&lt;/code&gt; — and nothing else. No zone, no offset. It has no idea whether that's 10am in Shanghai or 10am in Chicago, and it doesn't care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: the driver has to guess on the way back.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you hand postgres.js a JavaScript &lt;code&gt;Date&lt;/code&gt;, it serializes it and sends it over. Postgres receives a value for a &lt;code&gt;timestamp without time zone&lt;/code&gt; column, discards the offset, and stores the literal wall clock.&lt;/p&gt;

&lt;p&gt;Then you read it back. Postgres hands the driver a bare string with no zone marker. The driver has to turn that into a JS &lt;code&gt;Date&lt;/code&gt;, and it has no offset to work with — so it resolves the string against the &lt;em&gt;runtime's local timezone&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Which is not necessarily the timezone the value was written in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The round trip
&lt;/h2&gt;

&lt;p&gt;My laptop is UTC+8. Trace one value through:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;new Date()&lt;/code&gt; in my app&lt;/td&gt;
&lt;td&gt;18:00 Beijing = &lt;strong&gt;10:00 UTC&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver serializes, Postgres strips the offset&lt;/td&gt;
&lt;td&gt;stored as &lt;code&gt;10:00&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read back: bare string, no zone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-17 10:00:00&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver resolves against local time (UTC+8)&lt;/td&gt;
&lt;td&gt;10:00 Beijing = &lt;strong&gt;02:00 UTC&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wrote 10:00 UTC. Read 02:00 UTC.&lt;/p&gt;

&lt;p&gt;Eight hours. And it compounds — write that value back and you lose another eight.&lt;/p&gt;

&lt;p&gt;The number isn't special. It's just my UTC offset. In New York it would be five hours, in the other direction. In London during summer, one.&lt;/p&gt;

&lt;p&gt;And in UTC it is &lt;strong&gt;zero&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which is why every test passed
&lt;/h2&gt;

&lt;p&gt;Read that last line again, because it's the whole story.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Actions runners: UTC&lt;/li&gt;
&lt;li&gt;Your Docker container, unless you went out of your way: UTC&lt;/li&gt;
&lt;li&gt;Your production server: almost certainly UTC&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the local offset is zero, the write-side and read-side interpretations agree exactly. The bug doesn't produce a small error under UTC. It produces &lt;em&gt;no error at all&lt;/em&gt;. This isn't a flaky test or a rare edge case — in a UTC environment the behavior is genuinely, completely correct.&lt;/p&gt;

&lt;p&gt;So my test suite wasn't failing to catch a bug. It was running in the one environment where the bug does not exist.&lt;/p&gt;

&lt;p&gt;There is exactly one way to trigger it: run code that reads and writes timestamps &lt;strong&gt;from a machine whose clock isn't UTC&lt;/strong&gt;. A developer laptop. Which is precisely what I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The expensive version
&lt;/h2&gt;

&lt;p&gt;I needed to backfill a column — copy timestamps from an old table into a new one. Straightforward script: read rows, transform, write rows.&lt;/p&gt;

&lt;p&gt;I ran it from my laptop.&lt;/p&gt;

&lt;p&gt;Every row it touched moved eight hours. The script was correct. The logic was correct. It would have been fine on the server. It just happened to make a round trip through a JavaScript runtime sitting in UTC+8, and paid the toll on the way through.&lt;/p&gt;

&lt;p&gt;The rule I now follow without exception:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A backfill must never round-trip a timestamp through the application layer.&lt;/strong&gt; Copy it inside the database.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;new_table&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;old_table&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres moving a value from one column to another cannot get the timezone wrong, because no timezone is ever inferred. There is no JS runtime in the path to guess with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it going forward
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;timestamptz&lt;/code&gt;.&lt;/strong&gt; In Drizzle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timestamptz&lt;/code&gt; doesn't actually store a timezone either — that's a common misconception; Postgres normalizes to UTC internally. But the wire format carries an offset in both directions, so the driver never has to guess. The round trip becomes lossless no matter what timezone anything is running in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit what you already have.&lt;/strong&gt; My schema turned out to be a mix: a handful of newer tables had &lt;code&gt;withTimezone: true&lt;/code&gt; and everything older didn't. Nothing in the application code distinguishes them. This finds them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'timestamp%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Set &lt;code&gt;TZ=UTC&lt;/code&gt; for your local runtime.&lt;/strong&gt; This is a band-aid, not a fix — it makes your laptop behave like CI, which hides the bug rather than removing it. But it stops you from making things worse while you migrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually took away
&lt;/h2&gt;

&lt;p&gt;I had assumed a green test suite meant the code was correct. What it actually meant was: &lt;em&gt;the code is correct in the environment the tests run in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Timezone is an ambient property of the machine, not an input to my tests. So is locale. So is filesystem case sensitivity. So is CPU architecture. My CI wasn't wrong — it just isn't a laptop in UTC+8, and it never will be.&lt;/p&gt;

&lt;p&gt;If your infrastructure is UTC top to bottom and your team isn't, that gap is where this class of bug lives. You don't close it by writing better tests. You close it by not letting the ambient environment participate in the answer — which, for timestamps, means &lt;code&gt;timestamptz&lt;/code&gt; and doing data migrations in SQL.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an indie developer running several small SaaS products solo. This one cost me a day and a data migration, so I figured I'd write it down.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>node</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
