<?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: Ender Yentar</title>
    <description>The latest articles on DEV Community by Ender Yentar (@enderyentar).</description>
    <link>https://dev.to/enderyentar</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%2F4116457%2F050fafb4-8316-482e-b9e4-c3c94f8613a0.jpeg</url>
      <title>DEV Community: Ender Yentar</title>
      <link>https://dev.to/enderyentar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/enderyentar"/>
    <language>en</language>
    <item>
      <title>Your backfill is a photograph</title>
      <dc:creator>Ender Yentar</dc:creator>
      <pubDate>Wed, 09 Sep 2026 03:24:07 +0000</pubDate>
      <link>https://dev.to/enderyentar/your-backfill-is-a-photograph-2kgj</link>
      <guid>https://dev.to/enderyentar/your-backfill-is-a-photograph-2kgj</guid>
      <description>&lt;p&gt;We were getting ready to make a column &lt;code&gt;NOT NULL&lt;/code&gt;. Standard preparation: count the rows that would violate it. The count was not zero.&lt;/p&gt;

&lt;p&gt;Seven API keys had no owning organization. That looked like a key problem, so we went looking at how keys are created.&lt;/p&gt;

&lt;p&gt;It was not a key problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keys were innocent
&lt;/h2&gt;

&lt;p&gt;All seven belonged to the same account. And that account had no organization either. Widening the query, two accounts were in that state. Both had signed up recently, within the same three week window.&lt;/p&gt;

&lt;p&gt;That reframed the question. Keys read &lt;code&gt;organization_id&lt;/code&gt; off the user who owns them. If the user has none, every key that user creates is born without one. The keys were downstream of something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;Two weeks earlier we had run a migration that created an organization for every existing user and linked them up. It worked. Every row we had at that moment was correct.&lt;/p&gt;

&lt;p&gt;Then we shipped it and moved on.&lt;/p&gt;

&lt;p&gt;Nobody wired the signup path.&lt;/p&gt;

&lt;p&gt;The proof took one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"Organization("&lt;/span&gt; backend/app/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero hits in application code. The only places that constructed an &lt;code&gt;Organization&lt;/code&gt; were the migration itself and the tests. So from the moment the migration finished, every new account was born outside the ownership chain, and the data drifted a little further from correct with each signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody noticed for two weeks
&lt;/h2&gt;

&lt;p&gt;Because nothing read the column yet.&lt;/p&gt;

&lt;p&gt;The organization was groundwork for permission checks that had not shipped. No request failed. No error was logged. No user saw anything wrong. The system behaved exactly as it had before, because the broken part was not load bearing.&lt;/p&gt;

&lt;p&gt;This is the uncomfortable shape of the bug: it produced no symptom, and the absence of a symptom is what let it grow. It only surfaced because we went looking for something else, and it had been quietly blocking that something else the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  We had already written the lesson down
&lt;/h2&gt;

&lt;p&gt;While reading the code, we found this in a helper module, in a comment above a completely different function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A backfill is a photograph. A dual write is keeping the photograph current.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Someone on this project learned that lesson, wrote it down, applied it to inboxes, and then did not apply it to users. The knowledge was in the repository. It just was not attached to the thing that needed it.&lt;/p&gt;

&lt;p&gt;A backfill answers "what is true right now". It cannot answer "what stays true tomorrow". Those are two different jobs and shipping the first one feels like finishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix we did not make
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to create the organization at signup. We counted the paths that create a user:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normal email signup&lt;/li&gt;
&lt;li&gt;Google sign-in&lt;/li&gt;
&lt;li&gt;Two separate billing paths&lt;/li&gt;
&lt;li&gt;Admin seeding&lt;/li&gt;
&lt;li&gt;The demo system user&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six call sites. Adding the same three lines to six places would have made today's bug six times more likely, not less, because the seventh path gets written next month by someone who never reads this post.&lt;/p&gt;

&lt;p&gt;So we took the guarantee off the caller entirely. It is a session level hook that runs before every flush:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@event.listens_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before_flush&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_attach_personal_organization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Assign the relationship, not the foreign key. See below.
&lt;/span&gt;            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only looks at &lt;code&gt;session.new&lt;/code&gt;, so flushing an existing user a second time does not open a second organization. If &lt;code&gt;organization_id&lt;/code&gt; is already set, it leaves it alone, which is what lets the backfill migration and the tests do their own thing.&lt;/p&gt;

&lt;p&gt;Now it does not matter which path creates the user, or whether that path knows organizations exist.&lt;/p&gt;

&lt;p&gt;There is a real cost to this and it is worth saying out loud: &lt;code&gt;grep -rn "Organization("&lt;/code&gt; still finds nothing useful in the application code. We traded an explicit call for an invisible one. The mitigation is signposting: the model module says in its docstring that organizations are born in a &lt;code&gt;before_flush&lt;/code&gt; event and that grepping will not find the call. If you use this pattern, write that sign, because the next person will grep first.&lt;/p&gt;

&lt;p&gt;The test says the same thing out loud. It does not go through the signup endpoint, because that would only prove the endpoint works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_guarantee_does_not_depend_on_the_caller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SessionLocal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw-insert@test.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rawinsert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone later moves the guarantee back into the signup handler, this test goes red. That is its entire job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that bit us on the way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You cannot flush inside &lt;code&gt;before_flush&lt;/code&gt;.&lt;/strong&gt; The first version added the organization, flushed to get its id, and assigned it to the foreign key. SQLAlchemy raises "Session is already flushing" and all four tests fail at once. The fix was to stop thinking in ids: assign the relationship, and let SQLAlchemy work out the insert order and the key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The backfill migration passed locally and failed on Postgres.&lt;/strong&gt; It used &lt;code&gt;sa.table()&lt;/code&gt;, the lightweight construct, which has no primary key definition, so &lt;code&gt;inserted_primary_key&lt;/code&gt; comes back empty and indexing it raises. &lt;code&gt;sa.Table()&lt;/code&gt; with an explicit primary key column fixes it.&lt;/p&gt;

&lt;p&gt;We never would have seen this locally, because our test suite runs on SQLite and Alembic does not run there at all. A small staging box with the production schema caught it. That is a &lt;a href="https://dev.to/enderyentar/sqlite-doesnt-enforce-foreign-keys-by-default-and-it-cost-us-three-bugs-1ii9"&gt;separate story&lt;/a&gt;, and it is the same story.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do now
&lt;/h2&gt;

&lt;p&gt;After any backfill, three questions before it counts as shipped:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What creates new rows of this kind, and does that path set the field?&lt;/li&gt;
&lt;li&gt;If the answer is "nothing reads it yet", what will read it, and when?&lt;/li&gt;
&lt;li&gt;Is the guarantee attached to a caller, or to the data?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first question is the one we skipped. The third is the one that actually fixed it.&lt;/p&gt;

&lt;p&gt;Production is clean now: no accounts without an organization, no keys without an owner, no broken links between the two. The proof was taken inside a transaction that was rolled back, so verifying the fix did not create a row to explain later.&lt;/p&gt;

&lt;p&gt;We hit this while building &lt;a href="https://mailflat.net/docs/api-keys" rel="noopener noreferrer"&gt;MailFlat&lt;/a&gt;, where every API key belongs to an inbox, which belongs to an account, which belongs to an organization. A chain is only as good as the day someone forgets to attach the next link.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>migrations</category>
      <category>database</category>
    </item>
    <item>
      <title>SQLite doesn't enforce foreign keys by default, and it cost us three bugs</title>
      <dc:creator>Ender Yentar</dc:creator>
      <pubDate>Tue, 08 Sep 2026 21:17:38 +0000</pubDate>
      <link>https://dev.to/enderyentar/sqlite-doesnt-enforce-foreign-keys-by-default-and-it-cost-us-three-bugs-1ii9</link>
      <guid>https://dev.to/enderyentar/sqlite-doesnt-enforce-foreign-keys-by-default-and-it-cost-us-three-bugs-1ii9</guid>
      <description>&lt;p&gt;Our test suite runs on SQLite. Production runs on PostgreSQL. That is a common setup and for a long time it felt free: tests were fast, isolated, and green.&lt;/p&gt;

&lt;p&gt;Over about three weeks we hit three separate bugs that all came from the same gap. None of them were caught by 1200+ passing tests, because the tests were running on an engine that quietly forgives what production rejects.&lt;/p&gt;

&lt;p&gt;Here they are, in the order we found them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Two tests that were red in production and green locally
&lt;/h2&gt;

&lt;p&gt;We were making a schema change, so we ran the suite against a real Postgres instance for once. Two tests failed. On SQLite the same commit was 1241/1241 green.&lt;/p&gt;

&lt;p&gt;First reaction: today's change broke something. We stashed it and re-ran against a fresh Postgres database. &lt;strong&gt;The same two tests failed.&lt;/strong&gt; So they had been broken for a while and nobody had seen it.&lt;/p&gt;

&lt;p&gt;Both were test bugs, not product bugs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# To simulate "an inbox owned by someone else"
&lt;/span&gt;&lt;span class="n"&gt;other_user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;   &lt;span class="c1"&gt;# a user id that does not exist
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite accepts that row. Postgres rejects it with &lt;code&gt;inboxes_user_id_fkey&lt;/code&gt;. And the test was weak on top of being broken: it wanted to prove "another person's inbox is not accessible", but what it actually created was "an inbox owned by nobody".&lt;/p&gt;

&lt;p&gt;The second one was subtler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# no ORDER BY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unordered &lt;code&gt;.first()&lt;/code&gt;. SQLite happened to return the admin the test needed. Postgres returned a different admin, and the test failed looking for an inbox that belonged to someone else.&lt;/p&gt;

&lt;p&gt;Neither bug was in the product. Both were in tests that had been passing for weeks while measuring the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A migration that could not fail locally, because it never ran locally
&lt;/h2&gt;

&lt;p&gt;Later we wrote a data migration to backfill some rows. It looked fine. It passed review. Then we ran it against Postgres and it blew up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;organizations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;region&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organizations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(...))&lt;/span&gt;
&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inserted_primary_key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# IndexError: tuple index out of range
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sa.table()&lt;/code&gt; is SQLAlchemy's lightweight table construct. It has no primary key definition, so the driver never asks for the generated id and &lt;code&gt;inserted_primary_key&lt;/code&gt; comes back empty. The fix is a real &lt;code&gt;sa.Table()&lt;/code&gt; with an explicit primary key column.&lt;/p&gt;

&lt;p&gt;The point is not the API detail. The point is this: &lt;strong&gt;our test suite does not run Alembic at all.&lt;/strong&gt; Tests create the schema directly. So migration code the code that runs against production data, once, with no undo was the least tested code in the repository. Not under-tested. Zero lines executed.&lt;/p&gt;

&lt;p&gt;We caught this one on a staging box with real Postgres. If we had not had one, we would have found it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deleted rows that came back to life
&lt;/h2&gt;

&lt;p&gt;The third one was the strangest.&lt;/p&gt;

&lt;p&gt;We changed a uniqueness check to ask the &lt;code&gt;api_keys&lt;/code&gt; table instead of a legacy column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ApiKey&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revoked&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This inbox already has a key.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suddenly, brand new inboxes started reporting that they already had a key.&lt;/p&gt;

&lt;p&gt;The chain took a while to see:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;api_keys.inbox_id&lt;/code&gt; has &lt;code&gt;ondelete="CASCADE"&lt;/code&gt;. In production Postgres, deleting an inbox
deletes its key rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite does not enforce foreign keys unless you turn them on.&lt;/strong&gt; So in tests, deleting
an inbox left its key rows behind as orphans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite reuses row ids.&lt;/strong&gt; A newly created inbox could take the id of a deleted one, and
inherit its orphaned key rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So a fresh inbox "already had a key" a key belonging to an inbox that had been deleted.&lt;/p&gt;

&lt;p&gt;The old code never saw this, because the old check read a column on the inbox itself, and on a new inbox that column is NULL. The bug was not new. It became visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and what it does not fix
&lt;/h2&gt;

&lt;p&gt;One line, in the engine setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@event.listens_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sqlite_pragmas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dbapi_connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connection_record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dbapi_connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA foreign_keys=ON&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite ships with foreign key enforcement &lt;strong&gt;off&lt;/strong&gt; for backwards compatibility. It has been that way for years and it is documented, but the default is silence: no warning, no error, just a database that accepts rows Postgres would refuse.&lt;/p&gt;

&lt;p&gt;Turning it on made our tests behave like production. Case 3 disappeared. Case 1 would have been caught the day it was written.&lt;/p&gt;

&lt;p&gt;But be clear about what one pragma does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It does not make SQLite run your migrations. Case 2 is still invisible locally.&lt;/li&gt;
&lt;li&gt;It does not change id allocation. SQLite still reuses ids; Postgres sequences do not.&lt;/li&gt;
&lt;li&gt;It does not make the two engines equivalent. It closes one specific gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we changed beyond the pragma
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Migrations get a real Postgres before they run anywhere else.&lt;/strong&gt; Not the full suite, just the migration, against a database that has the production schema. Ours runs on a small staging machine. Twice now, that box caught something that SQLite structurally could not: once the migration above, once a duplicate revision id that had forked the migration tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Green" now has a scope.&lt;/strong&gt; A green suite means "green on SQLite". That sentence used to be implicit and it was doing a lot of quiet damage. Writing it down changed how we read the result.&lt;/p&gt;

&lt;p&gt;The uncomfortable version of the lesson: for three weeks our tests were measuring a different database than the one our users touch. They were not lying. We were reading them as if they said more than they did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We hit these while building &lt;a href="https://mailflat.net" rel="noopener noreferrer"&gt;MailFlat&lt;/a&gt;, an email API where agents and test suites get real inboxes. The bug journal these came from is kept per-incident, symptom first, which is why three separate weeks turned out to be one story.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>testing</category>
      <category>python</category>
      <category>database</category>
    </item>
  </channel>
</rss>
