<?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>How to Stop an Image Model From Ignoring Your Reference Image</title>
      <dc:creator>libersand</dc:creator>
      <pubDate>Fri, 21 Aug 2026 14:16:12 +0000</pubDate>
      <link>https://dev.to/libersand/how-to-stop-an-image-model-from-ignoring-your-reference-image-i5a</link>
      <guid>https://dev.to/libersand/how-to-stop-an-image-model-from-ignoring-your-reference-image-i5a</guid>
      <description>&lt;p&gt;A user uploaded a floor plan, typed "remove the room labels", and got back the same plan — labels still on it. Tried again, same result.&lt;/p&gt;

&lt;p&gt;The interesting part: the model was not ignoring the instruction. Another line in our own prompt was quietly outranking it.&lt;/p&gt;

&lt;p&gt;I work on &lt;a href="https://floordrafter.com/" rel="noopener noreferrer"&gt;FloorDrafter&lt;/a&gt;, an AI floor plan generator. A big part of the product takes a reference image — a photo of a hand sketch, a scanned blueprint, an existing plan — and applies one requested change to it. That turns out to be a much harder prompt problem than generating from scratch, because now the model has two masters: the picture and the sentence.&lt;/p&gt;

&lt;p&gt;Here are six things we learned making it behave, plus how we measure whether a prompt change actually helped.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Separate "how to draw" from "what to draw"
&lt;/h2&gt;

&lt;p&gt;Our style presets started life as single strings:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;top-down 2D color floor plan: flat orthographic overhead view, color-filled rooms… &lt;strong&gt;clear room labels and dimension lines with measurements&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Looks harmless. But that string got injected into the same block as the user's instruction, under a rule that said everything in the block must be delivered fully and visibly. So the block read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REQUESTED CHANGE
- Representation: ...clear room labels and dimension lines...
- remove the room labels          &amp;lt;- the user's own words
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two contradictory requirements, both marked mandatory. The style description won, because it was more specific.&lt;/p&gt;

&lt;p&gt;The fix was to split every style into two halves: &lt;code&gt;render&lt;/code&gt; (viewpoint, line weight, materials, lighting — &lt;em&gt;how&lt;/em&gt; to draw) and &lt;code&gt;annotations&lt;/code&gt; (room labels, dimension lines — &lt;em&gt;what content&lt;/em&gt; to include). The from-scratch branch names both. The edit-an-existing-image branch names only &lt;code&gt;render&lt;/code&gt;, because labels and dimensions are content that already exists in the reference — whether they stay is the user's call, not the style preset's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generalizable version:&lt;/strong&gt; if your prompt is assembled from fragments, audit every fragment for whether it smuggles in content requirements. A fragment that describes &lt;em&gt;form&lt;/em&gt; is safe to inject anywhere. A fragment that describes &lt;em&gt;content&lt;/em&gt; can collide with user intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Write the rule as "only change what's named", not as a list of prohibitions
&lt;/h2&gt;

&lt;p&gt;The obvious way to protect a reference image is a MUST NOT list: don't move walls, don't resize rooms, don't change the window positions.&lt;/p&gt;

&lt;p&gt;That list locks the user out. Someone who wants to "keep the layout but knock down the kitchen wall" now can't, because you preemptively banned it.&lt;/p&gt;

&lt;p&gt;Inverting it works better: change only what the instruction names, leave everything else as it is in the reference. The granularity comes from the user's own sentence instead of from your list.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Explicitly authorize the requested change, or you get a copy
&lt;/h2&gt;

&lt;p&gt;This one is counterintuitive. After we tightened the fidelity language, output quality went up — and then users started reporting that nothing changed at all. The model had become so conservative it was returning something close to the original image.&lt;/p&gt;

&lt;p&gt;Fidelity instructions alone teach the model that changing things is risky. You have to also grant full permission to execute the named change, and add a closing self-check that confirms the change is actually visible. Both halves, or you trade one failure mode for the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Ban &lt;em&gt;adding&lt;/em&gt;, never ban &lt;em&gt;existing&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Our closing prohibition originally read, roughly, "no text beyond room labels and dimensions."&lt;/p&gt;

&lt;p&gt;Then a user uploaded a full drawing sheet — not a bare floor plan, but a title block, project name, legend, scale bar, north arrow, notes. The model read that prohibition literally and deleted all of it. A real case: a church site plan came back with the main title, the church name, the SIMBOLOGÍA legend, the scale bar, the NOTA block and five bottom notes all gone. The floor plan survived. Everything around it did not.&lt;/p&gt;

&lt;p&gt;A prohibition on &lt;em&gt;text&lt;/em&gt; cannot distinguish "text you invented" from "text that was already there". So the rule can only forbid &lt;strong&gt;adding&lt;/strong&gt;. Preserving what exists needs its own affirmative clause — and it needs one more sentence that most people miss:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Changing the style is not a reason to delete content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that, picking a monochrome CAD style gets read as "so only keep room labels and dimensions", and you are back to deleting the sheet.&lt;/p&gt;

&lt;p&gt;Both clauses shipped, and both are asserted in the test suite described at the end — title blocks, legends, scale bars and note blocks are now on an explicit preserve list, copied verbatim.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Some constraints must be immune to your own exemption clauses
&lt;/h2&gt;

&lt;p&gt;Once you have a rule that says "only change what's named", you will naturally write self-checks that begin &lt;em&gt;"Setting aside what the change named…"&lt;/em&gt;. That phrasing is a loophole.&lt;/p&gt;

&lt;p&gt;A user photographed a C-shaped outline, 16 by 24, and wrote &lt;strong&gt;"2 units on 1 footprint"&lt;/strong&gt;. The model duplicated the entire C shape and butted the two copies together into a 32 by 24 building. Every dimension label was preserved perfectly. The geometry had doubled.&lt;/p&gt;

&lt;p&gt;Two failures stacked here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The protection clause banned &lt;em&gt;moving&lt;/em&gt; the footprint but not duplicating, scaling or extending it.&lt;/li&gt;
&lt;li&gt;The self-check started with the exemption phrase, so the model decided the footprint change fell under "what the change named" and skipped the check entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The footprint check had to be rewritten to start with &lt;strong&gt;"Regardless of what the change named…"&lt;/strong&gt;. Geometric integrity is not negotiable by the instruction; content is.&lt;/p&gt;

&lt;p&gt;There is a second lesson buried in that example. "2 units on 1 footprint" contains a goal (2 units) and a boundary (1 footprint) in one breath. Models reliably grab the goal and drop the boundary, so the prompt has to say out loud that the user's sentence may contain limits, not just targets.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A capability needs an exemption in every section that can override it
&lt;/h2&gt;

&lt;p&gt;Back to the labels. "Remove the labels" failed because three separate parts of the prompt each independently reinstated them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The rule authorized restyling and adding — but never mentioned deleting.&lt;/li&gt;
&lt;li&gt;The sheet-protection clause said do not drop, blank out, condense or summarise any existing element.&lt;/li&gt;
&lt;li&gt;The closing self-check saw missing annotations and told the model to redo them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Patch any one of those and the other two still win. Deletion had to be named in all three places at once — and scoped tightly ("delete only what was named; deleting anything else is an error"), because a broad deletion permission takes you straight back to lesson 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern:&lt;/strong&gt; when you add a capability to a long prompt, grep the whole thing for sections that could veto it. Long prompts develop internal politics.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we know any of this worked
&lt;/h2&gt;

&lt;p&gt;Prompt changes are easy to rationalize and hard to verify. We ran fixed probes: same reference image, same instruction, repeated runs per variant, counting outcomes by hand against the previous prompt. The denominators below count elements rather than runs — dimension-chain segments in the reference, room labels across the probe set, and images in the sweep.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original dimension chain survives&lt;/td&gt;
&lt;td&gt;~0.6 / 9&lt;/td&gt;
&lt;td&gt;~7 / 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Room labels preserved&lt;/td&gt;
&lt;td&gt;~5 / 20&lt;/td&gt;
&lt;td&gt;~15 / 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images with invented dimensions&lt;/td&gt;
&lt;td&gt;9 / 10&lt;/td&gt;
&lt;td&gt;0 / 10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those numbers are small-sample and hand-counted. That is fine — they are decision-grade, not paper-grade. The point is that "this prompt feels better" is not a finding, and a fixed probe costs an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unit-testing a prompt
&lt;/h2&gt;

&lt;p&gt;The six lessons above are all invariants: properties the assembled prompt must have, in every branch, forever. Nothing stops a future edit from quietly removing one — they are just strings in a template.&lt;/p&gt;

&lt;p&gt;So they are unit tests now. The prompt builder is a pure function, which makes this cheap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bans only added text, never existing text&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildPrompt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hasReference&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="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ADD_ONLY_TEXT_RULE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// "do not add … text"&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doesNotMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LEGACY_TEXT_BAN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the old blanket ban&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;states that a style change is not a reason to delete content&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;makes the footprint check immune to the change exemption&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildPrompt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hasReference&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="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FOOTPRINT_UNCONDITIONAL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "regardless of what the change named"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 28 of them. They do not test the model — they test that the string we send still contains the clauses we worked out. Each one encodes a specific failure mode we found and fixed, so it cannot come back unnoticed.&lt;/p&gt;

&lt;p&gt;If you maintain a prompt longer than a screenful, this is the highest-value hour you can spend on it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Split prompt fragments into &lt;em&gt;form&lt;/em&gt; and &lt;em&gt;content&lt;/em&gt;; only form is safe to inject everywhere.&lt;/li&gt;
&lt;li&gt;Prefer "change only what is named" over a list of prohibitions.&lt;/li&gt;
&lt;li&gt;Grant the change explicitly, or fidelity language will suppress it.&lt;/li&gt;
&lt;li&gt;Forbid adding, never forbid existing.&lt;/li&gt;
&lt;li&gt;Make integrity constraints immune to your own exemption phrasing.&lt;/li&gt;
&lt;li&gt;New capabilities need an exemption in every section that could veto them.&lt;/li&gt;
&lt;li&gt;Probe with fixed inputs and count. Then lock the result in with tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is specific to floor plans. Any time you send an image plus an instruction and expect the untouched parts to stay untouched, the same failure modes are waiting.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>promptengineering</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <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>
