<?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: Arseniy Potapov</title>
    <description>The latest articles on DEV Community by Arseniy Potapov (@potapov).</description>
    <link>https://dev.to/potapov</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%2F3782696%2F7aefe108-ee8b-42f0-b8ee-de6f4dfe9a14.jpeg</url>
      <title>DEV Community: Arseniy Potapov</title>
      <link>https://dev.to/potapov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/potapov"/>
    <language>en</language>
    <item>
      <title>Database Race Conditions Your Tests Miss</title>
      <dc:creator>Arseniy Potapov</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:45:55 +0000</pubDate>
      <link>https://dev.to/potapov/database-race-conditions-your-tests-miss-24ag</link>
      <guid>https://dev.to/potapov/database-race-conditions-your-tests-miss-24ag</guid>
      <description>&lt;p&gt;I once spent three days on a production race that had passed tests, lint and type checks, and I've written it up twice: as &lt;a href="https://potapov.dev/blog/claude-code-quality/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;the transactions that interlocked&lt;/a&gt; and as &lt;a href="https://potapov.dev/blog/ai-slop-detection/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;the counter that lost updates&lt;/a&gt;. Some of that code was AI-assisted. That's the pattern I rank P1 when I review agent-written diffs: correct single-threaded logic in a world that isn't single-threaded. The tests were green and the race still shipped, because none of them forced two workers onto the same row at the same moment.&lt;/p&gt;

&lt;p&gt;That's the question I now ask of every &lt;a href="https://potapov.dev/blog/trust-agent-tests-and-reviews/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;green check on agent-written code&lt;/a&gt;: which wrong version would it reject? For shared database state, you answer it by controlling when two workers read and write the same records. Pick the ordering that breaks the rule your data must keep, such as "both increments to a counter survive." That rule gives you a final value to assert, plus any error the caller has to handle.&lt;/p&gt;

&lt;p&gt;Below, I'll reproduce two different failures on PostgreSQL 17 and MongoDB 8. A deadlock aborts a transaction loudly. A stale write loses an update silently. Then I'll compare four guards: atomic updates, consistent lock ordering, cooperative advisory locks and version checks. The examples are constructed, small enough to paste into two terminals, and every one ran on scratch servers. Each ends as a regression test you can keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Workers Are Enough to Expose the Gap
&lt;/h2&gt;

&lt;p&gt;You don't need production traffic to make two operations overlap. Two workers touching one record are enough, and a small schedule lets you follow each step.&lt;/p&gt;

&lt;p&gt;Say worker A processes records &lt;code&gt;41&lt;/code&gt; and &lt;code&gt;42&lt;/code&gt;, while worker B processes &lt;code&gt;42&lt;/code&gt; and &lt;code&gt;43&lt;/code&gt;. The jobs have different IDs, but both touch record &lt;code&gt;42&lt;/code&gt;. If a test runs A to completion before starting B, B sees A's finished changes. That test never asks what happens when B changes &lt;code&gt;42&lt;/code&gt; between A's read and its dependent write.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyldkmvxkkleh4gyjhn87.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyldkmvxkkleh4gyjhn87.png" alt="Sequence diagram: Worker A reads record 42 and pauses. Worker B changes the value and gets a write acknowledgement. A resumes and writes from the old value." width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Time runs downward. The diagram shows operation order, not measured durations.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At A's final arrow, its calculation depends on a value B has already changed. Whether the database accepts that write depends on the operation and its guards. Keep that read/write boundary in mind for the MongoDB section.&lt;/p&gt;

&lt;p&gt;So check both the record IDs and the operation order. A test that gives every worker separate fixtures avoids the overlap entirely. A test with shared fixtures can still miss it if A finishes before B reaches the record. Starting two jobs at once leaves the interleaving to the scheduler, so neither setup guarantees the sequence in the diagram.&lt;/p&gt;

&lt;p&gt;The timeline narrows the investigation to one question: can another worker change the state this operation still relies on? PostgreSQL adds a second failure to tell apart, where two transactions each hold a lock the other needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostgreSQL: Follow the Lock Cycle
&lt;/h2&gt;

&lt;p&gt;A deadlock starts with each transaction holding something the other needs. Here, two workers each increment two counters, but visit the rows in opposite order. Both operations must finish for each counter to reach &lt;code&gt;2&lt;/code&gt;. The SQL uses database-side increments, so there's no stale application calculation to blame.&lt;/p&gt;

&lt;p&gt;Open two independent &lt;code&gt;psql&lt;/code&gt; connections to the same disposable database. Run this setup once, outside a transaction, before either worker starts. I ran everything here on PostgreSQL 17.11 at &lt;code&gt;READ COMMITTED&lt;/code&gt;.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;race_counters&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;race_counters&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the numbered groups in order, switching terminals where indicated. Submit A's second update, then switch to B while A waits. Each transaction holds its first row lock until it ends.&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="c1"&gt;-- 1. Session A&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;READ&lt;/span&gt; &lt;span class="k"&gt;COMMITTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;race_counters&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Session B&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;READ&lt;/span&gt; &lt;span class="k"&gt;COMMITTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;race_counters&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Session A: waits for B&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;race_counters&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Session B: closes the cycle&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;race_counters&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A now needs row &lt;code&gt;2&lt;/code&gt;, held by B, and B needs row &lt;code&gt;1&lt;/code&gt;, held by A. Neither can finish without the other releasing a lock. After &lt;code&gt;deadlock_timeout&lt;/code&gt; (one second by default), PostgreSQL detects the cycle and aborts one transaction. Don't depend on which one it picks. PostgreSQL's &lt;a href="https://www.postgresql.org/docs/17/explicit-locking.html#LOCKING-DEADLOCKS" rel="noopener noreferrer"&gt;deadlock documentation&lt;/a&gt; describes this and recommends a consistent acquisition order.&lt;/p&gt;

&lt;p&gt;In my run, A was the victim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  deadlock detected
DETAIL:  Process 872 waits for ShareLock on transaction 741; blocked by process 873.
Process 873 waits for ShareLock on transaction 740; blocked by process 872.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;ROLLBACK;&lt;/code&gt; in the terminal reporting &lt;code&gt;deadlock detected&lt;/code&gt;, and &lt;code&gt;COMMIT;&lt;/code&gt; in the survivor. &lt;code&gt;SELECT * FROM race_counters ORDER BY id;&lt;/code&gt; then shows &lt;code&gt;1&lt;/code&gt; in both counters. The victim's first increment disappeared with its transaction. That's an explicit failure the caller must recover from, unlike two successful writes silently replacing each other's result.&lt;/p&gt;

&lt;p&gt;Now change the acquisition order. With both transactions closed, reset with &lt;code&gt;UPDATE race_counters SET n = 0;&lt;/code&gt;. Both workers use this same sequence:&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;BEGIN&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;READ&lt;/span&gt; &lt;span class="k"&gt;COMMITTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;race_counters&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;race_counters&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pause A after its first update and start B. B waits on row &lt;code&gt;1&lt;/code&gt; before it holds row &lt;code&gt;2&lt;/code&gt;, which leaves A free to finish. Complete A, then B, and both counters reach &lt;code&gt;2&lt;/code&gt;. That removes the cycle at the cost of waiting where the workers overlap. Apply the order across every write path that takes part, including any extra rows those paths touch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sorting IDs in application memory only helps if the database acquires the locks in that order. An &lt;code&gt;UPDATE ... WHERE id IN (1, 2)&lt;/code&gt; doesn't promise to visit rows in the listed order.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Keep a bounded retry path for SQLSTATE &lt;code&gt;40P01&lt;/code&gt; (&lt;code&gt;deadlock_detected&lt;/code&gt;). Roll back and start a fresh transaction that reruns its reads, decisions and writes. The aborted transaction can't continue, and rerunning only its last statement in a new transaction drops the earlier work. PostgreSQL's &lt;a href="https://www.postgresql.org/docs/17/mvcc-serialization-failure-handling.html" rel="noopener noreferrer"&gt;retry guidance&lt;/a&gt; makes that whole-transaction boundary explicit and separates deadlocks from serialization failures (&lt;code&gt;40001&lt;/code&gt;). Cap the attempts, add backoff with jitter and surface exhaustion to the caller. When I replayed the victim's whole transaction after the survivor committed, both counters reached &lt;code&gt;2&lt;/code&gt;. Repeated failures still mean you should look at the lock cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the Resource Your Lock Protects
&lt;/h2&gt;

&lt;p&gt;A batch ID is a tempting advisory-lock key. But batches 101 and 102 can both modify tenant 42's records, and different keys let both workers proceed. Choose the resource that owns the invariant. If tenant 42 may hold at most two reservations, both workers must coordinate on tenant 42.&lt;/p&gt;

&lt;p&gt;PostgreSQL's &lt;a href="https://www.postgresql.org/docs/17/explicit-locking.html#ADVISORY-LOCKS" rel="noopener noreferrer"&gt;advisory locks&lt;/a&gt; are cooperative. Every writer that affects the invariant must take the agreed key before reading or changing the protected state, maintenance scripts included. An advisory lock doesn't lock a table, and it doesn't stop a writer that ignores the convention.&lt;/p&gt;

&lt;p&gt;I use the key &lt;code&gt;(7, 42)&lt;/code&gt;: application namespace &lt;code&gt;7&lt;/code&gt;, tenant &lt;code&gt;42&lt;/code&gt;. The two-integer &lt;a href="https://www.postgresql.org/docs/17/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS" rel="noopener noreferrer"&gt;lock function&lt;/a&gt; lets you reserve a namespace for this operation. Run the setup once in a scratch database, then the transaction on one connection:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;reservations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;booking_id&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;reservations&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;READ&lt;/span&gt; &lt;span class="k"&gt;COMMITTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_xact_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;reservations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;reservations&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;BEGIN&lt;/code&gt;, the lock, the capacity check and the insert in the same transaction on the same connection. &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt; releases at commit or rollback. In autocommit, a standalone lock call ends its protection before the next statement. A session-level &lt;code&gt;pg_advisory_lock&lt;/code&gt; lives longer and survives rollback, which gets messy with pooled connections. The explicit &lt;a href="https://www.postgresql.org/docs/17/transaction-iso.html#XACT-READ-COMMITTED" rel="noopener noreferrer"&gt;&lt;code&gt;READ COMMITTED&lt;/code&gt;&lt;/a&gt; gives the insert a fresh snapshot after any lock wait.&lt;/p&gt;

&lt;p&gt;Pause worker A before its commit. On a second connection, worker B runs the same transaction with booking ID &lt;code&gt;3&lt;/code&gt;. B waits on &lt;code&gt;(7, 42)&lt;/code&gt;. After A commits, B sees two reservations and inserts nothing. Commit B to finish this case.&lt;/p&gt;

&lt;p&gt;With both transactions closed, reset with &lt;code&gt;TRUNCATE reservations; INSERT INTO reservations VALUES (42, 1);&lt;/code&gt;. Repeat A's transaction and pause before commit. Give B the key &lt;code&gt;(7, 43)&lt;/code&gt; and booking ID &lt;code&gt;3&lt;/code&gt;, while its insert still targets tenant 42. B no longer waits. Let both inserts finish, then commit both transactions, and the count reaches three.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your operation&lt;/th&gt;
&lt;th&gt;Guard to consider&lt;/th&gt;
&lt;th&gt;Contract&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Increment one counter&lt;/td&gt;
&lt;td&gt;Atomic SQL update&lt;/td&gt;
&lt;td&gt;Calculate from the stored value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change several existing rows&lt;/td&gt;
&lt;td&gt;Row locks in consistent order&lt;/td&gt;
&lt;td&gt;All paths follow that order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enforce a tenant-wide rule&lt;/td&gt;
&lt;td&gt;Shared advisory key&lt;/td&gt;
&lt;td&gt;Every relevant writer participates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claim any available job&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Busy jobs may wait for a later pass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;A tenant-wide key serializes participating work for that tenant. Keep the protected transaction short. A global key would make unrelated tenants wait too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Use &lt;a href="https://www.postgresql.org/docs/17/sql-select.html#SQL-FOR-UPDATE-SHARE" rel="noopener noreferrer"&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt;&lt;/a&gt; when claiming any other available queue item satisfies the request. PostgreSQL explicitly allows an inconsistent view there. If your batch must update every required record, silently skipping busy rows changes the result you promised. It doesn't complete that batch safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  MongoDB: An Atomic Write Can Still Replace Stale Data
&lt;/h2&gt;

&lt;p&gt;MongoDB makes each single-document write atomic. A separate read, calculation and write still leaves room for another worker to change the document between those steps. MongoDB's &lt;a href="https://www.mongodb.com/docs/manual/core/write-operations-atomicity/" rel="noopener noreferrer"&gt;atomicity documentation&lt;/a&gt; describes exactly this overwrite when both updates filter only on &lt;code&gt;_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two workers each want to add one to a counter that starts at 10. Both read 10, both calculate 11, and both successfully store 11. The missing increment never produces a database error.&lt;/p&gt;

&lt;p&gt;You need a disposable MongoDB server and the &lt;code&gt;mongosh&lt;/code&gt; shell. I ran these on MongoDB 8.0.16 with &lt;code&gt;mongosh&lt;/code&gt; 2.5.9. Open &lt;code&gt;mongosh --nodb&lt;/code&gt; and paste the blocks in order into the same shell. Set &lt;code&gt;uri&lt;/code&gt; to your scratch server's address (&lt;code&gt;27118&lt;/code&gt; is just my test port, not MongoDB's default). &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;B&lt;/code&gt; use independent connections, and the script completes both reads before either write. The reset replaces only this demo's document in &lt;code&gt;race_demo.counters&lt;/code&gt;.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mongodb://127.0.0.1:27118&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Mongo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getDB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;race_demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;counters&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Mongo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getDB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;race_demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;counters&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&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="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;upsert&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="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&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="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// count: 11&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both updates report &lt;code&gt;matchedCount: 1&lt;/code&gt;, so nothing looks wrong. Each &lt;code&gt;$set&lt;/code&gt; replaces a field with a value the application computed. Replacing the whole document from a stale snapshot can overwrite other fields too. For this counter, express the increment directly with &lt;a href="https://www.mongodb.com/docs/manual/reference/operator/update/inc/" rel="noopener noreferrer"&gt;&lt;code&gt;$inc&lt;/code&gt;&lt;/a&gt;, so MongoDB applies it to the stored value:&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;reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&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;$inc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&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;$inc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// count: 12, version: 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the calculation has to happen in your application from fields it read earlier, use a version check to reject stale input. Put the snapshot's &lt;code&gt;version&lt;/code&gt; in the filter, then increment that version in the same write. I kept the counter arithmetic so the difference stays visible without extra business logic:&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;reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&lt;/span&gt;&lt;span class="dl"&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;save&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateOne&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="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;$inc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matchedCount&lt;/span&gt; &lt;span class="c1"&gt;// 1&lt;/span&gt;
&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matchedCount&lt;/span&gt; &lt;span class="c1"&gt;// 0: stale version rejected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first write moves the version to 1. The second write matches nothing and changes nothing. Check &lt;a href="https://www.mongodb.com/docs/manual/reference/method/db.collection.updateOne/" rel="noopener noreferrer"&gt;&lt;code&gt;matchedCount&lt;/code&gt;&lt;/a&gt; yourself, because a zero match doesn't throw a concurrency exception. With this filter, zero can also mean the document was deleted.&lt;/p&gt;

&lt;p&gt;On a version conflict, reread the document and recompute from the fresh state. When I reread B's snapshot and saved again, it matched and the count reached 12 at version 2. For an automatic retry, cap the attempts (three is a reasonable start), then return a conflict if contention continues. An edit that needs a user's judgment should surface the conflict instead. Retrying the same stale value with a newer version would overwrite the change you just detected.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every writer that changes fields used by the calculation must advance &lt;code&gt;version&lt;/code&gt;, maintenance scripts included. Keep upsert disabled for the guarded write. A missing match is a conflict to handle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The invariant here covers one document. A version check on this counter can't protect a calculation whose inputs also live in other documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the Interleaving a Regression Test
&lt;/h2&gt;

&lt;p&gt;Automate the terminal sequences with two independent connections and a shared committed fixture. Record the server version and transaction settings. Signal completed reads and lock acquisitions before releasing the next operation. Confirm blocked PostgreSQL requests from an observer connection with &lt;code&gt;pg_stat_activity&lt;/code&gt; or &lt;code&gt;pg_blocking_pids&lt;/code&gt;. A sleep can't prove that wait.&lt;/p&gt;

&lt;p&gt;These schedules make good test specifications:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL deadlock.&lt;/strong&gt; Reset both counters to 0. A locks row 1, B locks row 2, then dispatch both second-lock requests without awaiting either. Expect one transaction to report &lt;code&gt;40P01&lt;/code&gt;, the survivor to commit, and both counters at 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ordered locks.&lt;/strong&gt; Reset to 0. A locks row 1, B requests row 1. Confirm B waits, then let A finish and commit. Expect B to complete afterward with both counters at 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advisory key.&lt;/strong&gt; Reset to one reservation. A inserts under &lt;code&gt;(7, 42)&lt;/code&gt;, and B requests that key before checking capacity. Expect B to wait and the count to stay at 2. Repeat with B on &lt;code&gt;(7, 43)&lt;/code&gt;: both insert, and the count reaches 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MongoDB stale write.&lt;/strong&gt; Reset to count 10, version 0. Both read, A writes 11, then B writes its cached 11 after A's acknowledgement. Expect both writes to match and a final count of 11.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic increment.&lt;/strong&gt; Reset to count 10, version 0. Each connection runs the &lt;code&gt;$inc&lt;/code&gt; update. Expect count 12 and version 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version guard.&lt;/strong&gt; Reset to count 10, version 0. Both read, A saves, B tries its stale snapshot. Expect match counts of 1 then 0, count 11 and version 1.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Add recovery assertions to the failure cases. For the deadlock, run the whole-transaction retry after the survivor commits. Both counters must reach 2 no matter which worker failed. For MongoDB, exercise reread and recompute if that's your contract. The result must be count 12 at version 2, and a swallowed conflict must fail the test.&lt;/p&gt;

&lt;p&gt;Give every wait a deadline and clean up both connections on failure. With ordered locks, avoid a barrier that needs both workers to hold row 1, since the guard prevents exactly that. Keep the reproduced case next to your &lt;a href="https://potapov.dev/blog/testing-legacy-code/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;legacy-code regression tests&lt;/a&gt; and run it in &lt;a href="https://potapov.dev/blog/cicd-from-scratch/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;CI&lt;/a&gt; against a disposable database. Passing it covers this schedule, not every possible interleaving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test One Write at a Time
&lt;/h2&gt;

&lt;p&gt;Choose one write in your application: a counter increment, a balance change, or a document replaced from an earlier read. Give it a specific correctness rule before choosing a guard. For a counter, the rule might be "two accepted increments both appear in the final value."&lt;/p&gt;

&lt;p&gt;Then turn that write into a regression test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inventory the writers.&lt;/strong&gt; Name the request handlers, background jobs and maintenance scripts that can change the same records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State the invariant.&lt;/strong&gt; Write down the expected final state and which conflicts or aborts the caller must handle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force the overlap.&lt;/strong&gt; Use two independent sessions and a controlled schedule that exposes the unguarded failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the guard.&lt;/strong&gt; Repeat the test after the change. Assert the final state and the expected errors, including the outcome after any required retry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This covers PostgreSQL and MongoDB operations only. Redis, Elasticsearch, Celery delivery and external side effects bring their own interleavings, and these examples don't prove anything about them.&lt;/p&gt;

&lt;p&gt;If an agent wrote the code, this test is the one its green suite most likely lacks. Keep the failing schedule next to the guard, so a future edit has a concrete behavior to preserve.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More on this on potapov.dev:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://potapov.dev/blog/claude-code-quality/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;Using Claude Code Without Technical Debt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://potapov.dev/blog/ai-slop-detection/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;Detecting AI Slop: A Code Review Checklist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://potapov.dev/blog/trust-agent-tests-and-reviews/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;When to Trust AI Agent Tests and Reviews&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://potapov.dev/blog/testing-legacy-code/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;Testing Untested Legacy Code: A Practitioner's Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://potapov.dev/blog/cicd-from-scratch/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=race-conditions" rel="noopener noreferrer"&gt;Setting Up CI/CD When You Have None&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>mongodb</category>
      <category>database</category>
      <category>testing</category>
    </item>
    <item>
      <title>How to Check Your Test Can Catch the Bug</title>
      <dc:creator>Arseniy Potapov</dc:creator>
      <pubDate>Wed, 21 Oct 2026 18:20:02 +0000</pubDate>
      <link>https://dev.to/potapov/how-to-check-your-test-can-catch-the-bug-3m3</link>
      <guid>https://dev.to/potapov/how-to-check-your-test-can-catch-the-bug-3m3</guid>
      <description>&lt;p&gt;When an agent's PR says "added a regression test, all tests pass", I want to know one thing: would that test have caught the bug? Plenty of them can't. In my own experiments, grading tests that failed on the unfixed code and passed on the upstream fix still accepted every agent patch on two tasks, and two small probes written later rejected 8 of them. I wrote up that evidence, and when a green suite deserves trust, in &lt;a href="https://potapov.dev/blog/trust-agent-tests-and-reviews/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=test-can-catch-the-bug" rel="noopener noreferrer"&gt;When to Trust AI Agent Tests and Reviews&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is the hands-on part. I've told readers before to check that a test fails when you break the code, most recently in &lt;a href="https://potapov.dev/blog/ai-agents-declare-victory/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=test-can-catch-the-bug" rel="noopener noreferrer"&gt;the post on agents declaring victory&lt;/a&gt;. I never showed how. So here's the procedure on a bug small enough to hold in your head: five lines of Python, four checks, real output from every command, and a table of which check catches which false pass.&lt;/p&gt;

&lt;p&gt;Your new test is an oracle for one bug, and the only question is whether it can tell the buggy code from the fixed code. Each of the four checks catches a false pass the others miss:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fail on the base, for the right reason.&lt;/strong&gt; Catches the vacuous test, and filters out tests that go red for an unrelated reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break the repair.&lt;/strong&gt; Catches a test that still passes when the fix is subtly wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A preservation probe.&lt;/strong&gt; Catches the collateral regression the fix's own test never looks at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An oracle the test's author didn't write.&lt;/strong&gt; Catches a test and code that share one wrong assumption.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Fixture: Five Lines and an Off-by-One
&lt;/h2&gt;

&lt;p&gt;The demo is &lt;code&gt;chunk(items, size)&lt;/code&gt;, which splits a list into lists of &lt;code&gt;size&lt;/code&gt;. The base version drops the trailing partial chunk: &lt;code&gt;chunk([1, 2, 3, 4, 5], 2)&lt;/code&gt; returns &lt;code&gt;[[1, 2], [3, 4]]&lt;/code&gt;.&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;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Split items into lists of `size`; the last list may be shorter.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size must be positive&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;size&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&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;chunk([1, 2, 3, 4], 2)&lt;/code&gt; returns &lt;code&gt;[[1, 2], [3, 4]]&lt;/code&gt; on both versions, so a test that only uses exact multiples passes the bug and the fix alike. That's what bothers me about this bug: nothing in such a test is wrong except the numbers it picked. The repair changes one &lt;code&gt;range()&lt;/code&gt; bound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    return [items[i:i + size] for i in range(0, len(items) - size + 1, size)]
&lt;/span&gt;&lt;span class="gi"&gt;+    return [items[i:i + size] for i in range(0, len(items), size)]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole fixture is a tiny git repo you can rebuild in a few minutes. The code above is &lt;code&gt;src/chunks.py&lt;/code&gt;. Commit it and tag it &lt;code&gt;base&lt;/code&gt;, apply the diff on &lt;code&gt;main&lt;/code&gt; and tag that &lt;code&gt;repair&lt;/code&gt;. &lt;code&gt;pyproject.toml&lt;/code&gt; needs two sections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[tool.pytest.ini_options]&lt;/span&gt;
&lt;span class="py"&gt;pythonpath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[tool.mutmut]&lt;/span&gt;
&lt;span class="py"&gt;source_paths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"src/"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;pytest_add_cli_args_test_selection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tests/test_bug.py"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two test files go in with the repair. &lt;code&gt;tests/test_bug.py&lt;/code&gt; is the test under suspicion, with one test, &lt;code&gt;test_keeps_the_partial_last_chunk&lt;/code&gt;, asserting &lt;code&gt;chunk([1, 2, 3, 4, 5], 2) == [[1, 2], [3, 4], [5]]&lt;/code&gt;. &lt;code&gt;tests/test_vacuous.py&lt;/code&gt; holds two tests an agent might write: &lt;code&gt;test_chunk_runs&lt;/code&gt; calls &lt;code&gt;chunk()&lt;/code&gt; and asserts nothing, and &lt;code&gt;test_chunk_returns_something&lt;/code&gt; asserts the result is truthy.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;wrong-reason&lt;/code&gt; tag, one commit on top of &lt;code&gt;repair&lt;/code&gt;, adds a &lt;code&gt;chunk_count(items, size)&lt;/code&gt; helper that returns &lt;code&gt;len(chunk(items, size))&lt;/code&gt;, plus two tests that both assert &lt;code&gt;chunk_count([1, 2, 3, 4, 5], 2) == 3&lt;/code&gt;. &lt;code&gt;tests/test_count.py::test_counts_the_partial_chunk&lt;/code&gt; imports &lt;code&gt;chunk_count&lt;/code&gt; at the top of the file. &lt;code&gt;tests/test_count_runtime.py::test_counts_at_runtime&lt;/code&gt; imports it inside the test function. That difference matters, as you'll see. The probe files for Check 3 and the two scripts get added on &lt;code&gt;main&lt;/code&gt; as you reach them.&lt;/p&gt;

&lt;p&gt;You need Python 3.12 or later (Check 4 uses &lt;code&gt;itertools.batched&lt;/code&gt;), &lt;code&gt;pytest&lt;/code&gt; and &lt;code&gt;git&lt;/code&gt;. &lt;code&gt;mutmut&lt;/code&gt; is optional and POSIX-only, so on Windows run it in WSL. The &lt;code&gt;sed -i&lt;/code&gt; commands below are GNU sed. On macOS write &lt;code&gt;sed -i ''&lt;/code&gt;. Every output below came from Python 3.12.15, &lt;code&gt;pytest&lt;/code&gt; 9.1.1 and &lt;code&gt;mutmut&lt;/code&gt; 3.8.0, run in this order on &lt;code&gt;main&lt;/code&gt;. The output blocks keep the &lt;code&gt;pytest&lt;/code&gt; summary lines and drop tracebacks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;&lt;span class="nv"&gt;pytest&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;9.1.1 &lt;span class="nv"&gt;mutmut&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;3.8.0
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PYTHONDONTWRITEBYTECODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1    &lt;span class="c"&gt;# before the first run, in this shell&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set that variable before anything runs. It stops Python writing new &lt;code&gt;.pyc&lt;/code&gt; files, but it doesn't stop Python loading old ones, and a same-length edit made within the same second as the last run can execute the previous code. In an existing checkout, delete the caches first: &lt;code&gt;find src tests -name __pycache__ -prune -exec rm -rf {} +&lt;/code&gt;. A &lt;code&gt;.gitignore&lt;/code&gt; keeps caches out of commits. It doesn't disable them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 1: Fail on the Base, for the Right Reason
&lt;/h2&gt;

&lt;p&gt;The key move is tests from the repair, source from the base. Check out the whole base commit and you run the base's old tests, which say nothing about the new one. (Benchmarks call the code before the fix the base and the reference fix the gold patch. SWE-bench sorts tests into &lt;code&gt;FAIL_TO_PASS&lt;/code&gt; and &lt;code&gt;PASS_TO_PASS&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;check_test.sh&lt;/code&gt; does the swap in a throwaway &lt;code&gt;git worktree&lt;/code&gt;, for one test node. It matches the node id literally, so quote a parametrized one: &lt;code&gt;./check_test.sh base repair 'tests/test_x.py::test_partial[5]'&lt;/code&gt;.&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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Usage: ./check_test.sh BASE_REF REPAIR_REF TEST_NODE_ID&lt;/span&gt;
&lt;span class="c"&gt;# Runs one test from REPAIR_REF against BASE_REF's source, then REPAIR_REF's.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="nv"&gt;repair&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt; &lt;span class="nv"&gt;node&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PYTHONDONTWRITEBYTECODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nv"&gt;wt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'git worktree remove --force "$wt"'&lt;/span&gt; EXIT
git worktree add &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--detach&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$wt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$repair&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
run&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$wt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pytest &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; no:cacheprovider &lt;span class="nt"&gt;-rA&lt;/span&gt; &lt;span class="nt"&gt;--tb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;line &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="o"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s2"&gt;"s|&lt;/span&gt;&lt;span class="nv"&gt;$wt&lt;/span&gt;&lt;span class="s2"&gt;|&amp;lt;wt&amp;gt;|g"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$wt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; checkout &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--no-overlay&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; src/     &lt;span class="c"&gt;# base source, exact tree&lt;/span&gt;
&lt;span class="nv"&gt;b&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;run&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;bcode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;bcode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$wt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; checkout &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--no-overlay&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$repair&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; src/
&lt;span class="nv"&gt;r&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;run&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;rcode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;rcode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--- base (exit &lt;/span&gt;&lt;span class="nv"&gt;$bcode&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(FAILED|ERROR|PASSED|SKIPPED|E )'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
echo&lt;/span&gt; &lt;span class="s2"&gt;"--- repair (exit &lt;/span&gt;&lt;span class="nv"&gt;$rcode&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(FAILED|ERROR|PASSED|SKIPPED)'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;has&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;l&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'index($0, l " - ") == 1 || $0 == l { f = 1 } END { exit !f }'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;# literal id&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$bcode&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; has &lt;span class="s2"&gt;"FAILED &lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'^ERROR'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$rcode&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; has &lt;span class="s2"&gt;"PASSED &lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CANDIDATE: fails on base, passes on repair. Read the base failure: is it the bug?"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"REJECT: this run does not show the test catching the bug"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-overlay&lt;/code&gt; is there because a plain &lt;code&gt;git checkout &amp;lt;ref&amp;gt; -- src/&lt;/code&gt; only adds and overwrites files. On a test repo where the repair added one source file and deleted another, the "base" run without the flag still had the repair's new file, and the swap back left the base's deleted one behind. Any &lt;code&gt;git&lt;/code&gt; failure, like a mistyped ref, aborts the script before a verdict.&lt;/p&gt;

&lt;p&gt;From the repo root, on &lt;code&gt;main&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./check_test.sh base repair tests/test_bug.py::test_keeps_the_partial_last_chunk
--- base (exit 1)
E   assert [[1, 2], [3, 4]] == [[1, 2], [3, 4], [5]]
FAILED tests/test_bug.py::test_keeps_the_partial_last_chunk - assert [[1, 2],...
--- repair (exit 0)
PASSED tests/test_bug.py::test_keeps_the_partial_last_chunk
CANDIDATE: fails on base, passes on repair. Read the base failure: is it the bug?
$ ./check_test.sh base repair tests/test_vacuous.py::test_chunk_runs
--- base (exit 0)
PASSED tests/test_vacuous.py::test_chunk_runs
--- repair (exit 0)
PASSED tests/test_vacuous.py::test_chunk_runs
REJECT: this run does not show the test catching the bug
$ ./check_test.sh base wrong-reason tests/test_count.py::test_counts_the_partial_chunk
--- base (exit 4)
ERROR: found no collectors for &amp;lt;wt&amp;gt;/tests/test_count.py::test_counts_the_partial_chunk
E   ImportError: cannot import name 'chunk_count' from 'chunks' (&amp;lt;wt&amp;gt;/src/chunks.py)
ERROR tests/test_count.py
--- repair (exit 0)
PASSED tests/test_count.py::test_counts_the_partial_chunk
REJECT: this run does not show the test catching the bug
$ ./check_test.sh base wrong-reason tests/test_count_runtime.py::test_counts_at_runtime
--- base (exit 1)
E   ImportError: cannot import name 'chunk_count' from 'chunks' (&amp;lt;wt&amp;gt;/src/chunks.py)
FAILED tests/test_count_runtime.py::test_counts_at_runtime - ImportError: can...
--- repair (exit 0)
PASSED tests/test_count_runtime.py::test_counts_at_runtime
CANDIDATE: fails on base, passes on repair. Read the base failure: is it the bug?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last two are the trap. Both tests need &lt;code&gt;chunk_count&lt;/code&gt;, which the base doesn't have. The first imports it at the top of the file, so the base run dies during collection and the script rejects it. The second imports it inside the test function, so it fails at run time with exit 1 and a &lt;code&gt;FAILED&lt;/code&gt; line, and the script calls it a candidate. It looks red. It proves nothing.&lt;/p&gt;

&lt;p&gt;So the script narrows, it doesn't prove. It filters out what an exit code can show (a pass on the base, a collection error, a skip, a repair that doesn't pass), then hands you the base failure to read. The first run's &lt;code&gt;E&lt;/code&gt; line is the missing &lt;code&gt;[5]&lt;/code&gt;, which is the bug. The fourth run's is an &lt;code&gt;ImportError&lt;/code&gt;, which isn't.&lt;/p&gt;

&lt;p&gt;Drop &lt;code&gt;pythonpath&lt;/code&gt; from &lt;code&gt;pyproject.toml&lt;/code&gt; and &lt;code&gt;pytest -q tests/&lt;/code&gt; stops on 6 collection errors with exit 2, on the base too. A check that only asks "did it fail on base?" would wave every test through.&lt;/p&gt;

&lt;p&gt;One more hole: a test that &lt;code&gt;return&lt;/code&gt;s its comparison instead of asserting it. With &lt;code&gt;return chunk([1, 2, 3, 4, 5], 2) == [[1, 2], [3, 4]]&lt;/code&gt; in &lt;code&gt;tests/test_return.py&lt;/code&gt;, &lt;code&gt;pytest -q tests/test_return.py&lt;/code&gt; prints &lt;code&gt;1 passed, 1 warning&lt;/code&gt;, though the comparison is &lt;code&gt;False&lt;/code&gt;. Add &lt;code&gt;-W error::pytest.PytestReturnNotNoneWarning&lt;/code&gt; and it prints &lt;code&gt;1 failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The other half of the check matters as much. In my experiments, the first diagnostic probe for a &lt;code&gt;Click&lt;/code&gt; regression bundled two properties, and it failed the upstream fix because of a separate, known bug. That made it invalid, and it was thrown out. A test that can't pass the real fix is measuring something else.&lt;/p&gt;

&lt;p&gt;A team I work with hit the base-side failure in July. A regression test's setup rebuilt a database view with &lt;code&gt;CREATE ... IF NOT EXISTS&lt;/code&gt;, silently reused a stale view from earlier work, and passed whatever the new SQL said. Running it once against the unfixed code would have shown it green. In September that team adopted OpenClaw's public &lt;a href="https://github.com/openclaw/openclaw/blob/main/.agents/skills/test-audit/SKILL.md" rel="noopener noreferrer"&gt;test-audit skill&lt;/a&gt;, and its authoring gate puts it best: "A regression test that never demonstrably failed proves the mock, not the fix."&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 2: Break the Repair and Watch the Test Go Red
&lt;/h2&gt;

&lt;p&gt;A mutant is a copy of the code with one small deliberate change. If a test fails, the mutant is killed. If every test passes, it survived, and you've found something your tests can't see. Check 1 shows the test sees the original bug. Check 2 asks whether it would see a slightly wrong fix.&lt;/p&gt;

&lt;p&gt;Start by hand. Read the repair diff and write the mistake an agent would most plausibly make in that exact line. Here the fix is a loop bound, so the obvious wrong fix is a different off-by-one in the same bound. That's mutant A, applied to the repaired source on &lt;code&gt;main&lt;/code&gt; and then undone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sed -i 's/range(0, len(items), size)/range(0, len(items) - 1, size)/' src/chunks.py
$ pytest -q tests/test_bug.py tests/test_vacuous.py
FAILED tests/test_bug.py::test_keeps_the_partial_last_chunk - assert [[1, 2],...
1 failed, 2 passed in 0.01s
$ git checkout main -- src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug test kills it. Both vacuous tests pass it. The zero-tool version is a source rollback: revert one changed source file, and the test must fail. In my experiments, three of eight hand-made mutants that reverted &lt;code&gt;urllib3&lt;/code&gt; and &lt;code&gt;Click&lt;/code&gt; code to obsolete API contracts passed the narrow feature test, and only the broader regression module caught them.&lt;/p&gt;

&lt;p&gt;For a PR, that hand-written mutant of the changed line is the default. A tool sweep is broader, optional work. It costs one test run per mutant plus a manual read of every survivor, so skip it when that's more than the PR is worth.&lt;/p&gt;

&lt;p&gt;I hadn't used a mutation tool before this experiment. Here's what &lt;code&gt;mutmut&lt;/code&gt; found on the fixture. Its config is the two keys in &lt;code&gt;pyproject.toml&lt;/code&gt; above, and older blog posts still show the deprecated &lt;code&gt;paths_to_mutate&lt;/code&gt; and &lt;code&gt;tests_dir&lt;/code&gt;. A small &lt;code&gt;sweep.sh&lt;/code&gt; writes the test list into the second key, deletes &lt;code&gt;mutants/&lt;/code&gt;, runs &lt;code&gt;mutmut run&lt;/code&gt; and lists survivors:&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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Usage: ./sweep.sh TEST_PATH...&lt;/span&gt;
&lt;span class="c"&gt;# Sets mutmut's test selection in pyproject.toml, runs a fresh sweep, lists survivors.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;sel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'"%s", '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;sel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;%, &lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt;.bak &lt;span class="s2"&gt;"s|^pytest_add_cli_args_test_selection = .*|pytest_add_cli_args_test_selection = &lt;/span&gt;&lt;span class="nv"&gt;$sel&lt;/span&gt;&lt;span class="s2"&gt;|"&lt;/span&gt; pyproject.toml
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; pyproject.toml.bak &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; mutants
mutmut run &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; mutmut.log 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;cat &lt;/span&gt;mutmut.log&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &amp;lt; mutmut.log | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'🎉'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
mutmut results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still on &lt;code&gt;main&lt;/code&gt;, with the repair restored (🎉 killed, 🙁 survived):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./sweep.sh tests/test_vacuous.py
13/13  🎉 4 🫥 0  ⏰ 0  🤔 0  🙁 9  🔇 0  🧙 0
$ ./sweep.sh tests/test_bug.py
13/13  🎉 8 🫥 0  ⏰ 0  🤔 0  🙁 5  🔇 0  🧙 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug test alone left five survivors: three mangled error messages and two changes to the guard. One of them, from &lt;code&gt;mutmut show chunks.x_chunk__mutmut_2&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    if size &amp;lt;= 0:
&lt;/span&gt;&lt;span class="gi"&gt;+    if size &amp;lt;= 1:
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing calls &lt;code&gt;chunk()&lt;/code&gt; with &lt;code&gt;size=1&lt;/code&gt;, so nothing notices. The bug test was never meant to guard that line, and that's Check 3's job.&lt;/p&gt;

&lt;p&gt;What the sweep missed matters as much. None of its 13 mutants shifted the bound the way mutant A does, and none deleted the guard. Its operators swap &lt;code&gt;+&lt;/code&gt; for &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt; for &lt;code&gt;1&lt;/code&gt;, values for &lt;code&gt;None&lt;/code&gt;. The two mutants I'd most expect from an agent came from reading the diff, not from the tool.&lt;/p&gt;

&lt;p&gt;After you add a test for a survivor, re-run the sweep. I've watched an agent &lt;a href="https://potapov.dev/blog/ai-agents-declare-victory/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=test-can-catch-the-bug" rel="noopener noreferrer"&gt;publish a mutation table&lt;/a&gt; it never re-ran.&lt;/p&gt;

&lt;p&gt;Know the limits. &lt;code&gt;mutmut run&lt;/code&gt; exited 0 with 9 survivors, and &lt;code&gt;cosmic-ray exec&lt;/code&gt; exited 0 with 16 of 21. &lt;code&gt;Stryker&lt;/code&gt; fails a build only when &lt;code&gt;thresholds.break&lt;/code&gt; is set, and its default is null. &lt;code&gt;mutmut&lt;/code&gt; 3 doesn't mutate module-level code, so a wrong constant is invisible to it. Some survivors are equivalent mutants that no test can kill, so I'd read survivors one by one and skip the score. Google's 2018 report describes the same idea at scale: mutants only on the lines a change touches, shown during code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 3: A Preservation Probe Has to Pass Twice
&lt;/h2&gt;

&lt;p&gt;A preservation probe tests behavior the change must not touch. It passes on the base, passes on the repair, and fails a mutant that breaks the preserved behavior. That's the mirror image of the bug test. A probe that fails the upstream fix is invalid, like the bundled probe in Check 1. A probe that fails the base isn't a preservation probe at all: it's a second bug test.&lt;/p&gt;

&lt;p&gt;Why bother, when the project already has tests? Because a fix's own test only looks at the line that changed, and an agent's patch rarely stops there. It renames, simplifies a guard, reorders arguments. The two probes behind those 8 rejections in my experiments were exactly this kind: one checked that a &lt;code&gt;Click&lt;/code&gt; multiple-value callback still received its collected arguments, the other that a zero-byte &lt;code&gt;urllib3&lt;/code&gt; read didn't stop a later full read from being cached.&lt;/p&gt;

&lt;p&gt;On the fixture, the probe guards the &lt;code&gt;size&lt;/code&gt; check, and it can be vacuous too. Mutant B deletes the guard. The weakest probe, &lt;code&gt;tests/test_preserve_typeonly.py&lt;/code&gt;, only checks &lt;code&gt;pytest.raises(ValueError)&lt;/code&gt; at &lt;code&gt;size=0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sed -i '/if size &amp;lt;= 0:/,+1d' src/chunks.py
$ pytest -q tests/test_preserve_typeonly.py
1 passed in 0.00s
$ git checkout main -- src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It passes because &lt;code&gt;range()&lt;/code&gt; raises &lt;code&gt;ValueError&lt;/code&gt; for a zero step on its own. At &lt;code&gt;size=-1&lt;/code&gt; there's no error at all: &lt;code&gt;range(0, 3, -1)&lt;/code&gt; is empty, and the guard-less &lt;code&gt;chunk()&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt;. OpenClaw's skill lists this exact junk pattern: "negative controls that pass for an unrelated reason".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tests/test_preserve_first.py&lt;/code&gt; adds &lt;code&gt;match="size must be positive"&lt;/code&gt; and an exact-multiple case, &lt;code&gt;chunk([1, 2, 3, 4], 2) == [[1, 2], [3, 4]]&lt;/code&gt;. That kills mutant B at &lt;code&gt;size=0&lt;/code&gt;, and the sweep finds the next gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./sweep.sh tests/test_bug.py tests/test_preserve_first.py
13/13  🎉 11 🫥 0  ⏰ 0  🤔 0  🙁 2  🔇 0  🧙 0
    chunks.x_chunk__mutmut_2: survived
    chunks.x_chunk__mutmut_4: survived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first survivor is the &lt;code&gt;size &amp;lt;= 1&lt;/code&gt; guard from Check 2. The second raises &lt;code&gt;"XXsize must be positiveXX"&lt;/code&gt;. The &lt;code&gt;pytest&lt;/code&gt; docs say &lt;code&gt;match&lt;/code&gt; "is matched with the re.search() function", so the mangled message still matched. Neither trap is exotic. Both are a probe leaning on something other than the behavior it names.&lt;/p&gt;

&lt;p&gt;The final probe, &lt;code&gt;tests/test_preserve.py&lt;/code&gt;, probes &lt;code&gt;-1&lt;/code&gt;, anchors the pattern and covers &lt;code&gt;size=1&lt;/code&gt;:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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;test_rejects_non_positive_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^size must be positive$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;size&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;test_size_one_is_allowed&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&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;test_exact_multiple_unchanged&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass on the base, pass on the repair, fail on mutant B, then mutant A and the sweep (summary lines only):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git checkout --no-overlay base -- src/ &amp;amp;&amp;amp; pytest -q tests/test_preserve.py; git checkout main -- src/
4 passed in 0.00s
$ pytest -q tests/test_preserve.py
4 passed in 0.00s
$ sed -i '/if size &amp;lt;= 0:/,+1d' src/chunks.py &amp;amp;&amp;amp; pytest -q tests/test_preserve.py; git checkout main -- src/
FAILED tests/test_preserve.py::test_rejects_non_positive_size[0] - AssertionE...
FAILED tests/test_preserve.py::test_rejects_non_positive_size[-1] - Failed: D...
2 failed, 2 passed in 0.01s
$ sed -i 's/range(0, len(items), size)/range(0, len(items) - 1, size)/' src/chunks.py &amp;amp;&amp;amp; pytest -q tests/test_preserve.py; git checkout main -- src/
FAILED tests/test_preserve.py::test_size_one_is_allowed - assert [[1], [2]] =...
1 failed, 3 passed in 0.01s
$ ./sweep.sh tests/test_bug.py tests/test_preserve.py
13/13  🎉 13 🫥 0  ⏰ 0  🤔 0  🙁 0  🔇 0  🧙 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The probe also catches mutant A, through its &lt;code&gt;size=1&lt;/code&gt; case: &lt;code&gt;chunk([1, 2, 3], 1)&lt;/code&gt; loses the &lt;code&gt;[3]&lt;/code&gt;. The probe wasn't aimed at mutant A. That's the bonus of pinning plain boundary behavior: it catches more than the regression you had in mind.&lt;/p&gt;

&lt;p&gt;On an agent PR, I'd pick probes from what the changed function promised before the change: its error cases, its boundary inputs, the flags or state it leaves behind for the next caller. One assertion per promise, run on the base first. If it fails there, you've misread the old behavior, and that's worth knowing before you judge the patch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 4: An Oracle the Test's Author Didn't Write
&lt;/h2&gt;

&lt;p&gt;A test and its implementation can agree because both misread the issue. Then Checks 1 through 3 all pass, and both are wrong. That's the risk when one agent writes the code and the expected values from the same reading: as Kent Beck puts it, copying computed values into expectations "defeats double checking".&lt;/p&gt;

&lt;p&gt;On the fixture, the standard library supplies an independent oracle: &lt;code&gt;itertools.batched&lt;/code&gt; already splits an iterable into batches where "the last batch may be shorter than n". &lt;code&gt;oracle.py&lt;/code&gt; sits in the repo root:&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Independent oracle: compare chunk() to itertools.batched on random inputs.
Exit 0 = agrees everywhere, 1 = first disagreement.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;

&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Random&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randrange&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="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DISAGREE chunk(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, expected &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oracle: 2000 random cases agree&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;On the repair, the base and mutant A:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python oracle.py
oracle: 2000 random cases agree
$ git checkout --no-overlay base -- src/ &amp;amp;&amp;amp; python oracle.py; git checkout main -- src/
DISAGREE chunk([0, 1, 2, 3, 4, 5], 4) = [[0, 1, 2, 3]], expected [[0, 1, 2, 3], [4, 5]]
$ sed -i 's/range(0, len(items), size)/range(0, len(items) - 1, size)/' src/chunks.py &amp;amp;&amp;amp; python oracle.py; git checkout main -- src/
DISAGREE chunk([0, 1, 2, 3, 4, 5, 6], 3) = [[0, 1, 2], [3, 4, 5]], expected [[0, 1, 2], [3, 4, 5], [6]]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The oracle passes Check 1 itself: it disagrees on the base and agrees on the repair. It also found its own counterexamples, &lt;code&gt;[0, 1, 2, 3, 4, 5]&lt;/code&gt; with size 4, not the five-item list the bug test uses. The seed is fixed, so a disagreement reproduces every time.&lt;/p&gt;

&lt;p&gt;Most PRs have no &lt;code&gt;batched&lt;/code&gt; to lean on. Then I'd start smaller: before opening the patch, write two input and output pairs from the issue text, so the agent's code can't shape what you expect. When the issue has no examples, a reference implementation (a slow brute-force version, or the old code on inputs the change shouldn't affect) or one recorded real input from the system that produces it does the same job.&lt;/p&gt;

&lt;p&gt;In June a webhook relay my agents built passed its tests, its review rounds and CI while silently dropping real comments: the code and its tests were written against the same invented webhook shape, and the real producer sent a flat one. Logging one real payload exposed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Ask of a Test Before I Trust It
&lt;/h2&gt;

&lt;p&gt;Here's the whole map:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;False pass&lt;/th&gt;
&lt;th&gt;What it looks like&lt;/th&gt;
&lt;th&gt;Check that catches it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vacuous test&lt;/td&gt;
&lt;td&gt;no assertion, or &lt;code&gt;assert result&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1 (passes on base), 2 (mutants survive)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong-reason red&lt;/td&gt;
&lt;td&gt;collection or import error on the base&lt;/td&gt;
&lt;td&gt;1 narrows it, reading the base failure settles it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blind to a broken repair&lt;/td&gt;
&lt;td&gt;sees the bug, misses an off-by-one fix&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collateral regression&lt;/td&gt;
&lt;td&gt;fix breaks behavior its test never touches&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared assumption&lt;/td&gt;
&lt;td&gt;test and code agree, the spec doesn't&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows lean on Check 1, which is why it comes first. The last row is the only one that running the agent's own test more carefully can never fix, because the mistake sits in what the test expects.&lt;/p&gt;

&lt;p&gt;The bar I set for my own graders is the bar I'd set for any test offered as evidence: it fails on the base for an assertion, passes on the fix, and goes red when a changed source file is reverted. My grading harness has since adopted one more rule: each task's hidden tests must also reject at least two targeted wrong patches. The fixture already has two good kinds. Mutant A is a partial repair and mutant B a regression, and Checks 2 and 3 are that rule in its cheapest form.&lt;/p&gt;

&lt;p&gt;If you only run one check on an agent's test, run Check 1. It's one script and two &lt;code&gt;pytest&lt;/code&gt; runs, and it filters the two failures in the table that look most like success. Build the five-line fixture, run &lt;code&gt;./check_test.sh base repair tests/test_bug.py::test_keeps_the_partial_last_chunk&lt;/code&gt;, and then point it at the next agent PR that says the tests pass.&lt;/p&gt;

&lt;p&gt;When an agent's PR says "tests pass", which of these checks do you actually run before merging, and on which PRs do you skip them?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More on this on potapov.dev:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://potapov.dev/blog/trust-agent-tests-and-reviews/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=test-can-catch-the-bug" rel="noopener noreferrer"&gt;When to Trust AI Agent Tests and Reviews&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://potapov.dev/blog/ai-agents-declare-victory/?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=test-can-catch-the-bug" rel="noopener noreferrer"&gt;Why AI Agents Declare Victory Too Early&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>ai</category>
      <category>codereview</category>
    </item>
    <item>
      <title>Using Claude Code Without Technical Debt</title>
      <dc:creator>Arseniy Potapov</dc:creator>
      <pubDate>Tue, 03 Mar 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/potapov/using-claude-code-without-technical-debt-1a7</link>
      <guid>https://dev.to/potapov/using-claude-code-without-technical-debt-1a7</guid>
      <description>&lt;p&gt;Last month I spent three days debugging race conditions where overlapping transactions interlocked and corrupted shared state. The code passed every test. Linters were clean. Type checks passed. In production, under real load, two users hitting the same resource at the same time broke everything.&lt;/p&gt;

&lt;p&gt;Some of that code was AI-assisted. Not all of it - race conditions don't need AI to exist - but the AI-generated parts had sailed through review because they looked impeccable. Syntactically perfect. Well-structured. Exactly the kind of code you glance at and think "looks good." That's the trap. Auto-approving AI output because it reads well is how you end up spending a week staring at transaction logs instead of shipping features.&lt;/p&gt;

&lt;p&gt;That experience crystallized something I'd been feeling for months. AI tools write code that's correct in isolation - functions that do what you asked, following patterns that pass every static check. But production isn't isolation. Production is concurrent users, stale caches, network partitions, and data that doesn't look like your test fixtures. The gap between "works in dev" and "survives production" is where technical debt hides.&lt;/p&gt;

&lt;p&gt;I use Claude Code every day for production AI/ML systems. I'm not here to tell you to stop using AI tools - I'd be a hypocrite. I'm here to share the workflows I've built after learning these lessons the hard way. Two modes of working with AI, a context-priming system that makes output dramatically more predictable, and the red flags I've trained myself to catch before they reach production.&lt;/p&gt;

&lt;p&gt;Here's how I use Claude Code without losing sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI Gets Wrong (And Why It's Hard to Spot)
&lt;/h2&gt;

&lt;p&gt;AI-generated code has a dangerous property: it looks right. Clean variable names, correct syntax, reasonable structure. It passes lint, passes type checks, often passes tests. The problems are in the things you can't see by reading the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Doesn't Understand Your Runtime
&lt;/h3&gt;

&lt;p&gt;AI generates code in a vacuum. It doesn't know that your FastAPI endpoint gets hit by 200 concurrent users during batch processing. It doesn't know that your Celery workers share a database connection pool that saturates under load. It writes code that's correct for a single request and completely wrong for a thousand simultaneous ones.&lt;/p&gt;

&lt;p&gt;My race condition? AI-generated async code where two transactions could hit the same resource within milliseconds. Each transaction was correct in isolation. Together, they interlock and corrupt shared state. Nothing in the code &lt;em&gt;looks&lt;/em&gt; wrong - the bug is in the timing that only exists under production load.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Doesn't Know Your Architecture
&lt;/h3&gt;

&lt;p&gt;Every Claude Code conversation starts fresh. Without context, it invents patterns. Feature A gets a service layer. Feature B gets business logic inline in the route handler. Feature C introduces a repository pattern nobody asked for. Each one works individually. Together, your codebase becomes an archaeology dig where every layer is a different civilization. This kind of architecture drift is how teams end up in the &lt;a href="https://dev.to/blog/rewrite-vs-refactor/"&gt;rewrite-vs-refactor debate&lt;/a&gt; that costs months instead of weeks.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Over-Engineers
&lt;/h3&gt;

&lt;p&gt;AI loves abstractions. Ask for a simple data processor and you'll get an AbstractBaseProcessor with a StrategyFactory and a PluginRegistry. Code that should be 30 lines becomes 150. AI doesn't feel the maintenance cost of abstraction - it just reaches for the most "proper" solution it's seen in training data.&lt;/p&gt;

&lt;h3&gt;
  
  
  It Misses What's Between the Lines
&lt;/h3&gt;

&lt;p&gt;Business rules that nobody documented. The constraint that user IDs can't change after first payment because three downstream systems cache them. The convention that all async tasks must be idempotent because your queue has at-least-once delivery. AI can't know what it was never told, and the stuff that isn't written down is usually the stuff that matters most.&lt;/p&gt;

&lt;h3&gt;
  
  
  The False Confidence Trap
&lt;/h3&gt;

&lt;p&gt;This is the compounding problem. Because AI code is syntactically clean and well-structured, you trust it more than you should. You review it less carefully. You approve faster. And that's exactly when subtle bugs slip through - not because the AI is bad, but because the code &lt;em&gt;looks&lt;/em&gt; too good to question.&lt;/p&gt;

&lt;p&gt;These aren't reasons to stop using AI tools. They're reasons to use them with a system. The rest of this article is that system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    subgraph AI["What AI Generated"]
        A1["await db.get(sender)"]
        A2["await db.get(receiver)"]
        A3["if balance &amp;gt;= amount"]
        A4["sender.balance -= amount"]
        A5["await db.commit()"]
        A1 --&amp;gt; A2 --&amp;gt; A3 --&amp;gt; A4 --&amp;gt; A5
    end

    subgraph PROD["What Production Needed"]
        P1["select(...).with_for_update()"]
        P2["Lock rows in sorted order"]
        P3["if balance &amp;gt;= amount"]
        P4["sender.balance -= amount"]
        P5["await db.commit()"]
        P1 --&amp;gt; P2 --&amp;gt; P3 --&amp;gt; P4 --&amp;gt; P5
    end

    AI -. "Passes tests\nbut no row locking" .-&amp;gt; PROD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two Modes of Working With AI
&lt;/h2&gt;

&lt;p&gt;I've developed two distinct approaches for working with Claude Code in production. Neither eliminates the need to review AI output - both just change &lt;em&gt;when&lt;/em&gt; you invest your thinking time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mode 1: Inline Review (The Conversation)
&lt;/h3&gt;

&lt;p&gt;This is the interactive approach. I work with Claude Code on changes one at a time, reviewing and giving feedback as we go.&lt;/p&gt;

&lt;p&gt;The workflow looks like this: Claude suggests a change, I read it carefully, I either accept it, reject it, or ask for modifications. Then we move to the next change. It's slower per change, but I catch issues immediately when my mental context is still fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I review code changes and immediately give feedback. This is much slower but doesn't force me to review everything in one gigantic PR.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The trade-off is speed. You're not going to ship a feature in an hour with this approach. But you also won't face a 2,000-line PR full of AI-generated code that you need to audit all at once. The review burden is distributed across the work, not concentrated at the end.&lt;/p&gt;

&lt;p&gt;There's a hidden benefit: this mode is educational. I've learned new patterns, algorithms, and technologies through these conversations. When Claude suggests an approach I haven't seen before, I stop and understand it before accepting. Over time, that builds real expertise.&lt;/p&gt;

&lt;p&gt;I use this mode when I'm in unfamiliar territory - exploring a new API, debugging complex business logic, or working on code where the architecture isn't obvious yet. When I don't fully understand the problem space, I want to think through each step rather than delegate execution to AI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mode 2: Documentation-First (The Blueprint)
&lt;/h3&gt;

&lt;p&gt;This is the upfront investment approach. Before Claude writes a single line of code, I invest significant time creating detailed documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I invest time in creating very detailed documentation for the feature and then let AI work on tickets. Faster, but requires very clear understanding and plan before work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The documentation includes: the architecture (which services, which databases, how they interact), data flow (what comes in, how it transforms, what goes out), edge cases (what happens when X fails?), and constraints (performance requirements, backwards compatibility, security boundaries).&lt;/p&gt;

&lt;p&gt;Then I break the work into well-defined "tickets" and let Claude Code execute against that specification. For example, last week I needed a new API endpoint: I wrote a one-page spec covering the route, request/response schemas, database queries, error cases, and auth requirements. Claude produced a working implementation on the first pass - because it wasn't guessing, it was following a blueprint.&lt;/p&gt;

&lt;p&gt;This doesn't save me from all AI slop. I still review the results. But the code is more predictable, more consistent across features, and closer to what I actually wanted.&lt;/p&gt;

&lt;p&gt;I use this mode for well-understood features: CRUD operations, database migrations, repetitive refactoring, test writing. When I know exactly what needs to happen, documentation-first is faster overall despite the upfront time investment.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Use Which
&lt;/h3&gt;

&lt;p&gt;The decision is straightforward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Mode 1 (Inline Review) when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're in new or unfamiliar territory&lt;/li&gt;
&lt;li&gt;The problem requires complex business logic&lt;/li&gt;
&lt;li&gt;You're debugging and don't know the root cause yet&lt;/li&gt;
&lt;li&gt;The architecture isn't clear and you need to feel your way forward&lt;/li&gt;
&lt;li&gt;You're doing DevOps or infrastructure work (shell commands, network rules, Docker configs)&lt;/li&gt;
&lt;li&gt;The work unfolds as you go - you don't know what you'll find until you look&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use Mode 2 (Documentation-First) when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You understand the requirements completely&lt;/li&gt;
&lt;li&gt;The feature follows established patterns in your codebase&lt;/li&gt;
&lt;li&gt;You're doing repetitive work (migrations, similar CRUD endpoints)&lt;/li&gt;
&lt;li&gt;You're writing tests for well-understood behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I often alternate between them in the same day. Morning: documentation-first for a straightforward API endpoint. Afternoon: inline review for debugging a race condition where I don't know what's broken yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Key Insight
&lt;/h3&gt;

&lt;p&gt;Neither mode eliminates the need to review. The question isn't "should I review AI output?" The question is "when do I invest my thinking time?"&lt;/p&gt;

&lt;p&gt;Mode 1: thinking happens &lt;em&gt;during&lt;/em&gt; execution (distributed review).&lt;br&gt;
Mode 2: thinking happens &lt;em&gt;before&lt;/em&gt; execution (upfront design, then review at the end).&lt;/p&gt;

&lt;p&gt;Both require discipline. Both require saying no to AI suggestions that aren't right. The difference is timing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TB
    subgraph MODE1["Mode 1: Inline Review"]
        direction LR
        M1A["AI suggests change"] --&amp;gt; M1B["You review"] --&amp;gt; M1C["Accept / Reject / Modify"] --&amp;gt; M1A
        style M1B fill:#ffd,stroke:#aa0
    end

    subgraph MODE2["Mode 2: Documentation-First"]
        direction LR
        M2A["You write spec"] --&amp;gt; M2B["AI executes tickets"] --&amp;gt; M2C["You review result"]
        style M2A fill:#ffd,stroke:#aa0
    end

    MODE1 --- T1["Thinking distributed across work"]
    MODE2 --- T2["Thinking upfront, review at end"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Context Is Everything
&lt;/h2&gt;

&lt;p&gt;The single biggest factor in AI code quality isn't the model, the prompt, or the temperature setting. It's context. The more your AI tool knows about your project, your conventions, and your constraints, the better its output will be.&lt;/p&gt;

&lt;p&gt;I've found three layers of context that dramatically change Claude Code's output quality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: CLAUDE.md - Your Project's AI Constitution
&lt;/h3&gt;

&lt;p&gt;Claude Code reads a &lt;code&gt;CLAUDE.md&lt;/code&gt; file at the root of your project before every conversation. This is where you define the rules of engagement.&lt;/p&gt;

&lt;p&gt;Mine includes things like: "Never wrap code in try-except by default - we handle errors globally." And: "Do not use inline imports, always put imports at module level." These are my coding conventions, and without them Claude would happily generate code that violates both.&lt;/p&gt;

&lt;p&gt;CLAUDE.md should contain your coding style, architecture decisions, what NOT to do, and any project-specific constraints. Keep it concise - this isn't a wiki. It's a dense set of instructions that shapes every line of code Claude generates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Subdocument References
&lt;/h3&gt;

&lt;p&gt;CLAUDE.md gets long fast. The trick is keeping it lean and linking to deeper documentation for specific areas.&lt;/p&gt;

&lt;p&gt;My CLAUDE.md references separate docs for database schemas, API patterns, deployment procedures, and testing conventions. Claude reads the relevant subdocuments when it needs context for a specific task. This layered approach means CLAUDE.md stays scannable while deeper context is always available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: AI-Targeted Documentation
&lt;/h3&gt;

&lt;p&gt;This is the layer most people miss. Traditional documentation is written for humans - it explains concepts, includes tutorials, gives background. AI-targeted documentation is different. It's dense, specific, and architectural.&lt;/p&gt;

&lt;p&gt;Instead of "Our API uses REST principles," you write: "All API endpoints follow this pattern: FastAPI router in &lt;code&gt;api/v1/&lt;/code&gt;, Pydantic models in &lt;code&gt;schemas/&lt;/code&gt;, service layer in &lt;code&gt;services/&lt;/code&gt;. Responses use &lt;code&gt;StandardResponse&lt;/code&gt; wrapper. Auth via &lt;code&gt;get_current_user&lt;/code&gt; dependency."&lt;/p&gt;

&lt;p&gt;That one paragraph gives Claude more useful context than pages of explanation. The AI doesn't need to understand why - it needs to know what patterns to follow.&lt;/p&gt;

&lt;p&gt;The hidden cost: maintaining this documentation takes real effort. Every architecture change, every new convention needs to be reflected in these docs. It's not free. But it's the highest-ROI investment I've found for AI-assisted development. Better context in means dramatically better code out.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Principle
&lt;/h3&gt;

&lt;p&gt;Garbage context in, garbage code out. If your AI tool doesn't know your patterns, it will invent its own. If it doesn't know your constraints, it will ignore them. If it doesn't know your architecture, every feature will look different.&lt;/p&gt;

&lt;p&gt;Invest in documentation. Not for future developers - for your AI tools, right now. The payoff is immediate: more consistent code, fewer review cycles, less time fixing AI's guesses.&lt;/p&gt;

&lt;p&gt;There's a whole topic around automating the maintenance of AI documentation - keeping it fresh as your codebase evolves. I wrote &lt;a href="https://dev.to/blog/claude-md-guide"&gt;a definitive guide to CLAUDE.md&lt;/a&gt; covering the full five-layer configuration system. For now, start with CLAUDE.md and build from there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TB
    L1["CLAUDE.md\nConventions, architecture, constraints"]
    L2["Subdocuments\nDB patterns, API conventions, testing guide"]
    L3["AI-Targeted Feature Docs\nDense specs: routes, schemas, edge cases"]
    OUT["AI Output Quality"]

    L1 --&amp;gt;|"references"| L2
    L2 --&amp;gt;|"details"| L3
    L1 &amp;amp; L2 &amp;amp; L3 --&amp;gt;|"context in"| OUT

    style L1 fill:#e8f4fd,stroke:#1a73e8
    style L2 fill:#d4edda,stroke:#28a745
    style L3 fill:#fff3cd,stroke:#ffc107
    style OUT fill:#f8d7da,stroke:#dc3545
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Red Flags in Your Diff
&lt;/h2&gt;

&lt;p&gt;You already know why AI code goes wrong - runtime blindness, architecture drift, over-engineering. Here's what to actually look for when you're reviewing a diff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try-except wrapping everything.&lt;/strong&gt; The most reliable AI tell. You'll see entire function bodies wrapped in &lt;code&gt;try: ... except Exception: logger.error(...)&lt;/code&gt;. Your global error handler never fires because every exception gets caught and buried. If you see a bare &lt;code&gt;except Exception&lt;/code&gt; in a diff, reject it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Magic numbers.&lt;/strong&gt; Timeouts of 30, retry counts of 3, batch sizes of 100. AI picks numbers that look reasonable but aren't tied to anything real. Your timeout should be 5 seconds because the downstream SLA is 3. Check every numeric literal that isn't 0 or 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comments explaining "what."&lt;/strong&gt; &lt;code&gt;# Increment the counter&lt;/code&gt; above &lt;code&gt;counter += 1&lt;/code&gt;. If you see a comment describing what the next line does, delete it. The only comments worth keeping explain &lt;em&gt;why&lt;/em&gt; - and AI can't write those because it doesn't know your intent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Near-duplicate functions.&lt;/strong&gt; Three functions that differ by one parameter. AI generates each independently, doesn't see the duplication across sessions. Search for similar function signatures in the same module.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing boundary validation.&lt;/strong&gt; Internal functions that trust all inputs. AI treats each function as self-contained, skipping the validation that protects your system at entry points. Check: does new code that handles external input validate it?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline imports.&lt;/strong&gt; Imports inside function bodies instead of at module level. AI does this because it's "convenient" for the snippet. If your project convention is module-level imports, this is an instant reject.&lt;/p&gt;

&lt;p&gt;None of these require deep analysis. They're mechanical checks you can spot in seconds during review - which is exactly why they're worth having on a checklist.&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;# RED FLAG: Try-except wrapping everything
# BEFORE - global error handler never fires
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&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;status&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;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to process order &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&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;status&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;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# AFTER - let exceptions propagate
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&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;status&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;completed&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# RED FLAG: Magic numbers
# BEFORE
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# AFTER - tied to a real constraint
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DOWNSTREAM_TIMEOUT_SEC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 5s, SLA is 3s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Automating What You Can
&lt;/h2&gt;

&lt;p&gt;Every red flag from the previous section can be caught by a machine. Linters, type checkers, and pre-commit hooks handle the mechanical stuff - bare &lt;code&gt;except Exception&lt;/code&gt; blocks, inline imports, magic numbers, functions over 100 lines. You already have &lt;code&gt;ruff&lt;/code&gt; and &lt;code&gt;mypy&lt;/code&gt; (or ESLint and TypeScript strict). The step most people skip is adding custom pre-commit checks for the AI-specific patterns: error swallowing, numeric literals outside constants, near-duplicate functions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .pre-commit-config.yaml - AI-specific checks&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
  &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-bare-except&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no bare except Exception&lt;/span&gt;
      &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pygrep&lt;/span&gt;
      &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;except\s+(Exception|BaseException)\s*:'&lt;/span&gt;
      &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;python&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-inline-imports&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no inline imports&lt;/span&gt;
      &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pygrep&lt;/span&gt;
      &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^\s{4,}(import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;|from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;\S+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;)'&lt;/span&gt;
      &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;python&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The underrated CI trick: run your tests with concurrency. Most test suites run sequentially, so timing-dependent bugs pass locally. Run with &lt;code&gt;pytest -n auto&lt;/code&gt; or your framework's parallel flag. Bugs that only surface under concurrent execution will start failing in CI - which is exactly where you want them caught.&lt;/p&gt;

&lt;p&gt;But here's what matters more than any of this: &lt;strong&gt;what automation can't catch.&lt;/strong&gt; No tool will tell you whether this feature follows the same patterns as the rest of your codebase. No linter knows your business rules. No type checker can tell you the code solves the wrong problem.&lt;/p&gt;

&lt;p&gt;Automation handles the floor. Human review handles the ceiling. The mistake is confusing which problems belong to which layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Trade-offs
&lt;/h2&gt;

&lt;p&gt;Time for the part most AI articles skip.&lt;/p&gt;

&lt;h3&gt;
  
  
  What AI Is Genuinely Great At
&lt;/h3&gt;

&lt;p&gt;Boilerplate. CRUD endpoints, data models, serializers - anything where the pattern is established and you just need more of it. AI excels here because there's nothing to get wrong beyond following the template.&lt;/p&gt;

&lt;p&gt;Tests, when you define scope. Tell Claude exactly which cases to cover and it writes solid tests fast. Let it decide what to test and you'll get 90% happy-path coverage with zero edge cases.&lt;/p&gt;

&lt;p&gt;Mechanical refactoring. Renaming, moving code between modules, converting class patterns. Tedious work that humans mess up because our attention drifts. AI doesn't get bored.&lt;/p&gt;

&lt;p&gt;Exploring unfamiliar APIs. Need to integrate a library you've never used? Claude reads the docs faster than you and produces a working first draft. You still need to understand what it wrote, but the exploration phase shrinks dramatically.&lt;/p&gt;

&lt;p&gt;First-draft documentation. API docs, README updates, docstrings. AI produces a decent starting point that's faster to edit than to write from scratch.&lt;/p&gt;

&lt;h3&gt;
  
  
  What AI Is Genuinely Bad At
&lt;/h3&gt;

&lt;p&gt;Everything that requires understanding beyond the code itself. Concurrency correctness, architecture decisions, implicit business rules, cross-codebase consistency - I covered these in detail earlier, and they remain the core risks.&lt;/p&gt;

&lt;p&gt;But the one I keep coming back to: AI doesn't push back. It's eager to please. Tell it to build something wrong and it will do so enthusiastically. You need to be the one who decides "we shouldn't build this." AI has no judgment about whether the task itself makes sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hidden Costs
&lt;/h3&gt;

&lt;p&gt;Maintaining AI-targeted documentation takes real effort. Every architecture change needs to be reflected in your CLAUDE.md and supporting docs. This is an ongoing tax, not a one-time setup.&lt;/p&gt;

&lt;p&gt;Review time sometimes exceeds writing time. For complex logic, reading and verifying AI output takes longer than writing it yourself would have. You save nothing - you just shifted the work from writing to reading.&lt;/p&gt;

&lt;p&gt;Context-switching between driving and reviewing is cognitively expensive. You're either thinking creatively or thinking critically. Switching between the two hundreds of times a day is draining in a way that pure coding isn't.&lt;/p&gt;

&lt;p&gt;The speed gain is real but smaller than the hype. I'm not 10x faster. I'm not even 5x faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  My Honest Assessment
&lt;/h3&gt;

&lt;p&gt;AI tools make me maybe 1.5-2x faster overall. The biggest gains are in exploration and boilerplate - not in the hard parts that actually matter. Core logic, architecture, debugging - these take the same time they always did, sometimes longer because I'm reviewing AI's work on top of my own thinking.&lt;/p&gt;

&lt;p&gt;Quality is maintained only because I refuse to skip review. If I stopped reviewing, I'd ship faster but sleep worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  You're the Architect, AI Is the Contractor
&lt;/h2&gt;

&lt;p&gt;AI coding tools are the most productive addition to my workflow in years. They're also the easiest way to accumulate technical debt I've ever seen. The difference between the two outcomes is discipline - not talent, not experience, just a system you follow consistently.&lt;/p&gt;

&lt;p&gt;You wouldn't hand a contractor a plot of land and say "build something." You'd give them blueprints, constraints, materials specs, and you'd inspect the work at every stage. AI tools are the same. You design. AI executes. You review. Skip any step and you're gambling.&lt;/p&gt;

&lt;p&gt;Here's what matters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invest in documentation - for your AI, not for future developers.&lt;/strong&gt; CLAUDE.md, subdocuments, AI-targeted architecture docs. Better context in, better code out. The payoff is immediate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose your review mode deliberately.&lt;/strong&gt; Inline review for the unknown. Documentation-first for the predictable. Match the mode to the work, not your mood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automate the mechanical checks.&lt;/strong&gt; Linters, type checkers, pre-commit hooks - let tools handle the floor so your review time goes to the ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know the limits honestly.&lt;/strong&gt; 1.5-2x faster, not 10x. Great at boilerplate, bad at concurrency. The speed is real. The hype isn't.&lt;/p&gt;

&lt;p&gt;If you want a concrete starting point: write a CLAUDE.md for your main project this week. Just your coding conventions, your architecture patterns, and three things AI should never do in your codebase. That single file will change the quality of every AI interaction you have.&lt;/p&gt;

&lt;p&gt;The race condition you don't catch today is the production incident you debug next week. And enough unchecked drift turns into the kind of &lt;a href="https://dev.to/blog/why-rewrites-fail/"&gt;big rewrite&lt;/a&gt; nobody wants. Build the system. Follow the system. Sleep well.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article was created with the assistance of Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>typescript</category>
      <category>react</category>
    </item>
    <item>
      <title>Build Your Own Passwordless OTP Auth on AWS Lambda</title>
      <dc:creator>Arseniy Potapov</dc:creator>
      <pubDate>Sat, 28 Feb 2026 10:10:03 +0000</pubDate>
      <link>https://dev.to/potapov/build-your-own-passwordless-otp-auth-on-aws-lambda-4eme</link>
      <guid>https://dev.to/potapov/build-your-own-passwordless-otp-auth-on-aws-lambda-4eme</guid>
      <description>&lt;p&gt;I was adding authentication to a side project and started evaluating managed auth services. &lt;a href="https://auth0.com/pricing" rel="noopener noreferrer"&gt;Auth0&lt;/a&gt; gives you 25,000 MAU free. &lt;a href="https://clerk.com/pricing" rel="noopener noreferrer"&gt;Clerk&lt;/a&gt; gives you 50,000. &lt;a href="https://aws.amazon.com/cognito/pricing/" rel="noopener noreferrer"&gt;Cognito&lt;/a&gt; gives you 10,000. For a side project, any of them would cost zero dollars.&lt;/p&gt;

&lt;p&gt;But all of them meant routing my auth through someone else's infrastructure. My users, my verification flow, my data - controlled by a third party. All I needed was for users to prove they own an email or phone number. No passwords, no social login, no user profiles. Just "enter your email, get a code, get a token."&lt;/p&gt;

&lt;p&gt;So I built one. Two Lambda functions, a DynamoDB table, and 180 lines of &lt;a href="https://dev.to/blog/python/"&gt;Python&lt;/a&gt;. It's been running in production since February 2023 and costs about a dollar a month.&lt;/p&gt;

&lt;p&gt;This article walks through the real code, the trade-offs, and a build-vs-buy framework so you can decide whether owning your auth stack is worth it - or whether a managed service is the right call.&lt;/p&gt;

&lt;p&gt;You can try the &lt;a href="https://otp.potapov.dev" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; and browse the &lt;a href="https://github.com/muzhig/simple-otp" rel="noopener noreferrer"&gt;source code on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How OTP Works
&lt;/h2&gt;

&lt;p&gt;Most OTP tutorials generate codes with &lt;code&gt;random.randint(100000, 999999)&lt;/code&gt;. That's not a one-time password - that's a random number with no cryptographic guarantees. An attacker who intercepts one code learns nothing about the next, but there's no mathematical relationship enforcing that. A proper OTP uses HOTP (HMAC-based One-Time Password), defined in &lt;a href="https://tools.ietf.org/html/rfc4226" rel="noopener noreferrer"&gt;RFC 4226&lt;/a&gt;. HOTP takes two inputs: a shared secret (a random base32 string, minimum 128 bits per the RFC) and a counter (an integer that increments). Run them through HMAC-SHA1 and dynamic truncation, and you get a 6-digit code that's cryptographically tied to that specific secret and counter value. The &lt;code&gt;pyotp&lt;/code&gt; library implements this algorithm, so you don't write any cryptography yourself - you pass in the secret and counter, and it gives you back a code.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;

&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random_base32&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;hotp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&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;# first code
&lt;/span&gt;&lt;span class="n"&gt;hotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&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;# True - matches counter 0
&lt;/span&gt;&lt;span class="n"&gt;hotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# False - wrong counter
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties make HOTP work for authentication:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Can't reuse.&lt;/strong&gt; Each code is tied to a specific counter value. Once verified, the record is deleted, so the same code never works again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can't predict.&lt;/strong&gt; Without the secret, there's no way to compute the next code. The secret never leaves the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can't go backwards.&lt;/strong&gt; A code generated for counter 5 doesn't work for counter 4.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  HOTP vs TOTP
&lt;/h3&gt;

&lt;p&gt;You've probably used TOTP - Time-based One-Time Password - with authenticator apps like Google Authenticator. TOTP is HOTP with time as the counter. Codes rotate every 30 seconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;HOTP (counter-based)&lt;/th&gt;
&lt;th&gt;TOTP (time-based)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code valid until&lt;/td&gt;
&lt;td&gt;Used or expired by TTL&lt;/td&gt;
&lt;td&gt;~30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use case&lt;/td&gt;
&lt;td&gt;"Send me a code" flows&lt;/td&gt;
&lt;td&gt;Authenticator apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clock sync needed&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User pressure&lt;/td&gt;
&lt;td&gt;None - enter when ready&lt;/td&gt;
&lt;td&gt;Must type within window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For "type your email, get a code" flows, HOTP is the right choice. TOTP would mean the user has 30 seconds to check their email and type the code - that's stressful and leads to failed attempts. With HOTP, the code stays valid until the record expires (5 minutes in my implementation) or the user enters it successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The whole system is four components: two Lambda functions, one DynamoDB table, and whichever delivery service you prefer for sending codes. I use Mailgun for email and Twilio for SMS.&lt;/p&gt;

&lt;p&gt;I chose &lt;a href="https://dev.to/blog/aws-choosing-right-services/"&gt;serverless&lt;/a&gt; because OTP verification is the definition of a bursty workload. Most of the time nobody is logging in. Then 50 people sign up after a Product Hunt launch and you need to handle them all. Lambda scales to zero when idle and handles bursts without provisioning anything. For a service that processes a few requests per day on average, paying for a running server would be wasteful.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Data Model
&lt;/h3&gt;

&lt;p&gt;Each OTP record lives in &lt;a href="https://dev.to/blog/types-of-databases/"&gt;DynamoDB&lt;/a&gt; with a TTL that auto-deletes expired codes. The &lt;code&gt;pynamodb&lt;/code&gt; ORM keeps the model definition clean:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pynamodb.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Model&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pynamodb.attributes&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UnicodeAttribute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NumberAttribute&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;simple-otp-secrets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UnicodeAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash_key&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="n"&gt;otp_secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UnicodeAttribute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NumberAttribute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;expires&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NumberAttribute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; is the user's email or phone number - one record per identity. &lt;code&gt;otp_secret&lt;/code&gt; is the random base32 string that seeds HOTP generation. &lt;code&gt;counter&lt;/code&gt; tracks how many codes have been generated for this secret. &lt;code&gt;expires&lt;/code&gt; is a Unix timestamp for DynamoDB's TTL - after 5 minutes, DynamoDB deletes the record automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  What It Costs
&lt;/h3&gt;

&lt;p&gt;This is where serverless gets interesting. Here's what I actually pay:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lambda (2 functions)&lt;/td&gt;
&lt;td&gt;$0.00 (free tier: 1M requests/mo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB (on-demand)&lt;/td&gt;
&lt;td&gt;$0.00 (free tier: 25 GB storage, 25 WCU/RCU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway&lt;/td&gt;
&lt;td&gt;$0.00 (free tier: 1M calls/mo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailgun (email delivery)&lt;/td&gt;
&lt;td&gt;$0.00 (free tier: 100 emails/day)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio (SMS delivery)&lt;/td&gt;
&lt;td&gt;~$0.008 per SMS + $1.15/mo for a number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;&amp;lt;$2/mo&lt;/strong&gt; (or $0 if email-only)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only component that costs real money is Twilio SMS. If you only need email verification, the entire service runs within free tiers indefinitely. You could also replace Mailgun with &lt;a href="https://aws.amazon.com/ses/pricing/" rel="noopener noreferrer"&gt;AWS SES&lt;/a&gt; ($0.10 per 1,000 emails) and Twilio with &lt;a href="https://aws.amazon.com/sns/sms-pricing/" rel="noopener noreferrer"&gt;AWS SNS&lt;/a&gt; (~$0.007/SMS) to keep everything in AWS - I used Mailgun and Twilio because I already had accounts, but SES would make the email path truly $0. I've been running this for three years and my AWS bill has never exceeded $0.50 in a single month.&lt;/p&gt;

&lt;p&gt;Deployment uses the Serverless Framework, which wraps CloudFormation and handles the API Gateway + Lambda wiring. Here's the core of &lt;code&gt;serverless.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;functions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otpVerificationStart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main.otp_verification_start&lt;/span&gt;
    &lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/otp-verification/start"&lt;/span&gt;
          &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
          &lt;span class="na"&gt;cors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;reservedConcurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;

  &lt;span class="na"&gt;otpVerificationComplete&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main.otp_verification_complete&lt;/span&gt;
    &lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/otp-verification/complete"&lt;/span&gt;
          &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
          &lt;span class="na"&gt;cors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;reservedConcurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;

&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;simpleOtpSecrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::DynamoDB::Table&lt;/span&gt;
      &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;simple-otp-secrets&lt;/span&gt;
        &lt;span class="na"&gt;AttributeDefinitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
            &lt;span class="na"&gt;AttributeType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;S&lt;/span&gt;
        &lt;span class="na"&gt;KeySchema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;id&lt;/span&gt;
            &lt;span class="na"&gt;KeyType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HASH&lt;/span&gt;
        &lt;span class="na"&gt;ProvisionedThroughput&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ReadCapacityUnits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;WriteCapacityUnits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="na"&gt;TimeToLiveSpecification&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;expires&lt;/span&gt;
          &lt;span class="na"&gt;Enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two functions, one DynamoDB table with TTL enabled, IAM permissions for DynamoDB access. First deploy takes about 3 minutes. Subsequent deploys take under 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Endpoints
&lt;/h2&gt;

&lt;p&gt;The entire service is two Lambda functions behind API Gateway. One creates the OTP and sends it. The other verifies it and issues a JWT.&lt;/p&gt;

&lt;h3&gt;
  
  
  /start - Create and Send the Code
&lt;/h3&gt;

&lt;p&gt;When a user requests a code, the start endpoint creates or resets an OTP record in DynamoDB, generates the next HOTP code, and sends it via Mailgun (email) or Twilio (SMS).&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;otp_verification_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queryStringParameters&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\D&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;otp_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;otp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DoesNotExist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;otp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;otp_secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random_base32&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;otp_secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.mailgun.net/v3/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MAILGUN_DOMAIN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&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;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAILGUN_API_KEY&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                      &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&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;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FROM_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&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;Verify your email&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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your PIN: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.twilio.com/2010-04-01/Accounts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TWILIO_SID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/Messages.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TWILIO_SID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TWILIO_TOKEN&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                      &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&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;To&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="si"&gt;}&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;From&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TWILIO_NUMBER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your PIN: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&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;If a record already exists for this email or phone, I increment the counter instead of creating a new secret. This means the previous code is immediately invalidated - &lt;code&gt;pyotp.HOTP.verify()&lt;/code&gt; checks against the exact counter value, so only the latest code works. If a user requests a second code before entering the first one, they need to use the new code.&lt;/p&gt;

&lt;p&gt;The Mailgun and Twilio calls are raw HTTP requests. No SDK. For a function this small, pulling in &lt;code&gt;boto3&lt;/code&gt; or the Twilio SDK would double the deployment package for no benefit.&lt;/p&gt;

&lt;h3&gt;
  
  
  /complete - Verify and Issue JWT
&lt;/h3&gt;

&lt;p&gt;When the user enters their code, the complete endpoint looks up their OTP record, verifies the HOTP code, deletes the record, and returns a signed JWT.&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;otp_verification_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queryStringParameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;pin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;otp_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\D&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&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="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;otp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises DoesNotExist if expired or never created
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;otp_secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid PIN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tel:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss&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;https://otp.potapov.dev/&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;aud&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;https://api.potapov.dev/&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;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;success_response&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I delete the record on successful verification rather than incrementing the counter. This prevents code reuse and avoids stale records piling up in DynamoDB. If verification fails, the record stays - the user can try again with the same code until TTL expires.&lt;/p&gt;

&lt;p&gt;One shortcut I should be honest about: rate limiting. RFC 4226 explicitly requires throttling to resist brute force attacks - a 6-digit code has only a million possible values. I handle this with Lambda's &lt;code&gt;reservedConcurrency&lt;/code&gt; set to 1 per function, which you can see in the &lt;code&gt;serverless.yml&lt;/code&gt; above. That's not real rate limiting per user - it's a concurrency cap that serializes requests to the endpoint, but doesn't stop a patient attacker from trying codes sequentially for a single email. For a personal demo that handles a few logins per day, it's acceptable. For anything beyond that, you'd want per-IP or per-user throttling at the API Gateway level, or a DynamoDB counter that locks out after 3 failed attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  JWT: From Code to Client
&lt;/h2&gt;

&lt;p&gt;After successful OTP verification, the service issues a signed JWT. This token is how downstream APIs know the user proved ownership of their email or phone number.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying on the Receiving End
&lt;/h3&gt;

&lt;p&gt;The /complete endpoint issues the token. The interesting part is what happens when a downstream API receives it. Here's what validation looks like:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;

&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&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;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.potapov.dev/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://otp.potapov.dev/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# "email:user@example.com"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;algorithms&lt;/code&gt; parameter is required in PyJWT 2.x - omitting it raises &lt;code&gt;DecodeError&lt;/code&gt;. The &lt;code&gt;audience&lt;/code&gt; and &lt;code&gt;issuer&lt;/code&gt; parameters enforce that the token was meant for this specific API and issued by our OTP service. If either doesn't match, PyJWT raises an exception before your code ever sees the claims. The 24-hour expiry is generous for a demo - in production I'd cut it to 1-4 hours depending on the use case.&lt;/p&gt;

&lt;p&gt;One Python detail worth noting: the &lt;code&gt;datetime.now(timezone.utc)&lt;/code&gt; call in the /complete endpoint (with &lt;code&gt;from datetime import datetime, timedelta, timezone&lt;/code&gt;). If you're reading older tutorials that use &lt;code&gt;datetime.utcnow()&lt;/code&gt;, that's deprecated since Python 3.12 and now emits a &lt;code&gt;DeprecationWarning&lt;/code&gt;. The old version returns a naive datetime, the new one returns a timezone-aware datetime. PyJWT 2.x handles both, but the new form is correct and won't spam your logs with warnings.&lt;/p&gt;

&lt;h3&gt;
  
  
  HS256 vs RS256
&lt;/h3&gt;

&lt;p&gt;I use HS256 (symmetric signing) because this is a single-service setup. The same secret that signs the token also verifies it. Simple, fast, one environment variable.&lt;/p&gt;

&lt;p&gt;The limitation: only services that know the &lt;code&gt;JWT_SECRET&lt;/code&gt; can verify tokens. If I wanted the OTP service to act as a third-party identity provider - where other apps verify tokens without knowing the signing key - I'd switch to RS256. RS256 uses a private key to sign and a public key to verify. You publish the public key, and any service can validate tokens without accessing secrets.&lt;/p&gt;

&lt;p&gt;For a side project or internal tool, HS256 is the right call. For a product where external services consume your tokens, RS256 is worth the extra setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Client-Side Decoding
&lt;/h3&gt;

&lt;p&gt;JWTs are signed, not encrypted. The payload is base64-encoded JSON that any client can read:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jwtDecode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jwt-decode&lt;/span&gt;&lt;span class="dl"&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;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jwtDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&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;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "email:user@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's by design. The client needs to know who's logged in without making a server round-trip. But it means you should never put sensitive data in JWT claims - anything in the payload is readable by anyone with the token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build vs Buy
&lt;/h2&gt;

&lt;p&gt;Every OTP tutorial skips this part. They show you how to build it, declare victory, and leave you to figure out whether you should have used Auth0 instead. I've run this service for three years alongside projects that use Cognito and Clerk. Here's when each makes sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Build Your Own
&lt;/h3&gt;

&lt;p&gt;Build your own OTP service when all of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your auth needs are simple: email or phone verification, nothing else.&lt;/li&gt;
&lt;li&gt;You don't need SSO, SAML, or social login.&lt;/li&gt;
&lt;li&gt;You're comfortable deploying to Lambda (or any serverless platform).&lt;/li&gt;
&lt;li&gt;You want to own your auth stack. No vendor can change pricing, deprecate an API, or sunset a feature you depend on. Your data stays in your AWS account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cost isn't the main reason to build - managed services have &lt;a href="https://auth0.com/pricing" rel="noopener noreferrer"&gt;generous free tiers&lt;/a&gt; now. The real advantage is simplicity and control. When something breaks (and it will, eventually), you can read every line of the service in 10 minutes. Try doing that with Cognito's documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Buy
&lt;/h3&gt;

&lt;p&gt;Buy a managed auth service when any of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need MFA beyond OTP (FIDO2, passkeys, authenticator apps).&lt;/li&gt;
&lt;li&gt;Your customers require SSO/SAML integration.&lt;/li&gt;
&lt;li&gt;You have compliance requirements (SOC 2, HIPAA, GDPR consent flows).&lt;/li&gt;
&lt;li&gt;You're building a team product where auth touches user roles, permissions, organizations.&lt;/li&gt;
&lt;li&gt;You'd rather not think about auth at all - the &lt;a href="https://clerk.com/pricing" rel="noopener noreferrer"&gt;free tiers&lt;/a&gt; are generous enough that cost isn't a factor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wouldn't use this OTP service for a B2B SaaS product. Enterprise customers expect SSO. Security auditors expect documented auth flows, not a Lambda function someone built on a weekend. The moment you need "forgot password" or "link social account" or "enforce password rotation," you're reinventing a wheel that Auth0 and Clerk have spent years refining.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Decision
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criteria&lt;/th&gt;
&lt;th&gt;Build (DIY OTP)&lt;/th&gt;
&lt;th&gt;Buy (&lt;a href="https://auth0.com/pricing" rel="noopener noreferrer"&gt;Auth0&lt;/a&gt;/&lt;a href="https://aws.amazon.com/cognito/pricing/" rel="noopener noreferrer"&gt;Cognito&lt;/a&gt;/&lt;a href="https://clerk.com/pricing" rel="noopener noreferrer"&gt;Clerk&lt;/a&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost (low traffic)&lt;/td&gt;
&lt;td&gt;&amp;lt;$1&lt;/td&gt;
&lt;td&gt;$0 (free tiers: 10-50K users)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost at scale&lt;/td&gt;
&lt;td&gt;Scales with SMS only&lt;/td&gt;
&lt;td&gt;$35-240+/mo for paid features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;2-4 hours&lt;/td&gt;
&lt;td&gt;30 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO/SAML&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MFA options&lt;/td&gt;
&lt;td&gt;OTP only&lt;/td&gt;
&lt;td&gt;OTP, FIDO2, passkeys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor lock-in&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Medium to high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance certifications&lt;/td&gt;
&lt;td&gt;You own it&lt;/td&gt;
&lt;td&gt;Provider handles it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance burden&lt;/td&gt;
&lt;td&gt;Near zero (serverless)&lt;/td&gt;
&lt;td&gt;Near zero (managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customization&lt;/td&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;Limited by provider&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest answer is that most teams should buy. Auth is a solved problem, and the managed services handle edge cases (account recovery, brute force protection, session management) that you'll eventually need. I kept running my own because the cost is negligible and I like owning the stack. That's a preference, not a recommendation.&lt;/p&gt;

&lt;p&gt;There's a grey area worth mentioning: projects that start with simple OTP and grow. You build this for your MVP, users love it, now you need "remember this device" and "sign in with Google" and "admin can revoke sessions." At that point you're building an auth platform, not an OTP service. The right move is to migrate to a managed provider before you've reinvented half of it. I've seen teams spend months adding features to DIY auth that Auth0 ships out of the box.&lt;/p&gt;

&lt;p&gt;If you're a solo founder building an MVP, a side project, or anything where "user enters email, gets a code, gets a token" is the entire auth story - building takes an afternoon and costs nothing. The moment auth becomes a feature instead of plumbing, switch to a managed service and spend your time on what makes your product different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Years in Production
&lt;/h2&gt;

&lt;p&gt;I deployed this service in February 2023. It's now 2026 and it's still running. Here's what that looks like in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Went Right
&lt;/h3&gt;

&lt;p&gt;The service has had exactly zero outages that I caused. Lambda functions don't go stale, DynamoDB tables don't need vacuuming, and there's no server to patch. I haven't SSH'd into anything because there's nothing to SSH into. The only maintenance I've done in three years is updating the Mailgun sending domain when I moved to a new domain registrar.&lt;/p&gt;

&lt;p&gt;Total operational cost over three years: under $30, almost entirely Twilio SMS charges. The AWS components have never exceeded free tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Went Wrong
&lt;/h3&gt;

&lt;p&gt;Twice, Mailgun rate-limited my sending domain because I was on the free tier and hit the 100 emails/day cap during a demo. Not a code problem - just a free tier limit I should have anticipated. I've since switched to a self-hosted mail server, but &lt;a href="https://aws.amazon.com/ses/pricing/" rel="noopener noreferrer"&gt;AWS SES&lt;/a&gt; would have been the simpler fix.&lt;/p&gt;

&lt;p&gt;Once, a DynamoDB TTL deletion was delayed by about 15 minutes (TTL is best-effort, not exact), which meant an expired code still technically existed in the database. The HOTP verification still failed because the counter didn't match, so no security issue - just a confusing "OTP code not found" vs "Invalid PIN" error message distinction.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'd Change
&lt;/h3&gt;

&lt;p&gt;If I were hardening this for production use beyond my own projects, four things would change.&lt;/p&gt;

&lt;p&gt;I'd switch to RS256 for JWT signing, for the reasons I described above - the moment a second service needs to verify tokens, symmetric signing becomes a liability. I'd also add real per-user rate limiting instead of the &lt;code&gt;reservedConcurrency&lt;/code&gt; workaround I mentioned in the endpoints section. A DynamoDB counter that locks out after 3 failed attempts per email is straightforward and would actually satisfy the RFC 4226 throttling requirement.&lt;/p&gt;

&lt;p&gt;The error responses need work. Right now they're generic ("Invalid PIN", "OTP code not found"). Distinguishing between "expired" and "never created" helps the frontend show the right UX - "Your code expired, request a new one" is more useful than "something went wrong."&lt;/p&gt;

&lt;p&gt;And I'd wrap the OTP secret with KMS before storing it. DynamoDB encrypts at rest by default, so it's not sitting unencrypted on disk, but application-level encryption with KMS would add negligible cost and measurable security.&lt;/p&gt;

&lt;p&gt;None of these are deal-breakers for a personal demo. They're the kind of improvements that matter when the service grows beyond "my side project" into "something other people depend on."&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;p&gt;The whole service is 180 lines of Python, two Lambda functions, and one DynamoDB table. It handles email and SMS verification with proper HOTP codes, issues signed JWTs, and cleans up after itself. I've been running it for three years without touching it.&lt;/p&gt;

&lt;p&gt;Should you build this? If your app needs auth and your budget is zero, you now have working code. If your app needs auth and your budget isn't zero, you now know exactly when a managed service is worth it - and you can make that call based on features you actually need, not on the assumption that auth is too hard to own.&lt;/p&gt;

&lt;p&gt;Fork the &lt;a href="https://github.com/muzhig/simple-otp" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, try the &lt;a href="https://otp.potapov.dev" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; to see the flow in action, and decide for yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article was created with the assistance of Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>backend</category>
      <category>prototype</category>
    </item>
    <item>
      <title>AI Data Cleaning: From Demo to Production</title>
      <dc:creator>Arseniy Potapov</dc:creator>
      <pubDate>Sat, 14 Feb 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/potapov/ai-data-cleaning-in-production-from-toy-demos-to-real-pipelines-m17</link>
      <guid>https://dev.to/potapov/ai-data-cleaning-in-production-from-toy-demos-to-real-pipelines-m17</guid>
      <description>&lt;p&gt;In December 2022, I wrote an article about using AI to clean data. I was excited. GPT-3 had just become accessible, &lt;code&gt;text-davinci-003&lt;/code&gt; could parse messy CSV rows, and I built a demo with 6 rows of sample data. Six rows. I showed how the model could normalize dates, fix capitalization, and extract structured fields from free text. It worked beautifully.&lt;/p&gt;

&lt;p&gt;It also didn't scale past a demo.&lt;/p&gt;

&lt;p&gt;The model cost $0.02 per 1,000 tokens. Rate limits capped throughput at a few hundred requests per minute. Every API call returned slightly different formatting. There was no validation, no error handling, no way to reproduce results. I was essentially asking the world's most expensive intern to hand-clean each row one at a time.&lt;/p&gt;

&lt;p&gt;Three years later, I build data ingestion systems that process millions of records from dozens of sources, each arriving in its own special flavor of broken. County tax rolls with names like "SMITH JOHN A JR &amp;amp; MARY B SMITH-JONES TTEE OF THE SMITH FAMILY TRUST." Address files where column B switches meaning halfway through. CSV exports with mixed encodings, missing headers, and creative interpretations of what "null" means.&lt;/p&gt;

&lt;p&gt;AI is central to how these systems work. But not the way I imagined in 2022. The LLM doesn't touch every row. It looks at a sample, figures out what's wrong, creates a transformation plan, and hands it off to &lt;code&gt;pandas&lt;/code&gt; and Pydantic to execute and validate. The expensive, intelligent part happens once. The cheap, deterministic part runs on every row.&lt;/p&gt;

&lt;p&gt;This is the article I should have written three years ago. Here's what actually works when you're cleaning data at production scale - not with 6 rows, but with millions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Customer's Data Is Broken in Its Own Way
&lt;/h2&gt;

&lt;p&gt;Clean datasets are all alike; every dirty dataset is dirty in its own way.&lt;/p&gt;

&lt;p&gt;I've processed data from county governments, mortgage companies, title agencies, and tax assessors across 14 states. Not one source arrived clean. The problems fall into predictable categories, but the specific combination is unique every time.&lt;/p&gt;

&lt;p&gt;Start with the structural problems. CSV files with no headers, mixed delimiters (tabs in one section, commas in another), multiline fields that break naive parsers. One county sends an Excel workbook with 6 tabs. Another sends a ZIP of CSVs named &lt;code&gt;EXPORT_FINAL_v2_CORRECTED(1).csv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then there's encoding. Latin-1 data in a UTF-8 world. Windows line endings mixed with Unix. BOM markers that show up as invisible characters. I once spent a day debugging why 200 property addresses contained &lt;code&gt;Ã©&lt;/code&gt; instead of &lt;code&gt;é&lt;/code&gt; - classic UTF-8 double-encoding from an intermediate system that converted the file twice.&lt;/p&gt;

&lt;p&gt;Semantic ambiguity is worse. "John Smith" in one source, "SMITH, JOHN A JR" in another, "SMITH JOHN &amp;amp; MARY TTEE" in a third. Same person, three representations. Dates arrive in 15+ formats: &lt;code&gt;01/02/2024&lt;/code&gt;, &lt;code&gt;2024-01-02&lt;/code&gt;, &lt;code&gt;Jan 2, 2024&lt;/code&gt;, &lt;code&gt;20240102&lt;/code&gt;, and my favorite, &lt;code&gt;1/2/24&lt;/code&gt; (is that January 2nd or February 1st?).&lt;/p&gt;

&lt;p&gt;Missing data has personality. Empty string, "N/A", "n/a", "NA", "NULL", "None", "-", "0", and a single space character - I've seen all of these mean "this field has no value" within the same file. Then there are the blanket deletions where an entire column is empty because the export process silently dropped it.&lt;/p&gt;

&lt;p&gt;And domain-specific landmines. Addresses like "123 MAIN ST APT 4B C/O J SMITH" that need to be split into street, unit, and care-of fields. Property class codes that mean different things in different counties ("01" is "Single Family" in Lee County, "Residential" in Broward, and "Vacant Land" in Marion).&lt;/p&gt;

&lt;p&gt;The problems are predictable by category but unique in combination. You can't write one script that handles all sources. And you can't hire enough people to manually map every new dataset - not when each one arrives with its own encoding, its own column names, its own creative interpretation of how to represent a null value.&lt;/p&gt;

&lt;p&gt;The question is what you do about it. People have tried a lot of things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What People Try Before AI
&lt;/h2&gt;

&lt;p&gt;Every approach to data cleaning exists for a reason, and most of them work fine within their limits. The trouble starts when you outgrow those limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual cleanup in Excel.&lt;/strong&gt; Power Query, VLOOKUP, find-and-replace. This handles one-off tasks with small files perfectly well. If you're fixing 200 rows once a quarter, Excel is the right tool. It's not reproducible, though. Next quarter, when the same file arrives with a new encoding issue, you start from scratch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-off Python scripts.&lt;/strong&gt; A developer writes a &lt;code&gt;pandas&lt;/code&gt; pipeline per data source. I've done this dozens of times: read CSV, rename columns, parse dates, strip whitespace, export. Works great for 5-10 sources. At 50+, you're maintaining 50 scripts, each encoding tribal knowledge that walks out the door when the developer leaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ETL platforms&lt;/strong&gt; like &lt;code&gt;dbt&lt;/code&gt;, Fivetran, or Airbyte are excellent for structured sources: APIs, databases, well-defined schemas. They struggle with truly messy data. The CSV with no headers. The Excel file where column B is sometimes "Owner Name" and sometimes "Property Address" depending on which county clerk exported it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data quality frameworks&lt;/strong&gt; (Great Expectations, Soda, &lt;code&gt;dbt&lt;/code&gt; tests) are superb at &lt;em&gt;detecting&lt;/em&gt; problems: "15% of addresses failed to geocode." They don't &lt;em&gt;fix&lt;/em&gt; anything. You still need a human to figure out why and write the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pasting into ChatGPT&lt;/strong&gt; is where I was in 2022. Send 50 rows, ask for clean output, marvel at the result. It genuinely works for small batches. It doesn't scale: Osmos measured $2,250 for 100K rows through GPT-4 before Microsoft acquired them. Inconsistent output between calls, no validation, and rate limits that kill any automated pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI-assisted pipeline&lt;/strong&gt; is what I'll spend the rest of this article on. AI analyzes samples, generates transformation configs, quality gates verify the output. It combines deterministic execution from scripts, config-driven structure from ETL, automated checks from quality frameworks, and LLMs' ability to understand messy, ambiguous data.&lt;/p&gt;

&lt;p&gt;Here's how they compare:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Scales to 1M rows&lt;/th&gt;
&lt;th&gt;Reproducible&lt;/th&gt;
&lt;th&gt;Handles ambiguity&lt;/th&gt;
&lt;th&gt;Cost per run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Excel / manual&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Human judgment&lt;/td&gt;
&lt;td&gt;Free + hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-off scripts&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Per-script&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Dev time per source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ETL platforms&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Platform fees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality frameworks&lt;/td&gt;
&lt;td&gt;Detection only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Config time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paste into ChatGPT&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (LLM)&lt;/td&gt;
&lt;td&gt;$2-2,250 per batch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI-assisted pipeline&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (LLM + rules)&lt;/td&gt;
&lt;td&gt;Hours once, pennies per run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these approaches is universally wrong. Excel is perfect for one-off cleanup. Scripts are perfect for stable, well-understood sources. ETL platforms are perfect for structured pipelines. The AI-assisted approach fills the gap between them: understanding messy, ambiguous data and generating the transformation logic that traditional tools then execute.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI as Planner, Not Processor
&lt;/h2&gt;

&lt;p&gt;The pattern that works in production is simple to describe: AI looks at a sample of your data, creates a transformation plan, and the system executes that plan with traditional tools. The LLM touches 100 rows. &lt;code&gt;pandas&lt;/code&gt; processes 10 million.&lt;/p&gt;

&lt;p&gt;Here's the concrete version. Five steps, each doing one thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Define the target shape.&lt;/strong&gt; Before any AI gets involved, you need a contract - what should clean data look like? I use Pydantic models for this because they double as validation:&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;class&lt;/span&gt; &lt;span class="nc"&gt;PropertyRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parcel_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unique parcel identifier, e.g. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;12-34-56-789&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;owner_first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;owner_last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;zip_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^\d{5}(-\d{4})?$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;assessed_value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;is_homestead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This schema isn't just documentation. It's the success criterion. After cleaning, every row must validate against it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Describe expectations.&lt;/strong&gt; Give the AI context about the data - not the schema (that's step 1), but &lt;em&gt;what the data represents&lt;/em&gt;. "This is a property tax roll export from a county government. Each row is a parcel. Owners can be individuals, trusts, or corporations. Addresses include both mailing and property-site addresses."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Feed AI sample data plus a toolkit.&lt;/strong&gt; Send the LLM 50-100 representative rows, the target schema, and a list of available transformation operations: &lt;code&gt;rename_column&lt;/code&gt;, &lt;code&gt;parse_date&lt;/code&gt;, &lt;code&gt;split_name&lt;/code&gt;, &lt;code&gt;filter_rows&lt;/code&gt;, &lt;code&gt;map_values&lt;/code&gt;, &lt;code&gt;convert_type&lt;/code&gt;. The LLM doesn't need to implement these - it just needs to know they exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: AI generates the plan.&lt;/strong&gt; The LLM responds with an ordered list of transformations. In our production system, this is a JSON config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transforms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rename"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mapping"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"OWNERNAME1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner_raw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"SITUS_ADDR"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"map_values"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prop_class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"mapping"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"01"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Single Family"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"02"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mobile Home"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"03"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Condo"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"split_name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner_raw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"targets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"owner_first"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner_last"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trust_flag"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"filter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"address_type == 'situs'"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"convert"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assessed_value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"float"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"null_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This config could be a Python script, a SQL query, or a &lt;code&gt;dbt&lt;/code&gt; model. The format matters less than the pattern: it's declarative, versionable, and deterministic. Same input always produces same output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Validate, execute, monitor.&lt;/strong&gt; Run the plan on a subset. Check quality gates (more on those next). If gates fail, send the failures back to the AI and let it adjust the plan. Once the subset passes, run on the full dataset. Check gates again.&lt;/p&gt;

&lt;p&gt;This is why it scales. The expensive part (LLM analysis) happens once per data source. The cheap part (&lt;code&gt;pandas&lt;/code&gt; applying the config) runs on every row. When the same county sends next month's data in the same format, the config runs without any AI involvement at all. When a new county arrives with a completely different layout, AI generates a new config.&lt;/p&gt;

&lt;p&gt;In our production system, we run 100+ county configurations. Each one was originally created by a human engineer studying the data. But the process that human follows - look at samples, understand the schema, write the mapping - is exactly what an AI agent can automate. The engineer's job shifts from "write the config" to "review the AI's config and approve it."&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Know the Data Is Actually Clean?
&lt;/h2&gt;

&lt;p&gt;You don't trust AI-generated code without tests. You shouldn't trust AI-generated transformation plans without quality gates either. The principle is the same: automate the work, verify the result.&lt;/p&gt;

&lt;p&gt;In our production pipeline, every data load runs through four layers of checks before anything hits the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema gates&lt;/strong&gt; are the baseline. Every field matches its expected type. IDs are unique. Required fields aren't null. Dates parse to ISO format. Foreign keys point at existing records. If your Pydantic model says &lt;code&gt;zip_code&lt;/code&gt; must match &lt;code&gt;^\d{5}$&lt;/code&gt; and 3% of rows have "N/A" in that field, the gate catches it before those rows propagate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shape gates&lt;/strong&gt; catch structural problems. On a refresh (re-processing the same data source), parcel count shouldn't change by more than 10%. If it does, someone uploaded the wrong file, or the schema changed, or data got truncated. We flag anything above 5% as a warning and above 10% as an error that blocks ingestion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution gates&lt;/strong&gt; are where it gets interesting. After parsing 50,000 owner names, we check the top 20 most common first names. If "SMITH" appears as a first name, the parser swapped first and last name columns. If more than 5 of the top 20 first names are uncommon (not in a standard frequency list), the parser is splitting names wrong - probably treating "JOHN MICHAEL" as first="JOHN MICHAEL" instead of first="JOHN", middle="MICHAEL."&lt;/p&gt;

&lt;p&gt;We run similar checks on address geocoding: after standardizing addresses through an address validation API (we use SmartyStreets), at least 60% should validate. Below 40% means a systematic parsing problem - wrong column, encoding mismatch, or format the parser doesn't recognize. On exemption rates, 25-40% of residential parcels typically have a homestead exemption. Below 10% means exemption data is missing entirely. Above 60% means the parser is incorrectly flagging non-exempt parcels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business logic gates&lt;/strong&gt; check relationships across tables. Every owner record must reference an existing parcel. Every transaction must reference an existing parcel. Exemption codes must exist in the reference table. These catch join errors and mapping mistakes that look fine in isolation but break when you query across tables.&lt;/p&gt;

&lt;p&gt;The thresholds (10%, 40%, 60%) come from processing 100+ data sources over several years. They encode what "normal" looks like. Data is never 100% accurate - there's always noise, always edge cases. The goal isn't perfection. It's catching &lt;em&gt;systematic&lt;/em&gt; problems early, before bad data propagates through the system and produces wrong results downstream.&lt;/p&gt;

&lt;p&gt;Here's the part that connects to the AI pipeline: when a gate fails, the system doesn't crash. It reports which gate failed, which rows caused the failure, and what looks wrong. That report feeds back to the AI agent, which can analyze the failures and adjust the transformation plan. Human reviews the adjustment, approves, and the pipeline reruns. This is the feedback loop that makes the system trustworthy: AI generates the plan, gates verify it, failures get fixed, and each iteration improves the result.&lt;/p&gt;

&lt;p&gt;This isn't academic idealism. &lt;a href="https://arxiv.org/pdf/2512.04123" rel="noopener noreferrer"&gt;Research on production agent deployments&lt;/a&gt; shows that 68% need human intervention within 10 steps. The best AI systems aren't fully autonomous - they're semi-autonomous with clear checkpoints. Quality gates are those checkpoints for data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name Parsing - Where the Hybrid Pattern Clicks
&lt;/h2&gt;

&lt;p&gt;Let me show you the "AI as planner" pattern with a concrete case study.&lt;/p&gt;

&lt;p&gt;Our data has a &lt;code&gt;full_name&lt;/code&gt; field. It contains everything from "John Smith" to "SMITH, JOHN A JR &amp;amp; MARY B SMITH-JONES TTEE OF THE SMITH FAMILY TRUST DATED 01/01/2020." We need to extract: first name, last name, trust flag, corporate flag. For every record.&lt;/p&gt;

&lt;p&gt;The traditional approach is a regex-based parser. Ours handles the common patterns well: "JOHN SMITH," "SMITH, JOHN A JR," "DR JOHN MICHAEL SMITH," Hispanic compounds like "DE LA CRUZ." It scores each parse result with a confidence value based on how well the tokens match known name patterns.&lt;/p&gt;

&lt;p&gt;About 80% of names parse cleanly with high confidence. That's 40,000 out of 50,000 records handled instantly by deterministic code. No AI involved, no API cost, no latency.&lt;/p&gt;

&lt;p&gt;The remaining 20% is where regex breaks down. Trust names: "DEBBIE DAVIDSON REVOCABLE TRUST." Multiple owners in a trust: "WALSH JOSEPH T &amp;amp; NHUNG REVOCABLE TRUST." Corporate entities: "M &amp;amp; M MANAGEMENT LLC." Names where context matters: is "LE" a Vietnamese surname or a legal abbreviation?&lt;/p&gt;

&lt;p&gt;These low-confidence results get routed to an LLM. The prompt includes the raw name string, the target schema (first_name, last_name, trust_flag, is_corporate), and a few examples of correct parsing. The LLM handles the ambiguity that regex can't.&lt;/p&gt;

&lt;p&gt;Out of those 10,000 LLM-routed names, roughly 9,500 resolve cleanly. The remaining 500 get flagged for human review - a queue that a person can clear in a few hours instead of manually fixing all 10,000.&lt;/p&gt;

&lt;p&gt;The cost math: the LLM portion costs about $2 for 10,000 names. The alternative is hiring someone to manually parse 10,000 complex names, which takes days.&lt;/p&gt;

&lt;p&gt;The pattern generalizes beyond names. Any data cleaning task where 80% is predictable and 20% is ambiguous fits this model: let traditional tools handle the easy cases, route the hard cases to AI, validate everything against the schema, and flag what neither can handle. You're not replacing the regex parser. You're adding a second tier that handles what the regex parser can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop Copy-Pasting Into ChatGPT
&lt;/h2&gt;

&lt;p&gt;There's a difference between using AI as a chat tool and using AI as a worker. Most "AI data cleaning" tutorials show the chat version: open ChatGPT, paste 50 rows, ask it to clean them, copy the result back. That works for one-off tasks the way manual Excel cleanup works - fine for small, non-recurring jobs.&lt;/p&gt;

&lt;p&gt;The production version looks completely different from the user's perspective. Here's what actually happens when someone uploads data to our system:&lt;/p&gt;

&lt;p&gt;A user receives a link. They drop their files - a ZIP of CSVs, an Excel workbook, a folder of PDFs. They don't know or care about our schema. They just know they need to get their data into the system.&lt;/p&gt;

&lt;p&gt;Behind the scenes: the system unpacks the archive and identifies file formats. An AI agent examines samples from each file, detects what each column likely represents, and generates a transformation config. The system runs that config, quality gates verify the output, and if something fails, the agent adjusts and retries. If it still can't resolve the issue, a human gets a specific report: "45% of addresses didn't geocode. Likely cause: PO boxes mixed with street addresses. 1,247 rows affected."&lt;/p&gt;

&lt;p&gt;The user who uploaded never sees any of this. They get "success" or "we need to clarify a few things."&lt;/p&gt;

&lt;p&gt;The key difference from the chat approach: an AI agent uses tools. It doesn't try to hold a million rows in its context window. It calls &lt;code&gt;pandas&lt;/code&gt; to read files, runs SQL to check distributions, executes validation functions, and writes configs. It iterates - try, check, adjust, retry - without a human in the loop for each step.&lt;/p&gt;

&lt;p&gt;An agent processing 1,000 files doesn't pipe each file through the LLM. It categorizes files by format and problem type, creates a plan per category, and executes plans with traditional tools. The LLM might make 20 API calls total for 1,000 files. The rest is &lt;code&gt;pandas&lt;/code&gt;, &lt;code&gt;Pydantic&lt;/code&gt;, and SQL doing what they do best: processing data fast and deterministically.&lt;/p&gt;

&lt;p&gt;This isn't a chatbot. It doesn't need a conversation UI. It doesn't need your attention. It reads messy files, figures out what's wrong, writes the fix, tests it, and leaves a report. When it can't fix something, it tells you exactly what's wrong and why. That's the kind of AI I'm excited about - not the one that talks to you, but the one that works for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Realities
&lt;/h2&gt;

&lt;p&gt;Let me be honest about the parts that aren't elegant.&lt;/p&gt;

&lt;p&gt;Sending every row through an LLM doesn't survive contact with the finance team. One million CSV rows at 500 tokens each through a budget API ($0.10/M tokens) costs $50 per run. Through GPT-4, it's closer to $5,000. Run that weekly across 50 data sources and you're looking at a six-figure annual line item for data cleaning alone. The config-generation approach costs a few dollars per data source (one-time LLM analysis of samples) plus pennies for &lt;code&gt;pandas&lt;/code&gt; execution on every run. That's 3-4 orders of magnitude cheaper. One practical tip: if you are sending data to an LLM, send it as CSV, not JSON. CSV uses 50-56% fewer tokens for the same data and LLMs parse it just as accurately.&lt;/p&gt;

&lt;p&gt;Rate limits compound the cost problem. We've had API providers throttle us during burst processing. Fifty thousand records hitting an LLM endpoint floods the rate limit within minutes. Batch APIs help (50% discount, higher throughput), but the latency goes from seconds to hours. If you need data cleaned before a morning deadline, that's a problem.&lt;/p&gt;

&lt;p&gt;LLMs also aren't deterministic. Same prompt, different run, slightly different output. For &lt;em&gt;planning&lt;/em&gt; this is fine - the generated config is deterministic once it exists. For &lt;em&gt;row-level processing&lt;/em&gt; it's a deal-breaker. I've seen the same name parsed as "John Smith Jr" in one call and "Smith, John Junior" in the next. Configs don't have this problem. They run the same way every time.&lt;/p&gt;

&lt;p&gt;And there are things AI simply can't do yet. Deduplication across records is still hard - a &lt;a href="https://arxiv.org/html/2511.21708" rel="noopener noreferrer"&gt;2025 evaluation&lt;/a&gt; found that LLMs excel at standardization and profiling but fail at non-exact deduplication. "J. Smith at 123 Main" vs "John Smith at 123 Main St Apt 4" requires comparing millions of pairs, and LLMs are both too slow and too inconsistent for this. Use &lt;code&gt;dedupe&lt;/code&gt; or &lt;code&gt;recordlinkage&lt;/code&gt; for fuzzy matching. AI can help &lt;em&gt;define&lt;/em&gt; the matching rules, but the execution needs traditional algorithms.&lt;/p&gt;

&lt;p&gt;For high-volume or privacy-sensitive pipelines, self-hosting (Ollama for development, vLLM for production, cloud GPUs for burst) eliminates rate limits and API costs. The break-even is roughly 2 million tokens per day at 70%+ utilization. Below that, APIs are cheaper when you factor in infrastructure overhead.&lt;/p&gt;

&lt;p&gt;The upside that makes all of this worthwhile is resilience. Traditional pipelines are brittle - one unexpected column name, one new encoding, one schema change, and the pipeline crashes at 3 AM. AI-assisted pipelines degrade gracefully. New format arrives? The agent analyzes the sample and generates a new config. Encoding changed? Detected and handled. Unseen edge case? Flagged for review instead of silently producing garbage. Each new data source makes the system smarter, not more fragile. The library of configs grows, the quality gate thresholds get refined, and the percentage of cases handled automatically increases over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent Helper
&lt;/h2&gt;

&lt;p&gt;Everyone is tired of chatbots. "Talk to AI" has become the default pitch for every product, every feature, every startup. But there's another kind of AI that doesn't get the hype and does most of the useful work.&lt;/p&gt;

&lt;p&gt;It doesn't have a chat interface. It doesn't need your attention. It reads 500 messy files while you sleep, figures out what's wrong with each one, writes the transformation config, tests it against quality gates, and leaves a report on your desk in the morning. When it can't fix something, it tells you exactly what's wrong and why. When a new format arrives that it's never seen before, it adapts instead of crashing.&lt;/p&gt;

&lt;p&gt;What previously required a week of an onboarding engineer's time - staring at spreadsheets, guessing at column meanings, writing one-off scripts, debugging encoding issues - now takes an afternoon of supervised automation. The human becomes the reviewer, not the laborer. That's a better use of judgment.&lt;/p&gt;

&lt;p&gt;The three-year lesson is simple. AI is best as a planner and edge-case resolver, not as a row-level processor. Define your target schema. Sample the data. Let AI generate the transformation plan. Verify with quality gates. Execute at scale with traditional tools. Handle exceptions. That's the pattern, and it works.&lt;/p&gt;

&lt;p&gt;This approach isn't revolutionary - the industry has converged on it independently. Every major data platform shipped some version of "AI suggests, human approves" in 2025. What's still missing is the practitioner knowledge: what thresholds to set, how to structure the feedback loop, when to route to AI versus when traditional tools are sufficient. That's what I tried to share here.&lt;/p&gt;

&lt;p&gt;As agents get better at using tools, this pattern will become standard infrastructure. Today it's an engineering pattern you build. Soon it'll be a checkbox in your data platform. But the fundamentals - target schemas, quality gates, confidence-based routing - those don't change. They're the boring, essential foundation that makes the AI part trustworthy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article was created with the assistance of Claude Code.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>data</category>
      <category>python</category>
      <category>production</category>
    </item>
  </channel>
</rss>
