<?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: TABATA Hitoshi</title>
    <description>The latest articles on DEV Community by TABATA Hitoshi (@hitoshi1964).</description>
    <link>https://dev.to/hitoshi1964</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%2F3975979%2F32ff10ed-7e5e-4fb0-82bb-39b1dda4afeb.jpg</url>
      <title>DEV Community: TABATA Hitoshi</title>
      <link>https://dev.to/hitoshi1964</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hitoshi1964"/>
    <language>en</language>
    <item>
      <title>Eight of my eighteen AWS scanners failed against a fake AWS — and that was the point</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:10:09 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/eight-of-my-eighteen-aws-scanners-failed-against-a-fake-aws-and-that-was-the-point-402c</link>
      <guid>https://dev.to/hitoshi1964/eight-of-my-eighteen-aws-scanners-failed-against-a-fake-aws-and-that-was-the-point-402c</guid>
      <description>&lt;p&gt;You cannot try a self-hosted AWS tool without an AWS account. That is a real wall: the thing needs credentials, a role to assume, and something in the account worth scanning before it shows you anything at all.&lt;/p&gt;

&lt;p&gt;So I pointed it at a fake AWS — LocalStack, in a Compose profile, with a seed script that fills it with resources. Then I ran the scanner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ten of the eighteen scanners worked. Eight failed.&lt;/strong&gt; The free edition of the&lt;br&gt;
emulator does not implement those services, so they returned errors.&lt;/p&gt;

&lt;p&gt;That was the interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scan that stops at the first error cannot tell you what was deleted
&lt;/h2&gt;

&lt;p&gt;Drift detection is not only "this attribute changed". The category that actually bites is &lt;strong&gt;the resource that is no longer there&lt;/strong&gt; — someone deleted it in the console, and &lt;code&gt;terraform plan&lt;/code&gt; says nothing because it was never in the state.&lt;/p&gt;

&lt;p&gt;To report a deletion, you have to finish the sweep. A resource is gone only if you looked everywhere it could have been and did not find it. Which means: &lt;strong&gt;a scanner that aborts on the first failing service is structurally incapable of detecting deletions.&lt;/strong&gt; It cannot tell "not there" from "I stopped early".&lt;/p&gt;

&lt;p&gt;I had written it to carry on and report an error per service, because that is what deletion detection needs. Running against an emulator that fails eight of eighteen services was the first time that design was tested for real — the sweep completed, the eight reported their own failures, and the deletion detection on the other ten still worked.&lt;/p&gt;

&lt;p&gt;The emulator did not just make the tool easier to try. It exercised the failure mode the tool depends on being right about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/MR-TABATA/SyncVey
&lt;span class="nb"&gt;cd &lt;/span&gt;SyncVey
docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; demo up &lt;span class="nt"&gt;-d&lt;/span&gt;
python scripts/seed_localstack.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No account, no credentials, no bill. The README says which scanners work on the emulator's free edition and which do not, CloudTrail attribution included.&lt;/p&gt;

&lt;p&gt;Starting it is also faster than it used to be: &lt;code&gt;docker compose up -d&lt;/code&gt; now pulls a published image instead of building one on your laptop (0.4.0). Building from source is still one command away — &lt;code&gt;cp docker-compose.override.yml.example docker-compose.override.yml&lt;/code&gt; — and gives you live reload.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/MR-TABATA/SyncVey" rel="noopener noreferrer"&gt;https://github.com/MR-TABATA/SyncVey&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tour: &lt;a href="https://syncvey.com/" rel="noopener noreferrer"&gt;https://syncvey.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;How it got here, release by release: &lt;a href="https://syncvey.com/history.en.html" rel="noopener noreferrer"&gt;https://syncvey.com/history.en.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>pg_total_relation_size() takes a lock. My locks page waited behind the lock it was there to explain.</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Thu, 27 Aug 2026 11:32:56 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/pgtotalrelationsize-takes-a-lock-my-locks-page-waited-behind-the-lock-it-was-there-to-1475</link>
      <guid>https://dev.to/hitoshi1964/pgtotalrelationsize-takes-a-lock-my-locks-page-waited-behind-the-lock-it-was-there-to-1475</guid>
      <description>&lt;p&gt;An &lt;code&gt;ALTER TABLE&lt;/code&gt; is waiting on an idle transaction. Ten sessions are queued behind it.&lt;br&gt;
You open the tool that exists to tell you which session to kill — and the page never&lt;br&gt;
returns.&lt;/p&gt;

&lt;p&gt;That was cli2ui, three weeks ago, on my own machine. The Locks panel itself was fine:&lt;br&gt;
it answered in 0.02 seconds. What never came back was the page on the &lt;em&gt;way&lt;/em&gt; to it.&lt;/p&gt;

&lt;p&gt;The overview and the Health panel both show table sizes, and table sizes come from&lt;br&gt;
&lt;code&gt;pg_total_relation_size()&lt;/code&gt; and &lt;code&gt;pg_table_size()&lt;/code&gt;. Those functions open the relation&lt;br&gt;
they measure. Opening a relation takes &lt;code&gt;ACCESS SHARE&lt;/code&gt;. And &lt;code&gt;ACCESS SHARE&lt;/code&gt; queues behind&lt;br&gt;
the &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; that the stuck &lt;code&gt;ALTER TABLE&lt;/code&gt; is holding.&lt;/p&gt;

&lt;p&gt;So the probe that measures your tables waits for exactly the thing you opened the tool&lt;br&gt;
to investigate. Nothing in that query looks dangerous — it is read-only, it touches no&lt;br&gt;
user data, it is the sort of thing you put on a dashboard without thinking. It still&lt;br&gt;
takes a lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only and lock-free are different properties.&lt;/strong&gt; I knew that sentence before this&lt;br&gt;
happened. I had still put a lock-taking probe on the path to the lock-diagnosis page.&lt;/p&gt;

&lt;p&gt;The fix is small and worth stating plainly, because the instinct is to remove the size&lt;br&gt;
cards and I do not think that is right — they are useful the other 99% of the time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;both probes now run under a &lt;strong&gt;1-second &lt;code&gt;lock_timeout&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;when they time out they &lt;strong&gt;report what happened&lt;/strong&gt; instead of failing the request&lt;/li&gt;
&lt;li&gt;each &lt;strong&gt;degrades its own card&lt;/strong&gt;, so the Health panel keeps every card a lock cannot touch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool now gets &lt;em&gt;less&lt;/em&gt; useful during a lock jam, rather than unreachable. That is the&lt;br&gt;
trade I want: a diagnostic tool has to be at its most reliable in exactly the situation&lt;br&gt;
it exists for.&lt;/p&gt;

&lt;p&gt;MySQL is unaffected, incidentally, and for a reason worth knowing: its sizes come from&lt;br&gt;
&lt;code&gt;information_schema.TABLES&lt;/code&gt;, which reads the data dictionary rather than opening the&lt;br&gt;
relation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other half: starting the app is now a pull, not a build
&lt;/h3&gt;

&lt;p&gt;Same release, unrelated lesson. &lt;code&gt;docker compose up&lt;/code&gt; used to run apt and pip on your&lt;br&gt;
laptop before showing you the first screen — a laptop that has no reason to compile&lt;br&gt;
anything. Compose now points at a published image (&lt;code&gt;jiniie/cli2ui:latest&lt;/code&gt;, amd64 and&lt;br&gt;
arm64, pushed on &lt;code&gt;v*&lt;/code&gt; tags), so starting the app is a pull. &lt;code&gt;CLI2UI_IMAGE&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;&lt;br&gt;
pins a version. Building from source moved to &lt;code&gt;docker-compose.override.yml.example&lt;/code&gt; —&lt;br&gt;
copy it and you get &lt;code&gt;build: .&lt;/code&gt; plus the working-tree mount, i.e. the old behaviour with&lt;br&gt;
live reload.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If you already have a checkout, read this before upgrading.&lt;/strong&gt; The default compose&lt;br&gt;
path no longer mounts &lt;code&gt;.:/app&lt;/code&gt;, so the management database now lives on a named volume&lt;br&gt;
(&lt;code&gt;CLI2UI_DB_PATH&lt;/code&gt; → &lt;code&gt;/data&lt;/code&gt;). A checkout with saved connections will start &lt;strong&gt;empty&lt;/strong&gt;:&lt;br&gt;
nothing was deleted, the app is looking somewhere else. Copy your &lt;code&gt;db.sqlite3&lt;/code&gt; into the&lt;br&gt;
volume, or copy &lt;code&gt;docker-compose.override.yml.example&lt;/code&gt; to put the file back in the&lt;br&gt;
project root where you can see it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is also why &lt;code&gt;:latest&lt;/code&gt; already moved: if you pull today, you get 1.4.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/MR-TABATA/cli2ui" rel="noopener noreferrer"&gt;https://github.com/MR-TABATA/cli2ui&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Release notes: &lt;a href="https://github.com/MR-TABATA/cli2ui/releases/tag/v1.4.0" rel="noopener noreferrer"&gt;https://github.com/MR-TABATA/cli2ui/releases/tag/v1.4.0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tour: &lt;a href="https://cli2ui.com/" rel="noopener noreferrer"&gt;https://cli2ui.com/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>sql</category>
    </item>
    <item>
      <title>pg_cancel_backend() returned true. The queue didn't move.</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Tue, 25 Aug 2026 10:27:02 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/pgcancelbackend-returned-true-the-queue-didnt-move-4921</link>
      <guid>https://dev.to/hitoshi1964/pgcancelbackend-returned-true-the-queue-didnt-move-4921</guid>
      <description>&lt;p&gt;Ten sessions are stuck. You skip the slow-query hunt, do the hard part properly, and find the one session at the front of the queue — the one holding a lock that everyone else is waiting on.&lt;/p&gt;

&lt;p&gt;You press cancel.&lt;/p&gt;

&lt;p&gt;Postgres says &lt;code&gt;t&lt;/code&gt;. Nothing happens.&lt;/p&gt;

&lt;p&gt;Not "nothing happens yet." Nothing happens at all. The session is still there, the locks are still held, and the nine sessions behind it are exactly where they were. The call succeeded and did nothing, which is a worse outcome than failing, because a failure would have told you to try something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancel cancels a &lt;em&gt;query&lt;/em&gt;. That session isn't running one.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_cancel_backend(pid)&lt;/code&gt; sends SIGINT to a backend and interrupts &lt;strong&gt;the query it is currently executing&lt;/strong&gt;. That's the whole contract. The return value means the signal reached a valid backend — not that any work stopped.&lt;/p&gt;

&lt;p&gt;Now look at what the classic jam actually is. A client ran a statement inside a transaction, took its locks, and then went quiet without committing. Postgres calls that state &lt;code&gt;idle in transaction&lt;/code&gt;. There is no query running. There is nothing for SIGINT to interrupt.&lt;/p&gt;

&lt;p&gt;And the locks? They belong to &lt;strong&gt;the transaction&lt;/strong&gt;, not to the statement that acquired them. Cancelling a query — even a real one — doesn't end the transaction. On a session that isn't running a query, cancel has nothing to grab at either end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Two sessions, one row. Session A updates it inside a transaction and then does nothing. Session B tries to update the same row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt; pid |        state        | wait_event_type | blocked_by |              query
-----+---------------------+-----------------+------------+----------------------------------
  99 | idle in transaction | Client          | {}         | UPDATE t SET v='held' WHERE id=1
&lt;/span&gt;&lt;span class="gp"&gt; 120 | active              | Lock            | {99}       | UPDATE t SET v='B' WHERE id=1;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pid 99 is the head. Fire cancel at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_cancel_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

 &lt;span class="n"&gt;cancel_returned&lt;/span&gt;
&lt;span class="c1"&gt;-----------------&lt;/span&gt;
 &lt;span class="n"&gt;t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three seconds later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt; pid |        state        | wait_event_type | blocked_by
-----+---------------------+-----------------+------------
  99 | idle in transaction | Client          | {}
 120 | active              | Lock            | {99}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical. Same state, same wait, same blocker. Now terminate instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_terminate_backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

 &lt;span class="n"&gt;terminate_returned&lt;/span&gt;
&lt;span class="c1"&gt;--------------------&lt;/span&gt;
 &lt;span class="n"&gt;t&lt;/span&gt;

 &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;
&lt;span class="c1"&gt;----+---&lt;/span&gt;
  &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Session B's update lands immediately. Same pid, same &lt;code&gt;t&lt;/code&gt;, completely different outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  The activity list is showing you a fossil
&lt;/h2&gt;

&lt;p&gt;Go back to that first table and look at the &lt;code&gt;query&lt;/code&gt; column for pid 99:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'held'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That session is &lt;strong&gt;not running that query&lt;/strong&gt;. It finished it a while ago. &lt;code&gt;pg_stat_activity.query&lt;/code&gt; is the &lt;em&gt;last&lt;/em&gt; statement the backend ran, not a live one — and for an idle session it just sits there, looking exactly like work in progress.&lt;/p&gt;

&lt;p&gt;This is why the usual instinct fails. You open the activity list, you see a session with a plausible-looking &lt;code&gt;UPDATE&lt;/code&gt;, and every visual cue says "here is a query, cancel it." The one column that would have told you the truth is &lt;code&gt;state&lt;/code&gt;, which is the column nobody sorts by.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wait_event_type&lt;/code&gt; gives it away too, once you know to look: pid 99 says &lt;code&gt;Client&lt;/code&gt;. It isn't waiting on a lock or on I/O. It's waiting on &lt;strong&gt;you&lt;/strong&gt; — on the application, to send the next statement. It'll wait all afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  So the button was sitting exactly where it can't work
&lt;/h2&gt;

&lt;p&gt;I found this while testing something else, and the annoying part wasn't the Postgres behaviour. It was the realisation that in my own tool, the cancel button was rendered on every session in the panel — including at the top of a wait-for chain, which is precisely where the head blocker is &lt;code&gt;idle in transaction&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The button was placed with maximum prominence at the one spot where it is guaranteed to be a no-op. It would even report success.&lt;/p&gt;

&lt;p&gt;The rule to fix that looks like a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@property&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cancellable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Whether &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cancel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; has anything to act on. It stops the *running query*
    (pg_cancel_backend / KILL QUERY), so a session that isn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t running one —
    idle, or idle in transaction — accepts the signal and changes nothing;
    pg_cancel_backend even returns true.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One line, and one line too coarse
&lt;/h2&gt;

&lt;p&gt;Apply that same rule inside a wait-for tree and you break the sessions that &lt;em&gt;were&lt;/em&gt; fine.&lt;/p&gt;

&lt;p&gt;A blocked session — one of the nine stacked behind the head — is waiting on a lock. A lock wait only ever happens &lt;strong&gt;part-way through a statement&lt;/strong&gt;: the backend started executing, reached the point where it needed the lock, and stopped there. There is a real, live query to cancel. Cancel works on it.&lt;/p&gt;

&lt;p&gt;But in the tree, that node's &lt;code&gt;state&lt;/code&gt; is empty. The row it came from is a lock-wait row, which describes &lt;em&gt;what this session is waiting for&lt;/em&gt; — the mode, the object, the duration. Only the blocker side of the join carries session state. So &lt;code&gt;state == "active"&lt;/code&gt; is false for every victim, and the naive rule disables cancel on precisely the sessions where it works.&lt;/p&gt;

&lt;p&gt;The fix is to read the fact that's actually present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@property&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cancellable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Same rule as Activity.cancellable, and it bites hardest here: the
    classic head blocker is idle in transaction, where cancel is a no-op.

    A node that carries wait fields is blocked *on a lock*, which only
    happens part-way through a statement — so it has a running query to
    cancel even though `state` is unset for it.&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock_mode&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it's waiting for a lock, it's mid-statement. That's not an inference about state — it's what a lock wait &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't hide it, and don't wait until afterwards
&lt;/h2&gt;

&lt;p&gt;Two tempting designs, both worse:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hide the button.&lt;/strong&gt; Now the head blocker is the only row with no controls, and the operator wonders whether the tool is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let them press it and show an error.&lt;/strong&gt; There is no error. Postgres returns &lt;code&gt;t&lt;/code&gt;. You'd have to invent a failure that didn't happen.&lt;/p&gt;

&lt;p&gt;What's left is to render it disabled, in place, carrying its own reason:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This session is not running a query, so there is nothing to cancel — it would report success and release nothing. Only kill ends its transaction.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The control stays where the eye expects it, and it explains itself at the moment of the mistake instead of after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  MySQL has the same split
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;KILL QUERY &amp;lt;id&amp;gt;&lt;/code&gt; stops the running statement; &lt;code&gt;KILL &amp;lt;id&amp;gt;&lt;/code&gt; (or &lt;code&gt;KILL CONNECTION&lt;/code&gt;) drops the connection and rolls the transaction back. Same distinction, same trap — a connection sitting in &lt;code&gt;Sleep&lt;/code&gt; with an open transaction has no statement for &lt;code&gt;KILL QUERY&lt;/code&gt; to take.&lt;/p&gt;

&lt;p&gt;The state names differ, so normalise once: a session running a statement is reported as &lt;code&gt;active&lt;/code&gt; on both sides (MySQL's &lt;code&gt;COMMAND='Query'&lt;/code&gt; maps onto it). After that, one rule reads correctly on either engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;Terminate isn't free, and I'm not selling it as the answer. You are throwing away a transaction that someone — some service, some human with a psql window — believes is still open. The application gets a dropped connection, and how gracefully it handles that is not your call to make from a monitoring panel.&lt;/p&gt;

&lt;p&gt;Cancel is still the right tool for the case it was built for: a genuinely long-running &lt;code&gt;active&lt;/code&gt; query you want to stop without killing the session. Nothing here says otherwise.&lt;/p&gt;

&lt;p&gt;And the real fix is upstream of both buttons. A session that goes &lt;code&gt;idle in transaction&lt;/code&gt; for four minutes is an application bug — a transaction opened around a network call, a &lt;code&gt;with&lt;/code&gt; block that isn't there, a connection returned to the pool without a rollback. &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; will end them for you on a schedule, which beats noticing by hand at 2am.&lt;/p&gt;

&lt;p&gt;But when you &lt;em&gt;are&lt;/em&gt; noticing by hand at 2am, it matters a great deal that the button you press does something. &lt;code&gt;t&lt;/code&gt; is not the same as done.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;This is one piece of &lt;strong&gt;&lt;a href="https://cli2ui.com" rel="noopener noreferrer"&gt;cli2ui&lt;/a&gt;&lt;/strong&gt; — a local-only web UI over the &lt;code&gt;psql&lt;/code&gt; commands you keep half-remembering. No AI, no SaaS. It's MIT-licensed on &lt;a href="https://github.com/MR-TABATA/cli2ui" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. What's the longest &lt;code&gt;idle in transaction&lt;/code&gt; you've caught in production?&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>sql</category>
    </item>
    <item>
      <title>I deleted a queue and my own CLI told me a resource had been added</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Sat, 22 Aug 2026 23:00:39 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/i-deleted-a-queue-and-my-own-cli-told-me-a-resource-had-been-added-38h</link>
      <guid>https://dev.to/hitoshi1964/i-deleted-a-queue-and-my-own-cli-told-me-a-resource-had-been-added-38h</guid>
      <description>&lt;p&gt;Last week I finally did something I'd been putting off for months: I pointed my AWS drift detector at a &lt;strong&gt;fake AWS&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not a mock in a unit test — a real emulator listening on a real port, speaking the real API. I created a handful of resources, scanned them, then deleted an SQS queue and scanned again to watch the tool notice. Here is what it printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Demo System / Production: 3 drifted (changed=1 added=2)
    ~ EC2 i-9538ffc1...   instance_state: running -&amp;gt; stopped
    + S3  demo-assets-2
    + SQS demo-jobs          &amp;lt;-- the queue I had just deleted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The web UI, looking at the same database, said the queue was &lt;strong&gt;removed&lt;/strong&gt;. The CLI said it was &lt;strong&gt;added&lt;/strong&gt;. Same data, opposite verdicts, and only one of them was going to be in front of someone at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a deletion looked like a creation
&lt;/h2&gt;

&lt;p&gt;My scanner does not delete rows for resources that vanish from AWS. It stamps &lt;code&gt;missing_since&lt;/code&gt; and leaves the row alone, so you can still answer "what was there, and when did it go?" months later.&lt;/p&gt;

&lt;p&gt;Drift classification then looked at two fields. &lt;code&gt;raw_data&lt;/code&gt; is the latest scan, &lt;code&gt;raw_data_prev&lt;/code&gt; is the one before it. The CLI's rule read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data_prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# never had a previous value
&lt;/span&gt;    &lt;span class="n"&gt;added&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# ...so this is the first time we've seen it
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads fine right up until you notice &lt;strong&gt;&lt;code&gt;raw_data_prev&lt;/code&gt; is empty in two opposite situations&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a resource that just appeared and has only been scanned once&lt;/li&gt;
&lt;li&gt;a resource that appeared and disappeared before a second scan could confirm it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The field is a proxy for "we have no history here." It cannot tell you &lt;em&gt;why&lt;/em&gt; there's no history. The only field that knows a resource is gone is &lt;code&gt;missing_since&lt;/code&gt; — and the CLI never looked at it.&lt;/p&gt;

&lt;p&gt;The correct order isn't clever, it's just an order. &lt;strong&gt;Existence first, attributes second:&lt;/strong&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;if&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;missing_since&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# did it disappear?
&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data_prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# did it appear?
&lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                            &lt;span class="c1"&gt;# did its attributes change?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core already did exactly this. So did the drift report. So did the history snapshot writer. The CLI did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually stings
&lt;/h2&gt;

&lt;p&gt;I wrote the fix in about four minutes, and then went looking for how it happened — and found four copies of the same decision tree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it produces&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;environment badge&lt;/td&gt;
&lt;td&gt;counts for a card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;snapshot writer&lt;/td&gt;
&lt;td&gt;rows for drift history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drift report view&lt;/td&gt;
&lt;td&gt;sections for a page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI&lt;/td&gt;
&lt;td&gt;text, JSON, and a CI exit code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four callers, four hand-written copies of "what category is this asset in."&lt;/p&gt;

&lt;p&gt;This is not the first time it bit me. When I added the &lt;code&gt;removed&lt;/code&gt; category, some copies learned it and some didn't. A later fix updated two of them. &lt;strong&gt;The CLI was the fourth, and it stayed wrong for two releases&lt;/strong&gt; because nothing forces copies to agree.&lt;/p&gt;

&lt;p&gt;The genuinely embarrassing part: my &lt;em&gt;previous&lt;/em&gt; post ended with advice about this. I'd put the Auto Scaling ownership check in one shared helper so all four paths would inherit it, and wrote a smug little paragraph about how a tool that disagrees with itself is worse than no tool. All true — and the classification &lt;em&gt;around&lt;/em&gt; that helper was still copy-pasted four times. I'd deduplicated the thing I had just been burned by, and left the frame it sat in alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: callers stop deciding
&lt;/h2&gt;

&lt;p&gt;One function now owns the decision, and returns nothing but the decision:&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;# asset_manager/drift.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Existence first, attributes second.&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;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;missing_since&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AUTOSCALING&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_autoscaling_churn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;REMOVED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]&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;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data_prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AUTOSCALING&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_autoscaling_churn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;ADDED&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_compute_raw_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data_prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHANGED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;changes&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;changes&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UNCHANGED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each caller keeps only what genuinely differs — counts for the badge, JSON for the CLI, template rows for the page. None of them mentions &lt;code&gt;missing_since&lt;/code&gt; any more, which means none of them can get this wrong again.&lt;/p&gt;

&lt;p&gt;Worth noting what did &lt;em&gt;not&lt;/em&gt; get shared: the output shapes. A badge needs integers, a report needs ORM objects, a CLI needs strings. Trying to unify those too is how you end up with a "flexible" function taking four keyword arguments that each caller sets differently — which is four copies again, wearing a trench coat. &lt;strong&gt;Share the decision, not the presentation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The test I should have written two releases ago
&lt;/h2&gt;

&lt;p&gt;Per-rule tests are the easy half (a deletion is &lt;code&gt;REMOVED&lt;/code&gt;, a scale-in is churn, an ASG instance's &lt;em&gt;attribute&lt;/em&gt; change is still real drift). The one that earns its keep is the one that doesn't test a rule at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_badge_snapshot_and_cli_report_the_same_counts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;badge&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_env_drift_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_record_drift_snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DriftSnapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SCAN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cli&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drift_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;removed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;removed_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&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;cli&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;removed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;removed_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asserts no specific behaviour. It asserts that &lt;strong&gt;my four answers are the same answer&lt;/strong&gt;. If someone (me, in four months, in a hurry) writes a fifth copy that drifts, that's a red build instead of a support email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the emulator found this and my test suite didn't
&lt;/h2&gt;

&lt;p&gt;My unit tests mock AWS at the library layer, and they're good tests — 200+ of them, all green while this bug shipped. They were green because &lt;em&gt;I wrote them from the same mental model that produced the bug&lt;/em&gt;. I never wrote "create a queue, delete it, scan twice, assert it reads as removed," because it didn't occur to me that it wouldn't.&lt;/p&gt;

&lt;p&gt;The emulator is what made that sequence cheap enough to do idly. No AWS account, no credentials, no bill — start a container, make things, break things, watch the tool. The bug surfaced in about ten minutes of poking, not from rigor but from &lt;em&gt;playing with the actual product&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Honest notes from doing it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The free tier of the emulator implements a subset of AWS. &lt;strong&gt;10 of my 18 scanners&lt;/strong&gt; returned data; the rest reported an error per service. That was useful in itself: it exercised the path where a scanner fails, and confirmed a failed scanner is excluded from deleted-resource detection — an unreadable service must never read as "the customer deleted their fleet."&lt;/li&gt;
&lt;li&gt;CloudTrail attribution ("who changed this?") doesn't work there at all. Some things still need a real account.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;:latest&lt;/code&gt; is now a licensed build that exits immediately without an auth token. Pin the major version tag or your first impression is a dead container.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A field that's empty for two opposite reasons is a bug waiting for a quiet afternoon.&lt;/strong&gt; &lt;code&gt;raw_data_prev&lt;/code&gt; meant both "brand new" and "already gone." If you're branching on absence, ask what else could make it absent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge existence before attributes.&lt;/strong&gt; Did it appear, did it disappear, and only then, did it change. Get the order wrong and deletions come out as creations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicating one helper doesn't deduplicate the decision around it.&lt;/strong&gt; I shared the ASG check and left four copies of the classification that used it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test that your surfaces agree, not just that each one works.&lt;/strong&gt; Every copy passed its own tests while disagreeing with the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run your own tool for fun, not just in CI.&lt;/strong&gt; An emulator makes that free, and playing found in ten minutes what 200 green tests missed for two releases.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is a real, open-source (MIT) self-hosted tool that tracks how your live AWS drifts from Terraform — one &lt;code&gt;docker compose up&lt;/code&gt;, and now with a &lt;code&gt;--profile demo&lt;/code&gt; that runs it against a local emulator so you can try it with no AWS account at all: &lt;a href="https://syncvey.com" rel="noopener noreferrer"&gt;syncvey.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you ever found two parts of your own system confidently disagreeing about the same data — and which one did you trust first?&lt;/p&gt;

&lt;p&gt;docker pull jiniie/syncvey&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>My drift detector cried wolf every time my autoscaler scaled out — and the fix was a tag AWS already sets</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Thu, 30 Jul 2026 12:14:52 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/my-drift-detector-cried-wolf-every-time-my-autoscaler-scaled-out-and-the-fix-was-a-tag-aws-4f3e</link>
      <guid>https://dev.to/hitoshi1964/my-drift-detector-cried-wolf-every-time-my-autoscaler-scaled-out-and-the-fix-was-a-tag-aws-4f3e</guid>
      <description>&lt;p&gt;A while back I wrote about the first time my self-hosted drift detector &lt;em&gt;cried&lt;br&gt;
wolf&lt;/em&gt;: a diff bug that flagged phantom changes on every scan. I fixed it and&lt;br&gt;
felt clever.&lt;/p&gt;

&lt;p&gt;Then I pointed it at an account with an &lt;strong&gt;Auto Scaling group&lt;/strong&gt;, and it cried&lt;br&gt;
wolf again — for a completely different reason, and this time the tool was&lt;br&gt;
technically &lt;em&gt;right&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Every time the ASG scaled out, the new instance had an instance ID the previous&lt;br&gt;
scan had never seen. My drift report dutifully counted it as an &lt;strong&gt;added&lt;br&gt;
resource&lt;/strong&gt;. Scale in, scale out, Tuesday afternoon traffic — a steady drip of&lt;br&gt;
"drift" that was nothing of the sort. The autoscaler launching an instance isn't&lt;br&gt;
your infrastructure drifting from Terraform. It's the system working exactly as&lt;br&gt;
designed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why "added" is the wrong verdict here
&lt;/h2&gt;

&lt;p&gt;Attribute drift is easy to reason about: tfstate says &lt;code&gt;t3.micro&lt;/code&gt;, live AWS says&lt;br&gt;
&lt;code&gt;t3.large&lt;/code&gt;, that's drift. "Added" is trickier, because a resource that wasn't&lt;br&gt;
there and now is &lt;em&gt;usually&lt;/em&gt; means someone created something out-of-band — the&lt;br&gt;
exact console click I built this tool to catch.&lt;/p&gt;

&lt;p&gt;But an ASG is different. Terraform describes the &lt;strong&gt;group&lt;/strong&gt; — min, max, desired,&lt;br&gt;
launch template. It does &lt;em&gt;not&lt;/em&gt; describe each individual instance; those are the&lt;br&gt;
autoscaler's runtime decisions. So the identity and count of instances inside an&lt;br&gt;
ASG are never "drift from Terraform." They're supposed to change on their own.&lt;/p&gt;

&lt;p&gt;Counting them as drift doesn't just inflate a number. It trains everyone to&lt;br&gt;
ignore the drift report, which quietly kills the one signal you actually needed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix I almost over-engineered
&lt;/h2&gt;

&lt;p&gt;My first instinct was to call &lt;code&gt;DescribeAutoScalingGroups&lt;/code&gt;, list each group's&lt;br&gt;
members, and cross-reference. That means a new scanner and a new IAM permission&lt;br&gt;
(&lt;code&gt;autoscaling:DescribeAutoScalingGroups&lt;/code&gt;) on every deployment.&lt;/p&gt;

&lt;p&gt;Then I looked at what I was &lt;em&gt;already&lt;/em&gt; scanning. EC2 stamps every ASG-launched&lt;br&gt;
instance with a reserved tag — &lt;code&gt;aws:autoscaling:groupName&lt;/code&gt; — and my EC2 scanner&lt;br&gt;
already reads instance tags. The ownership signal was sitting in my data the&lt;br&gt;
whole time. No new API call, no new permission:&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;# asset_manager/autoscaling.py — one source of truth for "does an ASG own this?"
&lt;/span&gt;&lt;span class="n"&gt;ASG_TAG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;aws:autoscaling:groupName&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;autoscaling_group_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;):&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;raw_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="c1"&gt;# explicit key from the current scanner, else the tag we already had —
&lt;/span&gt;    &lt;span class="c1"&gt;# so instances scanned before this feature existed classify correctly too
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raw_data&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;autoscaling_group&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_data&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;tags&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&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;ASG_TAG&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users can't set the &lt;code&gt;aws:&lt;/code&gt; tag namespace themselves, so its presence is a&lt;br&gt;
reliable "the autoscaler owns this" flag. This is the third time on this project&lt;br&gt;
that the honest fix was &lt;em&gt;"the data already told me"&lt;/em&gt; rather than a new API call —&lt;br&gt;
I'm starting to think that's the default, not the exception.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one line that keeps this from being a foot-gun
&lt;/h2&gt;

&lt;p&gt;Here's the part I want to get right, because the naive version is dangerous.&lt;br&gt;
It's tempting to say "ignore drift on ASG-owned instances." &lt;strong&gt;Don't.&lt;/strong&gt; If you&lt;br&gt;
suppress &lt;em&gt;all&lt;/em&gt; drift on autoscaled instances, you go blind to a security group&lt;br&gt;
opened to &lt;code&gt;0.0.0.0/0&lt;/code&gt; on your entire web fleet — which is exactly the drift you&lt;br&gt;
most want to catch.&lt;/p&gt;

&lt;p&gt;So I suppress only the &lt;strong&gt;existence&lt;/strong&gt; dimension — a first-sighting instance the&lt;br&gt;
autoscaler created — and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data_prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# first sighting: churn if an ASG owns it, otherwise a real "added"
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_autoscaling_churn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;autoscaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;added&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# an ASG instance that CHANGED attributes is still real drift — reported
&lt;/span&gt;    &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_compute_raw_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data_prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data&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;diff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new autoscaled instance → churn, filed away. That same instance later showing&lt;br&gt;
an opened security group → drift, front and center. And I show the suppressed&lt;br&gt;
instances in their own &lt;em&gt;"Auto Scaling — not drift"&lt;/em&gt; section rather than hiding&lt;br&gt;
them, so the report is honest about what it chose not to alarm on.&lt;/p&gt;

&lt;h2&gt;
  
  
  One source of truth, or the CLI lies again
&lt;/h2&gt;

&lt;p&gt;My tool computes drift in a few places: the dashboard badge, the recorded&lt;br&gt;
history snapshot, the drift report page — and, since last week, a &lt;code&gt;syncvey&lt;/code&gt;&lt;br&gt;
command line. If each of those decided "is this churn?" on its own, they'd&lt;br&gt;
eventually disagree, and a tool that disagrees with itself is worse than no&lt;br&gt;
tool.&lt;/p&gt;

&lt;p&gt;So the ownership check lives in exactly one helper that all four import. Which&lt;br&gt;
surfaced a nice catch: the CLI I shipped in my last post has a&lt;br&gt;
&lt;code&gt;syncvey drift --exit-code&lt;/code&gt; flag meant to fail a CI build on drift. Before this&lt;br&gt;
change, a routine scale-out would have &lt;strong&gt;turned a pipeline red for no reason.&lt;/strong&gt;&lt;br&gt;
The same one-line fix closed that hole automatically, because the CLI reads the&lt;br&gt;
same function as everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't fix
&lt;/h2&gt;

&lt;p&gt;Honesty section, as always:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This suppresses scale-&lt;em&gt;out&lt;/em&gt; churn (new instances). Scale-&lt;em&gt;in&lt;/em&gt; leaves a stale
asset row for a terminated instance — that's a separate "prune what's gone"
problem I haven't solved here.&lt;/li&gt;
&lt;li&gt;It trusts the tag. That's a safe bet for &lt;code&gt;aws:autoscaling:groupName&lt;/code&gt;, but it's
a tag check, not a live ASG-membership lookup — I traded a little precision
for zero new IAM, on purpose.&lt;/li&gt;
&lt;li&gt;It's on by default (&lt;code&gt;DRIFT_SUPPRESS_AUTOSCALING&lt;/code&gt;), because churn-as-drift is
almost never what you want — but you can turn it off if you really want to see
every instance the autoscaler ever launched.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not every "added" resource is drift. If a system is &lt;em&gt;designed&lt;/em&gt; to create and
destroy resources on its own (autoscalers, spot fleets, Karpenter), counting
that as drift just teaches people to mute the alarm.&lt;/li&gt;
&lt;li&gt;Before adding an API call and an IAM permission, check what you already
collect. AWS reserved tags (&lt;code&gt;aws:autoscaling:groupName&lt;/code&gt;, &lt;code&gt;aws:cloudformation:*&lt;/code&gt;,
&lt;code&gt;aws:eks:*&lt;/code&gt;) carry a lot of ownership signal for free.&lt;/li&gt;
&lt;li&gt;Suppress narrowly. "Ignore this resource" is a foot-gun; "ignore this one
dimension of this resource" (its existence, not its config) keeps you from
going blind to the drift that matters.&lt;/li&gt;
&lt;li&gt;If several code paths answer the same question, give them one function to
call. The bonus is every consumer — including a CLI you wrote later — inherits
the fix.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is a real, open-source (MIT) self-hosted tool that tracks how your live AWS&lt;br&gt;
drifts from Terraform — one &lt;code&gt;docker compose up&lt;/code&gt;:&lt;br&gt;
&lt;a href="https://syncvey.com" rel="noopener noreferrer"&gt;syncvey.com&lt;/a&gt;. If you run autoscalers, how does your drift&lt;br&gt;
tooling tell "the autoscaler did its job" apart from "someone changed something"?&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>My infra tool could show drift, grade it, and Slack it — but it still couldn't fail a CI build</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Wed, 22 Jul 2026 12:39:58 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/my-infra-tool-could-show-drift-grade-it-and-slack-it-but-it-still-couldnt-fail-a-ci-build-ne2</link>
      <guid>https://dev.to/hitoshi1964/my-infra-tool-could-show-drift-grade-it-and-slack-it-but-it-still-couldnt-fail-a-ci-build-ne2</guid>
      <description>&lt;p&gt;A while back I wrote about teaching my self-hosted drift detector to stop being&lt;br&gt;
&lt;em&gt;pull&lt;/em&gt; — instead of waiting for someone to open a tab, it now pushes a Monday&lt;br&gt;
briefing into Slack. That fixed the human case.&lt;/p&gt;

&lt;p&gt;It didn't fix the case where there is no human. A pull request opens, CI runs,&lt;br&gt;
something merges — and the one moment where drift should be able to &lt;em&gt;stop a&lt;br&gt;
change cold&lt;/em&gt; is a pipeline that had no way to ask my tool anything. A dashboard&lt;br&gt;
assumes eyes. A Slack digest assumes someone reading Slack. CI reads neither. It&lt;br&gt;
reads &lt;strong&gt;exit codes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So this time the feature is small and unglamorous: a command line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python manage.py syncvey scan &lt;span class="nt"&gt;--system&lt;/span&gt; e-commerce   &lt;span class="c"&gt;# live scan + record a snapshot&lt;/span&gt;
python manage.py syncvey drift &lt;span class="nt"&gt;--env&lt;/span&gt; prod           &lt;span class="c"&gt;# what drifted, right now&lt;/span&gt;
python manage.py syncvey drift &lt;span class="nt"&gt;--exit-code&lt;/span&gt;          &lt;span class="c"&gt;# exit 1 if anything drifted&lt;/span&gt;
python manage.py syncvey drift &lt;span class="nt"&gt;--format&lt;/span&gt; json        &lt;span class="c"&gt;# same, for a pipeline to parse&lt;/span&gt;
python manage.py syncvey status                     &lt;span class="c"&gt;# systems / envs / counts / last scan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole point is that fourth-from-top line. &lt;code&gt;--exit-code&lt;/code&gt; turns drift into a&lt;br&gt;
build gate:&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="pi"&gt;-&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;Fail on infrastructure drift&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python manage.py syncvey drift --exit-code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a console change that never went through Terraform can turn a pipeline red.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But &lt;code&gt;terraform plan&lt;/code&gt; already runs in CI"
&lt;/h2&gt;

&lt;p&gt;This is the first objection, and it's fair — plenty of teams run &lt;code&gt;terraform plan&lt;/code&gt;&lt;br&gt;
in CI and call the non-empty diff a drift gate. The reason I still needed my own&lt;br&gt;
command is the same reason this whole project exists: &lt;strong&gt;&lt;code&gt;plan&lt;/code&gt; compares your&lt;br&gt;
state to your config. It never compares either one to reality.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If someone opens a security group in the console, there's no Terraform resource&lt;br&gt;
for it, so &lt;code&gt;plan&lt;/code&gt; has nothing to diff. Exit 0. Pipeline green. The most dangerous&lt;br&gt;
kind of drift — the click nobody wrote down — is exactly the kind &lt;code&gt;plan&lt;/code&gt; is&lt;br&gt;
structurally blind to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;syncvey drift&lt;/code&gt; diffs the &lt;strong&gt;live AWS state&lt;/strong&gt; (scanned with boto3) against the last&lt;br&gt;
snapshot. So &lt;code&gt;--exit-code&lt;/code&gt; is the gate &lt;code&gt;plan&lt;/code&gt;'s exit code can't be. Same form&lt;br&gt;
factor, different question.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 1: the plugin seam I already had didn't fit — and Django had a better one
&lt;/h2&gt;

&lt;p&gt;I keep the advanced pieces of this tool as &lt;strong&gt;detachable apps&lt;/strong&gt;: risk scoring,&lt;br&gt;
CloudTrail attribution, blast-radius — each lives in its own Django app, and the&lt;br&gt;
core is not allowed to import it. Install the app and the feature appears; remove&lt;br&gt;
it and the core neither knows nor cares.&lt;/p&gt;

&lt;p&gt;For web features, that seam is hand-built: a plugin advertises a nav entry and a&lt;br&gt;
URL, and a feature flag guards the route.&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;BlastRadiusConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppConfig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;syncvey_plugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;nav_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&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;label&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;Blast Radius&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;url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;blast_radius:home&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first instinct was to build the CLI the same way — invent some registry the&lt;br&gt;
core reads to decide which subcommands exist. I got about two minutes in before&lt;br&gt;
noticing I was reinventing something Django already does. &lt;strong&gt;Every app in&lt;br&gt;
&lt;code&gt;INSTALLED_APPS&lt;/code&gt; automatically contributes its &lt;code&gt;management/commands/*.py&lt;/code&gt;.&lt;/strong&gt; That&lt;br&gt;
&lt;em&gt;is&lt;/em&gt; the plugin seam. I didn't have to wire anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;syncvey_cli/
  apps.py                         # syncvey_plugin = True, feature 'cli'
  service.py                      # the actual scan/drift logic
  management/commands/syncvey.py  # the command — discovered for free
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;syncvey_cli&lt;/code&gt; to &lt;code&gt;INSTALLED_APPS&lt;/code&gt; → &lt;code&gt;manage.py&lt;/code&gt; grows a &lt;code&gt;syncvey&lt;/code&gt; command.&lt;br&gt;
Remove it → the command is gone, and nothing in the core breaks. The&lt;br&gt;
"detachable" property I'd carefully engineered for web features came &lt;em&gt;for free&lt;/em&gt;&lt;br&gt;
for a CLI, because command discovery is already how Django works. The lesson I&lt;br&gt;
keep relearning: before building a seam, check whether the framework already&lt;br&gt;
handed you one.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;AppConfig&lt;/code&gt; is almost empty — it just opts into the feature registry so the&lt;br&gt;
rest of the app can answer "is the CLI installed?":&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;SyncveyCliConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppConfig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;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;syncvey_cli&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="n"&gt;syncvey_plugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;feature_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;cli&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;nav_items&lt;/code&gt;, no URL, no route guard. A CLI isn't a web surface, so it&lt;br&gt;
contributes none of that — and the seam doesn't ask it to.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lesson 2: a CLI that computes drift &lt;em&gt;differently&lt;/em&gt; from your UI is a liar
&lt;/h2&gt;

&lt;p&gt;Here was the tempting shortcut. The command needs to know what drifted. The core&lt;br&gt;
already computes that for the dashboard, but its function is &lt;code&gt;_compute_raw_diff&lt;/code&gt;&lt;br&gt;
— underscore-prefixed, "private." Re-implementing a quick diff in the CLI would&lt;br&gt;
have been fewer imports and felt cleaner.&lt;/p&gt;

&lt;p&gt;It would also have been a bug — the worst kind, where two features quietly&lt;br&gt;
disagree about the same fact.&lt;/p&gt;

&lt;p&gt;The core diff has a hard-won rule in it. A live scan emits a curated ~11 fields&lt;br&gt;
per resource; a tfstate import carries 50+. If you diff by the &lt;strong&gt;union&lt;/strong&gt; of keys,&lt;br&gt;
every tfstate-only field reads as "deleted" and the whole thing is a wall of&lt;br&gt;
false drift. So the core compares the &lt;strong&gt;intersection&lt;/strong&gt; of keys and detects real&lt;br&gt;
attribute changes on shared ground:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;_DIFF_EXCLUDE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the CLI rolled its own diff, &lt;code&gt;syncvey drift&lt;/code&gt; and the dashboard would report&lt;br&gt;
different drift for the same environment. Which one do you trust? Neither, once&lt;br&gt;
you've seen them disagree. So the CLI imports the exact same function the UI&lt;br&gt;
uses:&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;asset_manager.views&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;_compute_raw_diff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, I imported a "private" helper across an app boundary. The rule I actually&lt;br&gt;
care about — &lt;em&gt;a plugin may depend on the core; the core never depends on a&lt;br&gt;
plugin&lt;/em&gt; — is intact. And there is now exactly &lt;strong&gt;one&lt;/strong&gt; definition of "what counts&lt;br&gt;
as drift" in the codebase. The CLI can't drift from the dashboard because they&lt;br&gt;
run the same code. &lt;code&gt;syncvey scan&lt;/code&gt; reuses &lt;code&gt;run_scan&lt;/code&gt; and the snapshot recorder for&lt;br&gt;
the same reason: the command mirrors the web scan flow instead of forking it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit codes are the actual product
&lt;/h2&gt;

&lt;p&gt;For a human, the pretty text output is the feature. For CI, the &lt;em&gt;exit code&lt;/em&gt; is&lt;br&gt;
the feature and the text is incidental. So the codes are a deliberate contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; — ok / no drift&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; — drift found (only with &lt;code&gt;drift --exit-code&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; — a scan job failed, or your &lt;code&gt;--system&lt;/code&gt; / &lt;code&gt;--env&lt;/code&gt; selector matched nothing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than it looks. A drift gate that exits &lt;code&gt;0&lt;/code&gt; because it&lt;br&gt;
silently matched zero environments is worse than no gate — it's a green light&lt;br&gt;
that checked nothing. &lt;code&gt;2&lt;/code&gt; keeps a typo'd &lt;code&gt;--system&lt;/code&gt; from passing as "clean."&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A monitoring tool that only speaks through a UI and a chat message still can't
participate in the one place changes are gated: the pipeline. CI doesn't read
dashboards; it reads exit codes. Give it one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform plan&lt;/code&gt; in CI is a state-vs-config gate, not a drift gate. It can't
fail on a resource that was never in Terraform. If you care about console-made
drift, you need a check that reads live cloud state.&lt;/li&gt;
&lt;li&gt;Before you build a plugin seam, check whether the framework already is one.
Django discovers management commands from &lt;code&gt;INSTALLED_APPS&lt;/code&gt; — a CLI plugin is
detachable for free, no registry required.&lt;/li&gt;
&lt;li&gt;If you add a second interface (CLI, API, export) over an existing feature,
&lt;strong&gt;reuse the exact function&lt;/strong&gt;, don't re-implement it. Two code paths that
compute "the same" answer will eventually disagree, and then both are
untrustworthy.&lt;/li&gt;
&lt;li&gt;Make "matched nothing" a non-zero exit. A gate that passes because it checked
zero things is the most dangerous kind of green.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This CLI ships with a self-hosted tool that tracks how your live AWS drifts from&lt;br&gt;
Terraform — open source (MIT), one &lt;code&gt;docker compose up&lt;/code&gt;:&lt;br&gt;
&lt;a href="https://syncvey.com" rel="noopener noreferrer"&gt;syncvey.com&lt;/a&gt;. If you gate infra in CI today, what are you&lt;br&gt;
gating on — &lt;code&gt;terraform plan&lt;/code&gt;, a cloud-state scan, or nothing yet?&lt;/p&gt;

</description>
      <category>django</category>
      <category>devops</category>
      <category>aws</category>
      <category>python</category>
    </item>
    <item>
      <title>A '12 libraries to make your Python production-ready' list crossed my feed — I use 2 of them, because a framework already made the other 10 decisions</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Tue, 07 Jul 2026 11:54:38 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/a-12-libraries-to-make-your-python-production-ready-list-crossed-my-feed-i-use-2-of-them-59f5</link>
      <guid>https://dev.to/hitoshi1964/a-12-libraries-to-make-your-python-production-ready-list-crossed-my-feed-i-use-2-of-them-59f5</guid>
      <description>&lt;p&gt;A good list came across my feed: &lt;em&gt;12 libraries that turn a weekend script into&lt;br&gt;
production software&lt;/em&gt; — click, python-dotenv, SQLAlchemy, marshmallow, tenacity,&lt;br&gt;
tqdm, schedule, celery, pytest, structlog, the Docker SDK, boto3. Every pick is&lt;br&gt;
defensible. It's the kind of list I'd have loved when I was younger.&lt;/p&gt;

&lt;p&gt;I run a production tool — a self-hosted AWS drift detector, live, with real&lt;br&gt;
users. So I checked it against the list, honestly expecting to find gaps.&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;two&lt;/strong&gt; of the twelve.&lt;/p&gt;

&lt;p&gt;Not because I'm hardcore and roll everything by hand. The opposite: because I&lt;br&gt;
picked &lt;strong&gt;Django&lt;/strong&gt;, and Django had already made ten of those decisions before I&lt;br&gt;
wrote a line. That's the part the list doesn't say out loud — &lt;em&gt;choosing a&lt;br&gt;
framework is itself a dependency decision&lt;/em&gt;, and it's the one that subsumes most&lt;br&gt;
of the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ten that vanished when I picked the framework
&lt;/h2&gt;

&lt;p&gt;None of these are "I don't need that." They're "I already have that, and adding&lt;br&gt;
the library would give me &lt;strong&gt;two&lt;/strong&gt; of the same thing":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;click → &lt;code&gt;manage.py&lt;/code&gt; commands.&lt;/strong&gt; My CLI is Django management commands. Arg
parsing, &lt;code&gt;--help&lt;/code&gt;, type coercion, discovery — all free, all consistent with the
rest of the app. A second CLI framework would just be a parallel one to keep in
sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLAlchemy → the Django ORM.&lt;/strong&gt; I'm already on one ORM (with &lt;code&gt;psycopg&lt;/code&gt; +
&lt;code&gt;dj-database-url&lt;/code&gt;, so SQLite-to-Postgres is a URL, not a rewrite). Two ORMs in
one app isn't flexibility, it's a bug factory — two identity maps, two
migration stories, two mental models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;marshmallow → forms + a boundary &lt;code&gt;normalize()&lt;/code&gt;.&lt;/strong&gt; Untrusted input goes
through Django form/model validation; the AWS scan results get normalized into
a fixed shape at the edge. The validation layer exists; it just isn't a
separate library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;schedule / celery → django-apscheduler.&lt;/strong&gt; I already have periodic scans. And
celery specifically is the &lt;em&gt;wrong&lt;/em&gt; direction for me right now: I
&lt;a href="https://dev.to/hitoshi1964/your-django-background-scheduler-is-probably-running-twice-heres-why-and-the-migrate-453c"&gt;wrote a whole post&lt;/a&gt;
about &lt;em&gt;guarding a single scheduler from running twice&lt;/em&gt;. Bolting on Redis + a
distributed task queue is scaling I haven't earned and complexity I'd have to
defend at 2am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;structlog's premise ("graduate from &lt;code&gt;print&lt;/code&gt;") → I already did.&lt;/strong&gt; The whole
codebase is on stdlib &lt;code&gt;logging&lt;/code&gt; with &lt;code&gt;getLogger(__name__)&lt;/code&gt;. The problem that
library solves for a script author — I don't have it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;python-dotenv → compose injects the env.&lt;/strong&gt; &lt;code&gt;docker compose&lt;/code&gt; sets the
variables; I read them with &lt;code&gt;os.getenv&lt;/code&gt; at the settings boundary. dotenv is a
nicety for local non-Docker dev, not a production dependency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tqdm → server jobs log, they don't draw bars.&lt;/strong&gt; My scans run on a schedule or
a web request, not in an attended terminal. A progress bar has no one to
progress &lt;em&gt;to&lt;/em&gt;; a structured log line does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker SDK → I'm the thing inside the container.&lt;/strong&gt; SyncVey &lt;em&gt;ships as&lt;/em&gt; a
container (&lt;code&gt;docker compose up&lt;/code&gt;). It doesn't need to &lt;em&gt;drive&lt;/em&gt; Docker from Python.
Different job entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's ten, gone — not by discipline, by a single upstream choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two that survived the filter
&lt;/h2&gt;

&lt;p&gt;Here's where I keep myself honest, because "I need nothing" is always a lie. Two&lt;br&gt;
of the twelve point at real holes Django didn't fill:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;tenacity — the genuine gap.&lt;/strong&gt; boto3 already retries AWS calls for me&lt;br&gt;
(botocore's built-in backoff). But my two &lt;em&gt;non-AWS&lt;/em&gt; HTTP calls — posting to a&lt;br&gt;
Slack webhook, fetching the EOL calendar — go out through raw &lt;code&gt;urllib&lt;/code&gt;, &lt;strong&gt;single&lt;br&gt;
shot, no retry&lt;/strong&gt;. A blip on either just fails. That's exactly the "transient&lt;br&gt;
network error" case the list names, and the framework doesn't cover it because&lt;br&gt;
it's not a framework concern. This one I should actually add.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;structlog — an upgrade, and it did its job before I installed it.&lt;/strong&gt; I'm on&lt;br&gt;
plain &lt;code&gt;logging&lt;/code&gt;, which is fine, but structured/JSON logs would make scan jobs and&lt;br&gt;
attribution failures queryable instead of greppable. And while auditing against&lt;br&gt;
the list I found a stray &lt;code&gt;print(f"Error processing …")&lt;/code&gt; that had slipped past my&lt;br&gt;
own logging convention in one view. The list earned its keep not by adding a&lt;br&gt;
dependency — by making me &lt;em&gt;look&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson isn't the count
&lt;/h2&gt;

&lt;p&gt;The quote at the bottom of these lists is usually some version of &lt;em&gt;"good&lt;br&gt;
developers write code, great developers assemble systems."&lt;/em&gt; True. But assembling&lt;br&gt;
a system doesn't start with adding twelve pieces. It starts with picking the&lt;br&gt;
&lt;strong&gt;one&lt;/strong&gt; piece that pre-answers ten of the questions — and then knowing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;which decisions it already made, so you don't bolt on a second copy (the
two-ORM trap), and&lt;/li&gt;
&lt;li&gt;which two it left open, so you don't skip them (my un-retried webhook).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The leverage the list is pointing at is real. I'd just locate it one level up:&lt;br&gt;
not in &lt;em&gt;which twelve libraries&lt;/em&gt;, but in &lt;em&gt;which one framework makes ten of them&lt;br&gt;
moot&lt;/em&gt; — and the discipline to audit the seams it leaves behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat
&lt;/h2&gt;

&lt;p&gt;If you're writing a genuine standalone script — a CLI tool, a data-munging job,&lt;br&gt;
no web surface, no persistence — the list is &lt;strong&gt;dead on&lt;/strong&gt; and reaching for Django&lt;br&gt;
would be the wrong hammer. click + tqdm + dotenv is exactly right there, and I'd&lt;br&gt;
use all three. The point isn't "framework always wins." It's &lt;em&gt;match the bundle to&lt;br&gt;
the shape of the thing.&lt;/em&gt; A weekend script and a hosted service want different&lt;br&gt;
default stacks, and pretending one list fits both is how you end up with two&lt;br&gt;
ORMs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Picking a framework is a dependency decision — the biggest one. It bundles
answers to CLI, ORM, validation, config, logging, and scheduling before you
choose any of them individually.&lt;/li&gt;
&lt;li&gt;Before adding a library, check whether your framework already gives you that
capability. A second one isn't more power; it's a synchronization problem (two
ORMs, two CLIs, two config layers).&lt;/li&gt;
&lt;li&gt;Then find the seams the framework &lt;em&gt;didn't&lt;/em&gt; cover. For me that was retries on
non-framework HTTP calls (tenacity) and structured logging (structlog) — the
two of twelve that were real.&lt;/li&gt;
&lt;li&gt;celery/Redis is scaling you should have to &lt;em&gt;justify&lt;/em&gt;, not a default. Single-node
with a guarded scheduler is a legitimate production shape.&lt;/li&gt;
&lt;li&gt;Auditing your stack against someone else's list is worth it even when you adopt
almost none of it — it's how I found a &lt;code&gt;print()&lt;/code&gt; I'd left in a view.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The tool I ran this audit against is an open-source, self-hosted AWS drift&lt;br&gt;
detector — Django, MIT, one &lt;code&gt;docker compose up&lt;/code&gt;:&lt;br&gt;
&lt;a href="https://syncvey.com" rel="noopener noreferrer"&gt;syncvey.com&lt;/a&gt;. If you checked your own production service&lt;br&gt;
against a "must-have libraries" list, how many would you actually be missing —&lt;br&gt;
and how many did your framework quietly hand you years ago?&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your connection pool is exhausted. pg_stat_activity tells you which of the three causes it is.</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Mon, 06 Jul 2026 11:36:43 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/your-connection-pool-is-exhausted-pgstatactivity-tells-you-which-of-the-three-causes-it-is-4mgn</link>
      <guid>https://dev.to/hitoshi1964/your-connection-pool-is-exhausted-pgstatactivity-tells-you-which-of-the-three-causes-it-is-4mgn</guid>
      <description>&lt;p&gt;There's a great genre of article about connection pools — size them &lt;code&gt;(cores × 2)&lt;/code&gt;, don't make them bigger when they exhaust, put PgBouncer in front so ten app instances don't each open their own twenty connections. All true. All written from the &lt;em&gt;application's&lt;/em&gt; side of the glass: your SQLAlchemy pool, your &lt;code&gt;pool_timeout&lt;/code&gt;, your worker threads piling into a queue.&lt;/p&gt;

&lt;p&gt;The trouble is that from the app's side, pool exhaustion has exactly one symptom, and it's useless. Every request hangs. Pages spin. Eventually: &lt;code&gt;FATAL: too many connections&lt;/code&gt;. You go look at the database, braced for a fire, and it's &lt;em&gt;fine&lt;/em&gt; — CPU low, queries fast. The database is healthy and your app is dead anyway.&lt;/p&gt;

&lt;p&gt;So everyone concludes the database has nothing to tell you. It has plenty to tell you. You were just standing on the wrong side of the glass. &lt;code&gt;pg_stat_activity&lt;/code&gt; is the connection log, and it separates the three ways a pool drains into three different shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  One query, three fingerprints
&lt;/h2&gt;

&lt;p&gt;The number behind &lt;code&gt;FATAL: too many connections&lt;/code&gt; is &lt;code&gt;max_connections&lt;/code&gt;. So the first thing you want is the count against it — but broken down by what each connection is &lt;em&gt;doing&lt;/em&gt;, because that breakdown is the whole diagnosis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;
                        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;
                        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'idle'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                          &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;
                        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'idle in transaction'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                           &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;idle_in_txn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'max_connections'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                                       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'superuser_reserved_connections'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&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;backend_type = 'client backend'&lt;/code&gt; throws out autovacuum, the walwriter, and the other internal processes — they're real backends, but they don't draw on the pool a client competes for, so counting them would just inflate the number that matters. What's left is &lt;em&gt;your applications' connections&lt;/em&gt;, sorted into three buckets. The buckets are the tell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The leak.&lt;/strong&gt; Code borrows a connection, opens a transaction, and an exception fires before the &lt;code&gt;COMMIT&lt;/code&gt;/&lt;code&gt;ROLLBACK&lt;/code&gt; — or before the &lt;code&gt;with&lt;/code&gt; block that would have returned it. The connection isn't running anything. It's sitting on an open transaction, holding its slot (and any locks it took) forever:&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="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;idle_in_txn&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;max_conn&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt;
&lt;span class="c1"&gt;------+--------+------+-------------+----------+----------&lt;/span&gt;
   &lt;span class="mi"&gt;98&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;      &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;          &lt;span class="mi"&gt;89&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;      &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;        &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;89 connections &lt;code&gt;idle in transaction&lt;/code&gt;. Nobody is doing any work — &lt;code&gt;active&lt;/code&gt; is 2 — and yet you're one connection off the ceiling. That's not load. That's a leak, bleeding one slot per un-returned connection until it hits the wall. &lt;code&gt;idle in transaction&lt;/code&gt; pegged near the limit is the single most diagnostic shape in this whole panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The slow query.&lt;/strong&gt; Same ceiling, completely different fingerprint:&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="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;idle_in_txn&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;max_conn&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt;
&lt;span class="c1"&gt;------+--------+------+-------------+----------+----------&lt;/span&gt;
   &lt;span class="mi"&gt;97&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="mi"&gt;94&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;           &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;      &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;        &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;94 connections genuinely &lt;code&gt;active&lt;/code&gt;. Everybody's running SQL — it's just that queries which should take 20ms are taking 2 seconds (an unindexed scan, a lock wait), so each connection is held 100× longer and the pool drains 100× faster under the same traffic. This is the one case where the database really is the bottleneck, and the count tells you so honestly: nothing is leaked, there's just more concurrent work than there are cores to run it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pools multiplying.&lt;/strong&gt; The one that only ever shows up in production:&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="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;idle_in_txn&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;max_conn&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;reserved&lt;/span&gt;
&lt;span class="c1"&gt;------+--------+------+-------------+----------+----------&lt;/span&gt;
   &lt;span class="mi"&gt;96&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;      &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;   &lt;span class="mi"&gt;91&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;           &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;      &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;        &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;91 connections plain &lt;code&gt;idle&lt;/code&gt;. Not in a transaction — just &lt;em&gt;open&lt;/em&gt;, doing nothing, warm. This is ten app instances each keeping a "reasonable" pool of twenty connections alive, against a server whose limit is 100. &lt;code&gt;20 × 10 = 200&lt;/code&gt; wanted, 100 available, and the connections aren't even busy — they're the resting pool, reserved by the app just in case. You never saw it in staging because staging runs one instance. It appears the moment you scale out. &lt;code&gt;idle&lt;/code&gt; high while &lt;code&gt;active&lt;/code&gt; is near zero is the shape of pools competing for a fixed ceiling.&lt;/p&gt;

&lt;p&gt;Three causes the app-side dashboard renders as one identical hang, told apart by which column is large.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you get locked out before it's "full"
&lt;/h2&gt;

&lt;p&gt;Notice &lt;code&gt;reserved&lt;/code&gt; in that query. &lt;code&gt;superuser_reserved_connections&lt;/code&gt; (default 3) holds back slots so a superuser can still get in to fix things when the pool is jammed. The catch: those slots are subtracted from &lt;em&gt;your&lt;/em&gt; budget, not added to the top. A normal application login starts failing at &lt;code&gt;max_conn − reserved&lt;/code&gt;, i.e. &lt;strong&gt;97&lt;/strong&gt;, not 100. So the panel that says "97 / 100, you've got headroom" is wrong for everyone who isn't a superuser — they were refused three connections ago. Surface &lt;code&gt;reserved&lt;/code&gt; next to the count or you'll misread the last few slots every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming the connection that's stuck
&lt;/h2&gt;

&lt;p&gt;The counts tell you &lt;em&gt;which&lt;/em&gt; failure. To fix a leak you need the specific session — who opened it, from which app, and how long ago they wandered off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EPOCH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt;&lt;span class="p"&gt;))::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;secs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="k"&gt;LAST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an &lt;code&gt;idle in transaction&lt;/code&gt; row, &lt;code&gt;query&lt;/code&gt; is the &lt;em&gt;last statement it ran&lt;/em&gt; before going idle, and &lt;code&gt;secs&lt;/code&gt; is how long ago that statement started — so a big &lt;code&gt;secs&lt;/code&gt; on an idle-in-transaction row is a transaction somebody opened, ran one thing in, and abandoned. (If you want the strictly precise "time in this state," that's &lt;code&gt;now() − state_change&lt;/code&gt;; &lt;code&gt;query_start&lt;/code&gt; runs a hair longer because it includes the statement's own runtime, but for spotting the abandoned transaction it's the same story.) Sort active first so live work is on top, then oldest-first so the connection that's been squatting longest floats up. That row is your leak, with a &lt;code&gt;pid&lt;/code&gt; you can hand to whoever owns that &lt;code&gt;application_name&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  MySQL has the count, not the breakdown
&lt;/h2&gt;

&lt;p&gt;The equivalent on MySQL is &lt;code&gt;Threads_connected&lt;/code&gt; against &lt;code&gt;max_connections&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;SHOW&lt;/span&gt; &lt;span class="n"&gt;STATUS&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'Threads_connected'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;VARIABLES&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'max_connections'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the ceiling and the headroom — the "too many connections" number — but not the by-state split, because &lt;code&gt;SHOW STATUS&lt;/code&gt; doesn't bucket threads into active / idle / idle-in-transaction the way &lt;code&gt;pg_stat_activity&lt;/code&gt; does. (You can dig equivalents out of &lt;code&gt;performance_schema&lt;/code&gt;, but it's not the one clean counter Postgres hands you.) So the MySQL version answers &lt;em&gt;are you near the wall&lt;/em&gt;, but not &lt;em&gt;which of the three walls&lt;/em&gt; — you'd fall back to &lt;code&gt;SHOW PROCESSLIST&lt;/code&gt; and read states by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;None of this fixes anything. It's a read-only look at a live view — it can't return a leaked connection or make a slow query fast. The actual repair is on the app's side of the glass, exactly where the pool articles put it: return every connection with &lt;code&gt;with&lt;/code&gt;/&lt;code&gt;try-finally&lt;/code&gt;, put PgBouncer in front so your instances stop multiplying, size the pool to your cores instead of your fears.&lt;/p&gt;

&lt;p&gt;But here's the thing the app's own dashboard structurally cannot do: it only sees &lt;em&gt;its own pool&lt;/em&gt;. Instance #4 has no idea instances #1–3 and #5–10 exist, let alone that the ten of them together just overran a 100-connection server. The only place the &lt;em&gt;sum&lt;/em&gt; is visible — every instance's connections, every state, side by side against the real ceiling — is &lt;code&gt;pg_stat_activity&lt;/code&gt;, on the database's side of the glass. Which of the three failures you have is a question only the database can answer, and it answers it in one query.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;This is one piece of &lt;strong&gt;&lt;a href="https://cli2ui.com" rel="noopener noreferrer"&gt;cli2ui&lt;/a&gt;&lt;/strong&gt; — a local-only web UI over the &lt;code&gt;psql&lt;/code&gt; commands you keep half-remembering. No AI, no SaaS. It's MIT-licensed on &lt;a href="https://github.com/MR-TABATA/cli2ui" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. When &lt;em&gt;your&lt;/em&gt; app hangs, do you look at the app, or at &lt;code&gt;pg_stat_activity&lt;/code&gt;?&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>Django's makemessages wrote Japanese I never approved — and my UI shipped it with total confidence</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Mon, 06 Jul 2026 11:34:12 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/djangos-makemessages-wrote-japanese-i-never-approved-and-my-ui-shipped-it-with-total-confidence-dle</link>
      <guid>https://dev.to/hitoshi1964/djangos-makemessages-wrote-japanese-i-never-approved-and-my-ui-shipped-it-with-total-confidence-dle</guid>
      <description>&lt;p&gt;I added a drift-history model to my Django app, gave its fields the usual&lt;br&gt;
&lt;code&gt;verbose_name&lt;/code&gt;s, and ran the one command you always run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;django-admin makemessages &lt;span class="nt"&gt;-l&lt;/span&gt; ja
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It found the new strings, dropped them into the &lt;code&gt;.po&lt;/code&gt; file, I recompiled, and&lt;br&gt;
moved on. A while later I opened the app in Japanese and a column header read&lt;br&gt;
&lt;strong&gt;取得日時&lt;/strong&gt; — "fetch time." I never wrote "fetch time." The field is&lt;br&gt;
&lt;code&gt;Detected At&lt;/code&gt;. Where did a translation I never typed come from?&lt;/p&gt;

&lt;p&gt;&lt;code&gt;makemessages&lt;/code&gt; wrote it. And it was confidently, specifically wrong.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (2026-07-09).&lt;/strong&gt; A commenter pushed back on this and was right, so I&lt;br&gt;
went and inspected the actual &lt;code&gt;.mo&lt;/code&gt; compiled at that commit. The fuzzy entries&lt;br&gt;
never made it in — &lt;code&gt;compilemessages&lt;/code&gt; excluded them exactly as designed. What my&lt;br&gt;
Japanese users really saw was the English msgid, &lt;code&gt;Detected At&lt;/code&gt;, sitting in an&lt;br&gt;
otherwise-Japanese page; the &lt;code&gt;取得日時&lt;/code&gt; was only ever the &lt;em&gt;suggestion&lt;/em&gt; in the&lt;br&gt;
&lt;code&gt;.po&lt;/code&gt;, caught before anything approved it. So the failure below is the&lt;br&gt;
&lt;strong&gt;msgid-fallback&lt;/strong&gt; one. The "confidently wrong translation ships" failure is&lt;br&gt;
real, but it needs someone to strip the flag first — it isn't what bit me. I've&lt;br&gt;
left the post as written and corrected it here rather than quietly rewriting the&lt;br&gt;
opening. I've also since built the CI gate the last takeaway admits I lacked.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  What makemessages actually did
&lt;/h2&gt;

&lt;p&gt;When you add a new &lt;code&gt;msgid&lt;/code&gt; that looks similar to one you've already translated,&lt;br&gt;
&lt;code&gt;makemessages&lt;/code&gt; doesn't leave it blank. It &lt;strong&gt;guesses&lt;/strong&gt; — it copies the nearest&lt;br&gt;
existing translation over and flags it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#, fuzzy
#| msgid "Fetched At"
msgid "Detected At"
msgstr "取得日時"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that carefully. My new string is &lt;code&gt;"Detected At"&lt;/code&gt;. gettext looked at my old,&lt;br&gt;
already-translated &lt;code&gt;"Fetched At"&lt;/code&gt; → &lt;strong&gt;取得日時&lt;/strong&gt;, decided the two English strings&lt;br&gt;
were close enough, and pre-filled my new label with the old one's Japanese. The&lt;br&gt;
&lt;code&gt;#| msgid "Fetched At"&lt;/code&gt; line is it telling me &lt;em&gt;"I copied this from Fetched At."&lt;/em&gt;&lt;br&gt;
The &lt;code&gt;#, fuzzy&lt;/code&gt; is it telling me &lt;em&gt;"…but I'm not sure."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It did this across the whole batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Detected At"      取得日時         → should be 検出日時   (bled from "Fetched At")
"Drift Snapshot"   スナップショット   → should be ドリフトスナップショット
"Drift Snapshots"  EOLスナップショット一覧 → should be ドリフトスナップショット一覧  (bled from "EOL Snapshots")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one is &lt;em&gt;plausible&lt;/em&gt;. 取得 (fetch) and 検出 (detect) are one concept apart.&lt;br&gt;
"Snapshot" really is スナップショット — it just dropped the "Drift" qualifier.&lt;br&gt;
"Drift Snapshots" got the translation for "&lt;strong&gt;EOL&lt;/strong&gt; Snapshots" because both end in&lt;br&gt;
"Snapshots." The heuristic matches on the English source text, and UI labels are&lt;br&gt;
short and repetitive, so it matches things that share words but mean different&lt;br&gt;
things.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this is worse than an empty translation
&lt;/h2&gt;

&lt;p&gt;An untranslated string is honest — it falls back to the English source, you see&lt;br&gt;
a stray English word in your Japanese page, you go fix it. A &lt;strong&gt;fuzzy&lt;/strong&gt; string is&lt;br&gt;
a liar wearing a suit. There are two ways it bites, and both are quiet:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Django's &lt;code&gt;compilemessages&lt;/code&gt; drops fuzzy entries by default.&lt;/strong&gt; So the "safe"
outcome is that your brand-new labels silently revert to their English
&lt;code&gt;msgid&lt;/code&gt; in the middle of a Japanese UI — translation present in the &lt;code&gt;.po&lt;/code&gt;,
absent in the running app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The moment anyone accepts the guess&lt;/strong&gt; — strips the &lt;code&gt;#, fuzzy&lt;/code&gt; line, runs
&lt;code&gt;msgfmt --use-fuzzy&lt;/code&gt;, or lets a translation tool auto-approve — the &lt;em&gt;wrong&lt;/em&gt;
Japanese ships and looks completely intentional.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And here's the part that made it invisible to me: my app defaults to English&lt;br&gt;
(&lt;code&gt;LANGUAGE_CODE = 'en'&lt;/code&gt;) and only switches to Japanese from the browser's&lt;br&gt;
&lt;code&gt;Accept-Language&lt;/code&gt; via &lt;code&gt;LocaleMiddleware&lt;/code&gt;. I review in English. &lt;strong&gt;Every reviewer&lt;br&gt;
who reads the diff in English sees a green &lt;code&gt;.po&lt;/code&gt; full of confident-looking&lt;br&gt;
Japanese and no reason to doubt it.&lt;/strong&gt; The only people who see 取得日時 where it&lt;br&gt;
should say 検出日時 are the Japanese users — who assume that's just what the app&lt;br&gt;
calls it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix is a discipline, not a command
&lt;/h2&gt;

&lt;p&gt;There's no clever flag. The fix is to treat &lt;code&gt;#, fuzzy&lt;/code&gt; as &lt;strong&gt;"unreviewed," never&lt;br&gt;
"translated."&lt;/strong&gt; After every &lt;code&gt;makemessages&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"#, fuzzy"&lt;/span&gt; locale/ja/LC_MESSAGES/django.po
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each hit, read the &lt;code&gt;#| msgid "…"&lt;/code&gt; line — it tells you exactly which old&lt;br&gt;
string the guess bled from — then either correct the &lt;code&gt;msgstr&lt;/code&gt; and delete both&lt;br&gt;
comment lines, or clear it and translate from scratch. Only then recompile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;django-admin compilemessages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same sweep surfaced two neighbors of the same bug: a string with an &lt;strong&gt;empty&lt;br&gt;
&lt;code&gt;msgstr&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;"More actions"&lt;/code&gt; → falling back to English) and a &lt;code&gt;msgstr&lt;/code&gt; that had&lt;br&gt;
been &lt;strong&gt;concatenated onto itself&lt;/strong&gt; — the same Japanese sentence pasted twice into&lt;br&gt;
one message. Both are the kind of thing you never notice reviewing in your source&lt;br&gt;
language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;makemessages&lt;/code&gt; doesn't leave new strings blank — it &lt;strong&gt;guesses&lt;/strong&gt; from your
existing translations and marks the guess &lt;code&gt;#, fuzzy&lt;/code&gt;. That's a suggestion, not
a translation.&lt;/li&gt;
&lt;li&gt;The guess matches on &lt;strong&gt;source-text similarity&lt;/strong&gt;, so short, repetitive UI labels
(&lt;code&gt;verbose_name&lt;/code&gt;s especially) are exactly where it's most likely to be wrong —
"Detected At" inherits "Fetched At," "Drift Snapshots" inherits "EOL
Snapshots."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;compilemessages&lt;/code&gt; ignores fuzzy by default, so a fuzzy string either &lt;strong&gt;silently
reverts to English&lt;/strong&gt; or, if anyone accepts it, &lt;strong&gt;ships a confident
mistranslation&lt;/strong&gt;. Both are quiet failures.&lt;/li&gt;
&lt;li&gt;If you review in your source language, you are structurally blind to this. The
bug only renders in the target locale.&lt;/li&gt;
&lt;li&gt;Make &lt;code&gt;grep "#, fuzzy"&lt;/code&gt; (and empty &lt;code&gt;msgstr&lt;/code&gt;) part of the ritual — better, make it
a CI check that fails the build when either exists. &lt;strong&gt;I've since built exactly
that&lt;/strong&gt;: a stdlib-only checker that walks the &lt;code&gt;.po&lt;/code&gt;, flags any surviving fuzzy
entry (reporting the old string the guess bled from) or empty &lt;code&gt;msgstr&lt;/code&gt;, and
exits non-zero. The gate has to be enforced, not remembered. The &lt;code&gt;.mo&lt;/code&gt; is still
hand-compiled and committed, so a stale one can drift from the &lt;code&gt;.po&lt;/code&gt; — that one
I haven't closed.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is one paper cut from a self-hosted AWS drift detector I build in the open —&lt;br&gt;
Django, bilingual UI, MIT, one &lt;code&gt;docker compose up&lt;/code&gt;:&lt;br&gt;
&lt;a href="https://syncvey.com" rel="noopener noreferrer"&gt;syncvey.com&lt;/a&gt;. If you ship a translated app: do you have&lt;br&gt;
anything that stops a &lt;code&gt;#, fuzzy&lt;/code&gt; from reaching production, or do you catch the&lt;br&gt;
wrong ones the way I did — a user squinting at a label that isn't quite right?&lt;/p&gt;

</description>
      <category>django</category>
      <category>i18n</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Order your tables for a safe TRUNCATE with graphlib — circular FKs come free</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Sun, 05 Jul 2026 11:51:25 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/order-your-tables-for-a-safe-truncate-with-graphlib-circular-fks-come-free-56lp</link>
      <guid>https://dev.to/hitoshi1964/order-your-tables-for-a-safe-truncate-with-graphlib-circular-fks-come-free-56lp</guid>
      <description>&lt;p&gt;I wanted a panel that answers one boring, dangerous question: &lt;em&gt;in what order can I empty these tables without a foreign key stopping me?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;orders&lt;/code&gt; references &lt;code&gt;customers&lt;/code&gt;, you can't &lt;code&gt;TRUNCATE customers&lt;/code&gt; first — a row in &lt;code&gt;orders&lt;/code&gt; still points at it. You empty the children before the parents. With three tables you do this in your head. With forty, laced with a many-to-many join table and one &lt;code&gt;employees.manager_id&lt;/code&gt; that points back at &lt;code&gt;employees&lt;/code&gt;, you do it wrong, get &lt;code&gt;ERROR: update or delete on table "…" violates foreign key constraint&lt;/code&gt;, and start bisecting by hand.&lt;/p&gt;

&lt;p&gt;That order has a name — it's a topological sort of the foreign-key graph — and I was about to write the visited/back-edge walk from memory when I remembered Python has shipped one since 3.9. &lt;code&gt;graphlib&lt;/code&gt;. Nobody imports it. It's perfect for exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole ordering is one stdlib call
&lt;/h2&gt;

&lt;p&gt;Model each foreign key as an edge: the &lt;strong&gt;child&lt;/strong&gt; (the table holding the FK) depends on the &lt;strong&gt;parent&lt;/strong&gt; (the table it references). Feed those dependencies to &lt;code&gt;graphlib.TopologicalSorter&lt;/code&gt; and ask for a static order:&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;graphlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TopologicalSorter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CycleError&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sorter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TopologicalSorter&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;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;# add every table, so isolated ones appear
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;edges&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;                     &lt;span class="c1"&gt;# self-reference — handled below
&lt;/span&gt;        &lt;span class="n"&gt;sorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# child depends on parent
&lt;/span&gt;    &lt;span class="n"&gt;load_order&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="n"&gt;sorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;static_order&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# parents first
&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;load_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;load_order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;# safe INSERT / restore order
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;truncate_order&lt;/span&gt;&lt;span class="sh"&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="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load_order&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;  &lt;span class="c1"&gt;# safe TRUNCATE / DELETE order
&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;static_order()&lt;/code&gt; yields dependencies before the things that depend on them, so you get &lt;strong&gt;parents first&lt;/strong&gt; — that's the order you'd &lt;em&gt;load&lt;/em&gt; data in (or restore a dump). The safe order to &lt;em&gt;empty&lt;/em&gt; tables is the exact reverse: children first. One &lt;code&gt;reversed()&lt;/code&gt; and you have both directions from the same sort.&lt;/p&gt;

&lt;p&gt;Two small but load-bearing details:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add every table before adding edges.&lt;/strong&gt; A table with no foreign key in or out never appears in an edge, so if you only add edges it silently drops off your list. &lt;code&gt;sorter.add(t)&lt;/code&gt; with no predecessors registers it as a free-floating node that still comes out in the order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The edge direction is the thing to get right.&lt;/strong&gt; &lt;code&gt;sorter.add(child, parent)&lt;/code&gt; reads as "child depends on parent." Flip it and your TRUNCATE order confidently tells you to empty the parent first — the exact mistake the panel exists to prevent. Worth a comment at the call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cycle detection is the part you didn't write
&lt;/h2&gt;

&lt;p&gt;Here's the payoff. Some schemas have circular foreign keys — &lt;code&gt;a&lt;/code&gt; references &lt;code&gt;b&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; references &lt;code&gt;a&lt;/code&gt; (usually via a nullable column you're meant to fill in a second pass). When that happens &lt;strong&gt;no valid order exists&lt;/strong&gt;, and a hand-rolled topological sort's failure mode is to either loop forever or silently emit a partial, wrong answer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TopologicalSorter&lt;/code&gt; refuses to guess. It raises:&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;load_order&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="n"&gt;sorter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;static_order&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;cycle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;CycleError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# CycleError.args == (message, [n1, n2, …, n1]) — the second item is the
&lt;/span&gt;    &lt;span class="c1"&gt;# node list of the cycle, first element repeated at the end.
&lt;/span&gt;    &lt;span class="n"&gt;load_order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;cycle&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="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&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;if&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;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&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;CycleError&lt;/code&gt; doesn't just tell you a cycle exists — &lt;code&gt;exc.args[1]&lt;/code&gt; is &lt;strong&gt;the actual list of tables caught in it&lt;/strong&gt;, with the first repeated at the end so you can read the loop. So the same call that computes the order also detects the one schema footgun that makes the order impossible, and &lt;em&gt;names the tables involved&lt;/em&gt;, with zero extra code. You get a real diagnostic for free out of an exception you were catching anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The self-reference trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;employees.manager_id → employees&lt;/code&gt; is a foreign key from a table to itself. If you hand that to the sorter as &lt;code&gt;add("employees", "employees")&lt;/code&gt;, you've declared that &lt;code&gt;employees&lt;/code&gt; is its own predecessor, and it trips a &lt;strong&gt;false&lt;/strong&gt; &lt;code&gt;CycleError&lt;/code&gt; — a one-node "cycle" that isn't the kind you care about.&lt;/p&gt;

&lt;p&gt;But you can't just drop self-references either, because they're real — they just constrain a different level. A self-referential FK doesn't stop you from emptying the table in one shot: a single-table &lt;code&gt;TRUNCATE employees&lt;/code&gt; (or &lt;code&gt;DELETE FROM employees&lt;/code&gt; with no &lt;code&gt;WHERE&lt;/code&gt;) removes every row at once and the constraint is satisfied. Where it bites is &lt;em&gt;row by row&lt;/em&gt; — you can't delete one manager while a subordinate row still points at it, so a partial delete has to walk the table in dependency order. So the honest move is to exclude self-references from the table-level &lt;em&gt;ordering&lt;/em&gt; and report them separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;self_refs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They don't constrain table-level order — every row's parent is in the same table — but they're worth surfacing as their own note, not swept into the graph where they'd manufacture a phantom cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the edges: one catalog query
&lt;/h2&gt;

&lt;p&gt;The graph is engine-agnostic once you have the edges; only the extraction is Postgres-specific. Foreign keys live in &lt;code&gt;pg_constraint&lt;/code&gt; where &lt;code&gt;contype = 'f'&lt;/code&gt;, with &lt;code&gt;conrelid&lt;/code&gt; = the child and &lt;code&gt;confrelid&lt;/code&gt; = the parent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt;  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;fns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;fcl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_constraint&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;      &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conrelid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;ns&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;fcl&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;fcl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confrelid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;fns&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;fns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fcl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'f'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pg_catalog'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'information_schema'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;fns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pg_catalog'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'information_schema'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rows in, &lt;code&gt;ForeignKeyEdge(child, parent)&lt;/code&gt; out, straight into the builder above. The graph logic never learns which database it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same shape in MySQL
&lt;/h2&gt;

&lt;p&gt;MySQL keeps the same relationship in &lt;code&gt;information_schema.KEY_COLUMN_USAGE&lt;/code&gt; — a foreign-key row is one where &lt;code&gt;REFERENCED_TABLE_NAME&lt;/code&gt; is not null:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REFERENCED_TABLE_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REFERENCED_TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KEY_COLUMN_USAGE&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REFERENCED_TABLE_NAME&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REFERENCED_TABLE_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kcu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REFERENCED_TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same two columns, same edge, same &lt;code&gt;TopologicalSorter&lt;/code&gt;. The &lt;code&gt;graphlib&lt;/code&gt; half doesn't change one line — swapping the query is the entire port. That's the nice thing about pushing all the database-specific knowledge into "produce a list of (child, parent) pairs" and letting the standard library own the graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;This computes and &lt;em&gt;shows&lt;/em&gt; the order — it doesn't run a &lt;code&gt;TRUNCATE&lt;/code&gt;. That's deliberate: the value is answering "what's the safe order, and do you even have a valid one?" without touching a byte, and a few caveats keep it honest. A &lt;code&gt;DEFERRABLE&lt;/code&gt; constraint can be legally violated mid-transaction, so a cycle that looks fatal here may be resolvable with &lt;code&gt;SET CONSTRAINTS … DEFERRED&lt;/code&gt;. Composite and multi-column FKs are still just one edge between two tables. And the topological order is one &lt;em&gt;valid&lt;/em&gt; order, not the only one — independent subgraphs can be interleaved however you like.&lt;/p&gt;

&lt;p&gt;But the core is solid and it's almost free: the shortest correct path from "a pile of foreign keys" to "a safe delete order, plus a named list of the circular ones" is a &lt;code&gt;for&lt;/code&gt; loop and a &lt;code&gt;try/except&lt;/code&gt; around a standard-library class most people have never imported. You were about to write the graph algorithm. You didn't have to.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;This is one piece of &lt;strong&gt;&lt;a href="https://cli2ui.com" rel="noopener noreferrer"&gt;cli2ui&lt;/a&gt;&lt;/strong&gt; — a local-only web UI over the &lt;code&gt;psql&lt;/code&gt; commands you keep half-remembering. No AI, no SaaS. It's MIT-licensed on &lt;a href="https://github.com/MR-TABATA/cli2ui" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. What's the last stdlib module you reimplemented before realizing it already existed?&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>python</category>
    </item>
    <item>
      <title>My drift detector graded every change — and stayed blind to the secret that hadn't rotated in 200 days</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Sun, 05 Jul 2026 11:48:01 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/my-drift-detector-graded-every-change-and-stayed-blind-to-the-secret-that-hadnt-rotated-in-200-52oi</link>
      <guid>https://dev.to/hitoshi1964/my-drift-detector-graded-every-change-and-stayed-blind-to-the-secret-that-hadnt-rotated-in-200-52oi</guid>
      <description>&lt;p&gt;My drift detector is built on one idea: take two snapshots of your cloud, diff&lt;br&gt;
them, and grade what moved. A security group opened to &lt;code&gt;0.0.0.0/0&lt;/code&gt;? Critical. An&lt;br&gt;
RDS instance that flipped to public? Critical. A tag someone fat-fingered? Low.&lt;br&gt;
Every rule is a function of an &lt;strong&gt;old → new transition&lt;/strong&gt; — a change happened, and&lt;br&gt;
I score how bad the change is.&lt;/p&gt;

&lt;p&gt;Then I went to add "your Secrets Manager secret hasn't rotated in too long" and&lt;br&gt;
the whole model fell over. Not because it's hard to detect. Because the thing I&lt;br&gt;
wanted to flag &lt;strong&gt;produces no diff.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape that breaks a diff
&lt;/h2&gt;

&lt;p&gt;Here's a secret that hasn't rotated in 200 days. I scan it Monday. I scan it&lt;br&gt;
Tuesday. I diff the two snapshots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(no changes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Of course there are no changes — &lt;em&gt;nothing rotated&lt;/em&gt;. That's the entire problem.&lt;br&gt;
Scan it every minute for a week and every diff comes back empty. The dangerous&lt;br&gt;
state of this secret is precisely the state in which &lt;strong&gt;nothing is happening to&lt;br&gt;
it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My whole engine was wired to answer &lt;em&gt;"what changed between two points in time?"&lt;/em&gt;&lt;br&gt;
This risk lives in the opposite question: &lt;em&gt;"what is true about this thing right&lt;br&gt;
now, regardless of whether it just changed?"&lt;/em&gt; A password that rotated 200 days&lt;br&gt;
ago and a password that rotated 201 days ago look identical to a diff — but one&lt;br&gt;
of them crossed my 90-day line and the other didn't, and only absolute state&lt;br&gt;
knows that.&lt;/p&gt;

&lt;p&gt;I'd quietly assumed every risk was a &lt;em&gt;change&lt;/em&gt; risk. It isn't. Some risks are&lt;br&gt;
&lt;strong&gt;standing conditions&lt;/strong&gt;: overdue rotation, public-by-default, encryption never&lt;br&gt;
enabled. The absence of a change &lt;em&gt;is&lt;/em&gt; the finding.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two different questions, two different modules
&lt;/h2&gt;

&lt;p&gt;So I stopped trying to force rotation through the diff path. The change-based&lt;br&gt;
rules stayed exactly as they were — they need a prior snapshot and grade the&lt;br&gt;
transition:&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;# rules.py — grades a field-level diff (old → new)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data_prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_compute_raw_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data_prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data&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;changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;assess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;asset_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;findings&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;has_change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rotation got its own module that grades &lt;strong&gt;current state&lt;/strong&gt;, with no &lt;code&gt;_prev&lt;/code&gt; in&lt;br&gt;
sight:&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;# rotation.py — grades a standing condition (now, no diff)
&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;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt; &lt;span class="ow"&gt;or&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;_resource_type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;aws_secretsmanager_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;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;assess_rotation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;findings&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;Both emit the same &lt;code&gt;{'field', 'severity', 'reason'}&lt;/code&gt; shape, so the two streams&lt;br&gt;
of findings merge into one row and sort into the same severity-ranked list. The&lt;br&gt;
UI never knows one came from a diff and the other from a stopwatch.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the grader actually looks at
&lt;/h2&gt;

&lt;p&gt;Rotation posture isn't one boolean, it's a little ladder, and each rung is a&lt;br&gt;
different severity:&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;assess_rotation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age_days&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw_data&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_truthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;rotation_enabled&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;field&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;rotation_enabled&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;severity&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HIGH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reason&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;Automatic rotation is disabled&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;_wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;last_rotated_date&lt;/span&gt;&lt;span class="sh"&gt;'&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;last&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;field&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;last_rotated_date&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;severity&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MEDIUM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reason&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;Rotation is enabled but the secret has never rotated&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;_wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_age_days&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;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CRITICAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...})&lt;/span&gt;   &lt;span class="c1"&gt;# over 2× the limit
&lt;/span&gt;    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_age_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HIGH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...})&lt;/span&gt;        &lt;span class="c1"&gt;# past the limit
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rotation disabled&lt;/strong&gt; → HIGH. It's not overdue, it's structurally never
coming. Worse than "late."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enabled but never rotated&lt;/strong&gt; → MEDIUM. Someone flipped the switch and walked
away; the Lambda may be misconfigured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overdue past the limit&lt;/strong&gt; → HIGH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over twice the limit&lt;/strong&gt; → CRITICAL. 90 days late is a mistake; 180 days late
is a dead process nobody's watching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a pure function over a dict — no AWS calls, no models, fully testable with a&lt;br&gt;
frozen &lt;code&gt;now&lt;/code&gt;. Same discipline as the change rules: keep the judgement pure, keep&lt;br&gt;
the I/O outside.&lt;/p&gt;
&lt;h2&gt;
  
  
  The scan captures posture, never the secret
&lt;/h2&gt;

&lt;p&gt;The one thing I was paranoid about: a tool that &lt;em&gt;reads your secrets to check&lt;br&gt;
your secrets&lt;/em&gt; is a worse problem than the one it solves. It never touches a&lt;br&gt;
value.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ListSecrets&lt;/code&gt; already returns the rotation metadata — &lt;code&gt;RotationEnabled&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;LastRotatedDate&lt;/code&gt;, &lt;code&gt;NextRotationDate&lt;/code&gt; — so there's no &lt;code&gt;GetSecretValue&lt;/code&gt;, not even&lt;br&gt;
a &lt;code&gt;DescribeSecret&lt;/code&gt;. One list call carries everything the grader needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_paginator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;list_secrets&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → RotationEnabled, LastRotatedDate, NextRotationDate, RotationRules ...
# never GetSecretValue. posture only, value never leaves AWS.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I capture whether it rotates, when it last did, and how often it's supposed to —&lt;br&gt;
and nothing that would be dangerous to store. There's a moto-backed test whose&lt;br&gt;
entire job is to assert the scanned record never contains the secret string.&lt;/p&gt;
&lt;h2&gt;
  
  
  The detail I nearly got wrong: "Who changed this?"
&lt;/h2&gt;

&lt;p&gt;Every risk row has a lazy &lt;strong&gt;"Who changed this?"&lt;/strong&gt; button — click it and the tool&lt;br&gt;
calls CloudTrail to name whoever made the change (I wrote about that&lt;br&gt;
&lt;a href="https://dev.to/hitoshi1964/my-drift-detector-knew-a-security-group-changed-not-that-it-was-dangerous-or-who-opened-it-3phi"&gt;in Day 11&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;For an overdue rotation, that button is a lie. There &lt;em&gt;is&lt;/em&gt; no actor. Nobody did&lt;br&gt;
anything — that's the whole finding. Asking CloudTrail "who caused this secret to&lt;br&gt;
not rotate?" returns nothing, because non-events don't have culprits.&lt;/p&gt;

&lt;p&gt;So the button is gated on the same &lt;code&gt;has_change&lt;/code&gt; flag that the diff path sets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;assess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;asset_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;findings&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;has_change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;   &lt;span class="c1"&gt;# only change-based findings get an actor
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A row that exists &lt;em&gt;only&lt;/em&gt; because of a standing condition renders without the&lt;br&gt;
attribution button. The shape of the risk decides whether "who did it?" is even a&lt;br&gt;
coherent question — and for the absence of an event, it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The severity ladder is a heuristic. A 90-day default and a 2× critical cliff
are opinions, not policy — hence the &lt;code&gt;SECRET_ROTATION_MAX_AGE_DAYS&lt;/code&gt; setting.&lt;/li&gt;
&lt;li&gt;It grades what &lt;code&gt;ListSecrets&lt;/code&gt; reports. A rotation Lambda that "succeeds" while
silently rotating to the same value would still look healthy. Posture, not
proof.&lt;/li&gt;
&lt;li&gt;Secrets Manager only. Rotation-as-a-standing-condition generalizes (IAM access
keys, TLS certs, KMS key age) but I've only wired the one so far.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not every risk is a &lt;em&gt;change&lt;/em&gt; risk. Diff-based detection is structurally blind
to conditions that are dangerous precisely because &lt;strong&gt;nothing is changing&lt;/strong&gt; —
an overdue rotation produces no old→new transition to grade.&lt;/li&gt;
&lt;li&gt;When a new risk doesn't fit your existing pipeline, that's a signal it's a
&lt;em&gt;different question&lt;/em&gt;, not a harder version of the same one. Give it its own
path instead of bending the diff to fit.&lt;/li&gt;
&lt;li&gt;Grade standing conditions on absolute current state (&lt;code&gt;now&lt;/code&gt; vs
&lt;code&gt;last_rotated_date&lt;/code&gt;), not on a snapshot delta.&lt;/li&gt;
&lt;li&gt;Check posture without reading the secret — &lt;code&gt;ListSecrets&lt;/code&gt; carries the rotation
metadata, so you never call &lt;code&gt;GetSecretValue&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Attribution only makes sense for events. Gate "who did this?" on whether a
discrete change actually happened; non-events have no culprit.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This ships in a self-hosted tool that scans your live AWS, grades what drifted&lt;br&gt;
&lt;em&gt;and&lt;/em&gt; what's standing overdue, and never stores a secret value — open source&lt;br&gt;
(MIT), one &lt;code&gt;docker compose up&lt;/code&gt;: &lt;a href="https://syncvey.com" rel="noopener noreferrer"&gt;syncvey.com&lt;/a&gt;. What's the&lt;br&gt;
most dangerous thing in your account right now that would never show up in a&lt;br&gt;
diff because it's been quietly &lt;em&gt;not changing&lt;/em&gt; for months?&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>The Terraform MCP server has 35+ tools and none of them can see the resource I made in the console</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Fri, 03 Jul 2026 11:25:41 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/the-terraform-mcp-server-has-35-tools-and-none-of-them-can-see-the-resource-i-made-in-the-console-4c73</link>
      <guid>https://dev.to/hitoshi1964/the-terraform-mcp-server-has-35-tools-and-none-of-them-can-see-the-resource-i-made-in-the-console-4c73</guid>
      <description>&lt;p&gt;Everything is getting an MCP server, and Terraform is no exception — HashiCorp&lt;br&gt;
ships an official one now. Point your AI at it and you get 35+ tools: search the&lt;br&gt;
provider registry, pull module inputs and examples, look up Sentinel policies,&lt;br&gt;
list and drive HCP Terraform workspaces, runs, and variables. As an&lt;br&gt;
Infrastructure-as-Code copilot it's genuinely great.&lt;/p&gt;

&lt;p&gt;I build a drift detector, so I asked it the one question I always ask any new&lt;br&gt;
Terraform tool: &lt;strong&gt;can you find the security group my coworker opened by hand in&lt;br&gt;
the AWS console last Tuesday?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. And — this is the interesting part — it &lt;em&gt;structurally&lt;/em&gt; can't, for the exact&lt;br&gt;
same boring reason &lt;code&gt;terraform plan&lt;/code&gt; can't. A shiny AI wrapper doesn't patch the&lt;br&gt;
hole; it inherits it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the Terraform MCP server actually sees
&lt;/h2&gt;

&lt;p&gt;Look at what its tools are grouped into: &lt;strong&gt;provider&lt;/strong&gt; docs, &lt;strong&gt;module&lt;/strong&gt; registry&lt;br&gt;
metadata, &lt;strong&gt;policy&lt;/strong&gt; (Sentinel), and &lt;strong&gt;HCP Terraform / Enterprise&lt;/strong&gt; workspace&lt;br&gt;
management — runs, variables, tags, stacks. Notice what's &lt;em&gt;not&lt;/em&gt; in that list:&lt;br&gt;
there is no &lt;code&gt;scan_aws_account&lt;/code&gt;, no &lt;code&gt;list_untracked_resources&lt;/code&gt;, nothing that&lt;br&gt;
reaches into a live cloud and enumerates what's really there.&lt;/p&gt;

&lt;p&gt;When anything in that ecosystem "detects drift," it bottoms out at the same&lt;br&gt;
command it always has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan &lt;span class="nt"&gt;-refresh-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refresh reconciles your &lt;strong&gt;state file&lt;/strong&gt; against the real world. For every resource&lt;br&gt;
&lt;strong&gt;already in state&lt;/strong&gt;, it re-reads the live attributes and shows you what moved.&lt;br&gt;
That's the whole mechanism — and the whole limitation.&lt;/p&gt;
&lt;h2&gt;
  
  
  The blind spot is the data source, not the interface
&lt;/h2&gt;

&lt;p&gt;Here's the part that no amount of AI changes. &lt;code&gt;terraform plan&lt;/code&gt; starts from&lt;br&gt;
&lt;strong&gt;state&lt;/strong&gt; and asks: &lt;em&gt;"do the things I already know about still match reality?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the resources it can tell you about are exactly the resources Terraform put&lt;br&gt;
there. The security group someone created in the console has &lt;strong&gt;no state entry&lt;/strong&gt;.&lt;br&gt;
There's no row to refresh, nothing to compare, so &lt;code&gt;plan&lt;/code&gt; — and by extension the&lt;br&gt;
MCP server orchestrating it — reports a clean, confident &lt;em&gt;"No changes."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The most dangerous drift isn't a managed resource that wandered. It's the&lt;br&gt;
resource that was &lt;strong&gt;never managed at all&lt;/strong&gt;: the emergency S3 bucket, the&lt;br&gt;
click-ops RDS instance, the SG opened to &lt;code&gt;0.0.0.0/0&lt;/code&gt; at 2am during an incident.&lt;br&gt;
State-based tooling is blind to all of it by construction, because you can't&lt;br&gt;
refresh a row that doesn't exist.&lt;/p&gt;
&lt;h2&gt;
  
  
  Start from the cloud, not from the state
&lt;/h2&gt;

&lt;p&gt;My tool asks the opposite question — not &lt;em&gt;"does my state still match?"&lt;/em&gt; but&lt;br&gt;
&lt;em&gt;"what is actually in this account?"&lt;/em&gt; — by listing the account directly with&lt;br&gt;
boto3:&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;scan_ec2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ec2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ec2&lt;/span&gt;&lt;span class="sh"&gt;'&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;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ec2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_paginator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;describe_instances&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;paginate&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;reservation&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Reservations&lt;/span&gt;&lt;span class="sh"&gt;'&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;inst&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reservation&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Instances&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# everything AWS has, not what tf knows
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's one of these per service — EC2, S3, security groups, RDS, and so on —&lt;br&gt;
each just enumerating what the API says is live. Then it diffs that against the&lt;br&gt;
imported tfstate. A resource that shows up in the scan with &lt;strong&gt;no matching state&lt;br&gt;
entry&lt;/strong&gt; is the thing &lt;code&gt;terraform plan&lt;/code&gt; will never mention: real, running, and&lt;br&gt;
unmanaged.&lt;/p&gt;

&lt;p&gt;The difference is entirely in where you begin. State-first tools can only ever&lt;br&gt;
describe the map they were handed. Cloud-first tools describe the territory.&lt;/p&gt;

&lt;h2&gt;
  
  
  To be fair to the MCP server
&lt;/h2&gt;

&lt;p&gt;None of this is a knock on the Terraform MCP server — it's aimed at a different&lt;br&gt;
job, and it's good at it. Authoring config against current provider docs,&lt;br&gt;
discovering modules, wiring up HCP workspaces and runs, checking policy: those&lt;br&gt;
are real wins, and having an AI do them against &lt;em&gt;live registry data&lt;/em&gt; instead of&lt;br&gt;
its stale training memory is exactly right.&lt;/p&gt;

&lt;p&gt;It just isn't a cloud-discovery tool, and "add an MCP server" doesn't turn a&lt;br&gt;
state-based engine into one. If your worry is &lt;em&gt;config authoring&lt;/em&gt;, reach for it.&lt;br&gt;
If your worry is &lt;em&gt;what did someone spin up behind Terraform's back&lt;/em&gt;, you need&lt;br&gt;
something that queries the cloud, not the state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The official Terraform MCP server is a registry + HCP-orchestration copilot. Its
35+ tools do not include live cloud discovery.&lt;/li&gt;
&lt;li&gt;Any drift it surfaces still runs through &lt;code&gt;terraform plan -refresh-only&lt;/code&gt;, which
only reconciles resources &lt;strong&gt;already in state&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Console-made ("click-ops") resources have no state row, so state-based tooling —
CLI or MCP — is blind to them by construction. This is a data-source limit, not
a UX one.&lt;/li&gt;
&lt;li&gt;To catch unmanaged resources you have to invert the question: list the cloud
directly and diff &lt;em&gt;that&lt;/em&gt; against state, not the other way around.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is the core of a self-hosted tool that scans your live AWS and flags what&lt;br&gt;
Terraform never planted — open source (MIT), one &lt;code&gt;docker compose up&lt;/code&gt;:&lt;br&gt;
&lt;a href="https://syncvey.com" rel="noopener noreferrer"&gt;syncvey.com&lt;/a&gt;. When a resource appears in your account that&lt;br&gt;
nobody wrote HCL for, what catches it today — a scheduled scanner, a CSPM tool,&lt;br&gt;
or an angry bill at the end of the month?&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>mcp</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
