<?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>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>
    <item>
      <title>Your database GUI is a text editor. Where's the console?</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Fri, 03 Jul 2026 11:24:31 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/your-database-gui-is-a-text-editor-wheres-the-console-50p7</link>
      <guid>https://dev.to/hitoshi1964/your-database-gui-is-a-text-editor-wheres-the-console-50p7</guid>
      <description>&lt;p&gt;I spent an evening going through a big directory of database GUIs — the Macs-and-menu-bars kind, forty-something of them. DBeaver, TablePlus, Beekeeper, Postico, DataGrip, a wall of Redis and Mongo clients, a couple of new AI-first ones. Good tools. Genuinely nice to use.&lt;/p&gt;

&lt;p&gt;And somewhere around tool number thirty it clicked that almost every single one is the &lt;strong&gt;same shape&lt;/strong&gt;: a connection sidebar, a SQL editor, and a data grid you can sort and filter and edit cells in. That's the archetype. It's a &lt;em&gt;text editor for your database&lt;/em&gt;, with a spreadsheet stapled to the results.&lt;/p&gt;

&lt;p&gt;Which is great — right up until the question you have isn't "let me write a query."&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions an editor is bad at
&lt;/h2&gt;

&lt;p&gt;Here's the thing I keep hitting, and I don't think I'm alone. The moments I actually open a database tool in anger are almost never "I want to author a SELECT." They're operational:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Something is hung. Who is blocking whom?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The app is throwing connection errors. Am I out of connections, and how close was I?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A table feels bloated. Is autovacuum keeping up, or has it not run in three weeks?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This index — is anything even using it, or am I paying to maintain dead weight?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those has a real answer sitting in a system view. &lt;code&gt;pg_locks&lt;/code&gt; joined to &lt;code&gt;pg_stat_activity&lt;/code&gt;. &lt;code&gt;count(*)&lt;/code&gt; from &lt;code&gt;pg_stat_activity&lt;/code&gt; against &lt;code&gt;max_connections&lt;/code&gt;. &lt;code&gt;n_dead_tup&lt;/code&gt; and &lt;code&gt;last_autovacuum&lt;/code&gt; in &lt;code&gt;pg_stat_user_tables&lt;/code&gt;. &lt;code&gt;idx_scan = 0&lt;/code&gt; in &lt;code&gt;pg_stat_user_indexes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And here's how a query-editor tool answers them: it hands you the editor. So you paste in the &lt;code&gt;pg_stat_activity&lt;/code&gt; join you half-remember, get a column name wrong, look it up, run it, squint at the raw rows, and mentally reconstruct the blocking graph yourself. The tool didn't answer the question. It gave you a place to type the question, again, for the hundredth time.&lt;/p&gt;

&lt;p&gt;That's the hole. The category optimized hard for &lt;em&gt;authoring SQL&lt;/em&gt; and left &lt;em&gt;reading the state of the database&lt;/em&gt; as an exercise for the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editor questions vs. console questions
&lt;/h2&gt;

&lt;p&gt;It's worth being precise about the split, because it's not "some tools are better." It's two different jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Editor question&lt;/th&gt;
&lt;th&gt;Console question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What rows match this filter?&lt;/td&gt;
&lt;td&gt;Who is blocking whom right now?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Let me edit this cell.&lt;/td&gt;
&lt;td&gt;How many connections until I hit the ceiling?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design this schema.&lt;/td&gt;
&lt;td&gt;Has autovacuum touched this table lately?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Format and save this query.&lt;/td&gt;
&lt;td&gt;Which indexes are dead weight?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A SQL editor is the right tool for the left column. It is a genuinely bad fit for the right column — not because it &lt;em&gt;can't&lt;/em&gt; run the query, but because "run the query and read raw rows" is the entire user experience it offers, and the right-column questions want a computed answer, not a result set.&lt;/p&gt;

&lt;p&gt;The tell is that the operational answer is almost always a &lt;strong&gt;join across two or three catalog views plus a derived number&lt;/strong&gt; — the blocking graph, the headroom percentage, the dead-tuple ratio. That's not something you want to retype. That's something you want a tool to have already turned into a line that says &lt;em&gt;"session 4823 is blocking two others"&lt;/em&gt; or &lt;em&gt;"you're at 71% of max_connections."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one instinct that is spreading
&lt;/h2&gt;

&lt;p&gt;There was one exception in the pile worth calling out, and it's a hopeful one. A couple of the newer clients lead with &lt;strong&gt;read-only by default&lt;/strong&gt; — you connect, and the tool won't let you mutate anything unless you opt in. One AI-first Postgres client puts that right in its headline.&lt;/p&gt;

&lt;p&gt;I found that genuinely encouraging, because it's the same instinct from a different direction: the tool taking responsibility for the &lt;em&gt;state&lt;/em&gt; of the database instead of treating it as a dumb text target. Read-only-by-default says "I assume you're here to look, not to break things." That's a console instinct leaking into the editor category, and I'll take it.&lt;/p&gt;

&lt;p&gt;(It's also the exact reason my own runner defaults to &lt;code&gt;SET TRANSACTION READ ONLY&lt;/code&gt; at the server, not by parsing your SQL — but that's &lt;a href="https://cli2ui.com/" rel="noopener noreferrer"&gt;a whole other post&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  So build the console
&lt;/h2&gt;

&lt;p&gt;None of this is a knock on the editors. If your job is writing and iterating on SQL all day, a great editor is the correct tool and you should use the nicest one you can find.&lt;/p&gt;

&lt;p&gt;But there's a second job — &lt;em&gt;reading the live state of a database and catching the thing about to fall over&lt;/em&gt; — and the market answers it, overwhelmingly, by handing you an editor and wishing you luck. The interesting product isn't a prettier editor. It's the tool that has already run the &lt;code&gt;pg_stat&lt;/code&gt; join for you and put the answer on the screen: the blocking graph drawn as a graph, the connection headroom as a number with a ceiling, the unused indexes listed honestly, autovacuum's last pass with a timestamp you can actually read.&lt;/p&gt;

&lt;p&gt;That's the shape of an operations console, and it's a strangely empty shelf. Which, after forty tools, is the part I keep thinking about.&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, built to answer the console questions above without making you retype the &lt;code&gt;pg_stat&lt;/code&gt; join. No AI, no SaaS. MIT-licensed on &lt;a href="https://github.com/MR-TABATA/cli2ui" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. What's the operational question &lt;em&gt;you&lt;/em&gt; keep pasting into a query editor?&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>My infra tool knew what drifted, how bad, and who did it — then waited for someone to open a tab</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Wed, 01 Jul 2026 10:51:12 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/my-infra-tool-knew-what-drifted-how-bad-and-who-did-it-then-waited-for-someone-to-open-a-tab-4kme</link>
      <guid>https://dev.to/hitoshi1964/my-infra-tool-knew-what-drifted-how-bad-and-who-did-it-then-waited-for-someone-to-open-a-tab-4kme</guid>
      <description>&lt;p&gt;Over the last few weeks I taught my self-hosted drift detector some genuinely&lt;br&gt;
useful tricks: it keeps a &lt;strong&gt;history&lt;/strong&gt; so you can see the trend, it &lt;strong&gt;grades&lt;/strong&gt;&lt;br&gt;
each drift by security impact, and it can tell you &lt;strong&gt;who&lt;/strong&gt; made the change via&lt;br&gt;
CloudTrail. On paper, that's exactly what you want when infrastructure quietly&lt;br&gt;
diverges from Terraform.&lt;/p&gt;

&lt;p&gt;There was one problem, and it took me embarrassingly long to name it: &lt;strong&gt;all of&lt;br&gt;
it was pull.&lt;/strong&gt; You had to remember the tool existed, open it, and go look. On a&lt;br&gt;
random Tuesday afternoon, nobody does. A signal nobody pulls is a signal that&lt;br&gt;
doesn't exist.&lt;/p&gt;

&lt;p&gt;So I made it push. A Monday-morning briefing that lands in Slack on its own.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the briefing says
&lt;/h2&gt;

&lt;p&gt;The whole point is synthesis — one message that answers &lt;em&gt;what changed, how bad,&lt;br&gt;
which way it's trending, and who to talk to&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Drift briefing — Production
3 drifted  ·  ▲ 2 since last week
🔴 1 critical  🟠 1 high  🟡 1 medium  ⚪ 0 low
─────────────
CRITICAL  web-sg (SG / prod)
Opened to the entire internet (0.0.0.0/0)
changed by tanaka via AuthorizeSecurityGroupIngress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of that is new data. It's the drift history, the severity rules, and the&lt;br&gt;
CloudTrail lookup I already had — assembled and delivered instead of sitting in&lt;br&gt;
a tab. The building was easy. Realizing it needed to come &lt;em&gt;to&lt;/em&gt; people was the&lt;br&gt;
part I'd missed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule one: shut up on a quiet week
&lt;/h2&gt;

&lt;p&gt;The fastest way to get a notification muted is to send it when nothing happened.&lt;br&gt;
So the briefing only fires if there's something to say:&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;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attribute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;has_data&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="bp"&gt;False&lt;/span&gt;        &lt;span class="c1"&gt;# a clean week shouldn't ping anyone
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_post_to_slack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;_format_slack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No drift, no message. The absence of a Monday briefing &lt;em&gt;is&lt;/em&gt; the good news.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting part: a plugin that schedules its own job
&lt;/h2&gt;

&lt;p&gt;Here's the architecture constraint I'd set myself earlier in this project: the&lt;br&gt;
risk/attribution/digest stuff lives in an &lt;strong&gt;optional, detachable app&lt;/strong&gt;, and the&lt;br&gt;
core is not allowed to import it. Remove the app from &lt;code&gt;INSTALLED_APPS&lt;/code&gt; and the&lt;br&gt;
core should neither know nor care.&lt;/p&gt;

&lt;p&gt;That's easy for a web view (route it, guard it with a flag). It's harder for a&lt;br&gt;
&lt;strong&gt;background job&lt;/strong&gt;. The scheduler lives in the core. How does the core register&lt;br&gt;
a cron job that belongs to a plugin it's forbidden to import?&lt;/p&gt;

&lt;p&gt;The answer is the same trick Django itself uses for a lot of things: &lt;strong&gt;discovery,&lt;br&gt;
not import.&lt;/strong&gt; The core asks every installed app "do you have any jobs for me?"&lt;br&gt;
without knowing which apps those are:&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;# core: collect job specs from any plugin that offers them
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;plugin_scheduled_jobs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="o"&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;cfg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_plugin_app_configs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;          &lt;span class="c1"&gt;# apps with syncvey_plugin = True
&lt;/span&gt;        &lt;span class="n"&gt;getter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;scheduled_jobs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;callable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getter&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;jobs&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;getter&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="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# a bad plugin must not block startup
&lt;/span&gt;            &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core scheduler just iterates whatever comes back:&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;.plugins&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;plugin_scheduled_jobs&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;plugin_scheduled_jobs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;_scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;func&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&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="n"&gt;spec&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;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;jobstore&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_instances&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replace_existing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the plugin advertises its job from its &lt;code&gt;AppConfig&lt;/code&gt; — importing apscheduler&lt;br&gt;
and its own code &lt;em&gt;lazily&lt;/em&gt;, so the core never pulls any of it in:&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;DriftRiskConfig&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;scheduled_jobs&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.conf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&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;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DRIFT_DIGEST_ENABLED&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&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="c1"&gt;# opt-in; silent by default
&lt;/span&gt;        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apscheduler.triggers.cron&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CronTrigger&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.digest&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;run_digest_job&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;id&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;drift_digest_weekly&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;name&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;Weekly drift briefing&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;func&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run_digest_job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CronTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day_of_week&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mon&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the core scheduler runs a job it has never heard of, and deleting the plugin&lt;br&gt;
deletes the job with it. No &lt;code&gt;if plugin_installed:&lt;/code&gt; branches in the core, no&lt;br&gt;
import, no coupling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha that bit me: jobs are stored by reference
&lt;/h2&gt;

&lt;p&gt;I use &lt;code&gt;django-apscheduler&lt;/code&gt;, which persists jobs in the database so they survive&lt;br&gt;
a restart. That persistence is the trap. apscheduler doesn't pickle your&lt;br&gt;
function — it stores it &lt;strong&gt;by import path&lt;/strong&gt; (&lt;code&gt;syncvey_drift_risk.digest:run_digest_job&lt;/code&gt;)&lt;br&gt;
and re-imports it when the job fires.&lt;/p&gt;

&lt;p&gt;My first version made &lt;code&gt;run_digest_job&lt;/code&gt; a &lt;strong&gt;closure&lt;/strong&gt; inside &lt;code&gt;scheduled_jobs()&lt;/code&gt;,&lt;br&gt;
because it needed a bit of context. apscheduler couldn't serialize it: &lt;em&gt;"cannot&lt;br&gt;
be scheduled, it is not importable."&lt;/em&gt; The fix is boring but absolute — &lt;strong&gt;the job&lt;br&gt;
has to be a module-level function&lt;/strong&gt; anything can import by name. Any state it&lt;br&gt;
needs, it looks up itself when it runs (it just iterates the systems that have a&lt;br&gt;
Slack webhook). Once the job is a plain top-level function, the persistent store&lt;br&gt;
is happy.&lt;/p&gt;

&lt;p&gt;The opt-in flag matters for the same reason a clean week stays silent: a fresh&lt;br&gt;
&lt;code&gt;docker compose up&lt;/code&gt; shouldn't start firing outbound Slack messages. &lt;code&gt;scheduled_jobs()&lt;/code&gt;&lt;br&gt;
returns nothing until you set &lt;code&gt;DRIFT_DIGEST_ENABLED=true&lt;/code&gt; and configure a webhook.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A monitoring feature you have to &lt;em&gt;remember to open&lt;/em&gt; isn't done. Pull tells you
nothing at 2pm on a Tuesday; push meets people where they already are.&lt;/li&gt;
&lt;li&gt;Send nothing on a quiet week. The most trustworthy alert channel is the one
that only speaks when it matters.&lt;/li&gt;
&lt;li&gt;To let an optional plugin extend the core — even with a &lt;strong&gt;background job&lt;/strong&gt; —
use discovery (&lt;code&gt;getattr(app_config, 'hook', None)&lt;/code&gt;), not import. The core asks;
it never names the plugin.&lt;/li&gt;
&lt;li&gt;If your scheduler persists jobs (django-apscheduler and friends), the job
&lt;strong&gt;must be a top-level importable function&lt;/strong&gt; — it's stored by path, not pickled.
Closures and bound methods will fail at serialization time.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is the weekly briefing from a self-hosted tool that tracks how your live AWS&lt;br&gt;
drifts from 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;. Where do your infra alerts actually land —&lt;br&gt;
Slack, email, a dashboard you open on purpose, or a channel everyone muted months&lt;br&gt;
ago?&lt;/p&gt;

</description>
      <category>django</category>
      <category>aws</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>My drift detector knew a security group changed — not that it was dangerous, or who opened it</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Mon, 29 Jun 2026 12:12:15 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/my-drift-detector-knew-a-security-group-changed-not-that-it-was-dangerous-or-who-opened-it-3phi</link>
      <guid>https://dev.to/hitoshi1964/my-drift-detector-knew-a-security-group-changed-not-that-it-was-dangerous-or-who-opened-it-3phi</guid>
      <description>&lt;p&gt;My tool detects Terraform drift: it scans live AWS, diffs it against tfstate,&lt;br&gt;
and lists every resource that no longer matches. For a long time I thought that&lt;br&gt;
was the whole job.&lt;/p&gt;

&lt;p&gt;Then I was staring at a real drift report — forty-odd changes — during a "wait,&lt;br&gt;
why is &lt;em&gt;that&lt;/em&gt; open?" moment, and I realized the report was answering the wrong&lt;br&gt;
question. It told me &lt;strong&gt;what&lt;/strong&gt; changed. It couldn't tell me the two things I&lt;br&gt;
actually needed in that moment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which of these forty is an emergency, and which is noise?&lt;/li&gt;
&lt;li&gt;Who changed it?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A flat list where a security group opened to the entire internet sits in the&lt;br&gt;
same grey row as a renamed tag is a list that makes &lt;em&gt;you&lt;/em&gt; do the triage. So I&lt;br&gt;
fixed both. Here's how, and the bits that bit me.&lt;/p&gt;
&lt;h2&gt;
  
  
  Part 1 — Not all drift is equal
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;terraform plan&lt;/code&gt; is deliberately value-neutral: a diff is a diff. But to a human&lt;br&gt;
on call, &lt;code&gt;0.0.0.0/0&lt;/code&gt; appearing in an ingress rule is a heart-attack, and&lt;br&gt;
&lt;code&gt;Name: web&lt;/code&gt; → &lt;code&gt;Name: web-1&lt;/code&gt; is a shrug. The tool already had the field-level&lt;br&gt;
diff — it just treated every field the same.&lt;/p&gt;

&lt;p&gt;So I graded each change. No new AWS calls, no model — pure logic over the diff I&lt;br&gt;
already compute:&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;classify_change&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;field&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="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;old_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;new_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# opened to the world
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new_s&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;old_s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CRITICAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&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;Opened to the entire internet (0.0.0.0/0)&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;public&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;new_s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_TRUTHY&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;old_s&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_TRUTHY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CRITICAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&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;Resource was made publicly accessible&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# protections removed
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;encrypt&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;kms&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;sse&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;old_s&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_FALSY&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;new_s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_FALSY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;HIGH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&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;Encryption was 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;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;policy&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;iam&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;role&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;principal&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;acl&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="n"&gt;HIGH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&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;Access or permission configuration changed&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="n"&gt;LOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&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;Configuration value changed&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;A resource's severity is just the worst of its field changes — open a port &lt;em&gt;and&lt;/em&gt;&lt;br&gt;
rename a tag, you're an incident, not a shrug:&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;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LOW&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&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;sev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify_change&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;c&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="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;old&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;new&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;SEVERITY_ORDER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sev&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SEVERITY_ORDER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I'd flag if you copy this. First, &lt;strong&gt;grade on the transition, not the&lt;br&gt;
value&lt;/strong&gt; — &lt;code&gt;0.0.0.0/0 in new and not in old&lt;/code&gt; only fires when the change &lt;em&gt;opened&lt;/em&gt;&lt;br&gt;
it; a group that was always public doesn't scream every scan. Second, these are&lt;br&gt;
&lt;strong&gt;heuristics, not a policy engine&lt;/strong&gt; — string-matching field names will miss&lt;br&gt;
things and occasionally over-flag. That's a deliberate trade: a fast, obvious&lt;br&gt;
"this one first" beats a correct-but-unshipped OPA integration. I'd rather be&lt;br&gt;
roughly right on every resource today.&lt;/p&gt;
&lt;h2&gt;
  
  
  Part 2 — "...but who did this?"
&lt;/h2&gt;

&lt;p&gt;Severity tells you which drift to open first. It still doesn't tell you who to go&lt;br&gt;
talk to. And &lt;code&gt;terraform plan&lt;/code&gt; &lt;em&gt;structurally cannot&lt;/em&gt; tell you — it compares two&lt;br&gt;
files; it has no idea a human touched the console at 3pm.&lt;/p&gt;

&lt;p&gt;But my tool isn't stateless. It already assumes a read-only role into each&lt;br&gt;
account to scan it. Which means CloudTrail is right there, one API call away:&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;_READ_PREFIXES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Describe&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;List&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;Get&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;Lookup&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;BatchGet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lookup_actor&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;resource_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;client&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;cloudtrail&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lookup_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;LookupAttributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AttributeKey&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;ResourceName&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;AttributeValue&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
            &lt;span class="n"&gt;MaxResults&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="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;# AccessDenied, throttling, no trail...
&lt;/span&gt;        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CloudTrail lookup failed for %s: %s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_id&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                   &lt;span class="c1"&gt;# attribution is a bonus, never load-bearing
&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&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;Events&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;EventName&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_READ_PREFIXES&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;# skip the Describe/List noise — we scan a lot
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_parse_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# who / when / source IP, from the event JSON
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things that bit me here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filter out read events.&lt;/strong&gt; My first version proudly reported that the last
thing to touch the security group was... my own scanner, calling
&lt;code&gt;DescribeSecurityGroups&lt;/code&gt;. The tool kept catching itself. Skipping the
&lt;code&gt;Describe/List/Get&lt;/code&gt; prefixes gets you the actual &lt;em&gt;mutating&lt;/em&gt; event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It must never break the page.&lt;/strong&gt; &lt;code&gt;LookupEvents&lt;/code&gt; can be denied (missing
permission), throttled, or simply find nothing. Every one of those returns
&lt;code&gt;None&lt;/code&gt; and the row says "no record" — attribution failing can't take down the
drift report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do it lazily.&lt;/strong&gt; Calling CloudTrail for forty resources on page load is slow
and rate-limit roulette. So it's a per-row "Who changed this?" button —
CloudTrail is only hit for the one resource you actually care about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also added &lt;code&gt;cloudtrail:LookupEvents&lt;/code&gt; to the bundled IAM policy, and I'm honest&lt;br&gt;
in the UI about the limits: CloudTrail Lookup covers ~90 days of management&lt;br&gt;
events, and it's regional. Sometimes the answer is "no record," and that's fine —&lt;br&gt;
it's still more than &lt;code&gt;terraform plan&lt;/code&gt; ever offered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it lives: a detachable plugin
&lt;/h2&gt;

&lt;p&gt;I didn't grow this inside the core app. It's its own optional Django app that&lt;br&gt;
plugs in through one seam — a feature flag plus a sidebar entry — and the core&lt;br&gt;
never imports it. Drop it from &lt;code&gt;INSTALLED_APPS&lt;/code&gt; and the nav entry disappears and&lt;br&gt;
the routes 404; nothing else notices. Keeping advanced features at arm's length&lt;br&gt;
like this means the core stays a clean, boring ledger, and the interesting stuff&lt;br&gt;
is opt-in. (That's a whole post of its own.)&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Drift &lt;em&gt;detection&lt;/em&gt; is the easy 80%. The decision-useful part is &lt;strong&gt;how bad&lt;/strong&gt; and
&lt;strong&gt;who&lt;/strong&gt; — and neither comes from diffing two files.&lt;/li&gt;
&lt;li&gt;Grade severity on the &lt;em&gt;transition&lt;/em&gt; (old→new), not the current value, or your
dashboard cries wolf on every scan.&lt;/li&gt;
&lt;li&gt;Heuristic severity that ships beats a perfect policy engine that doesn't. You
can always tighten the rules later.&lt;/li&gt;
&lt;li&gt;If you already hold credentials into an account, CloudTrail attribution is
almost free — just remember to filter out your own read calls, fail soft, and
fetch it lazily.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is the drift-risk view of a self-hosted tool that watches how your live AWS&lt;br&gt;
drifts from 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;. When drift shows up in your infra, what tells&lt;br&gt;
you who did it today — CloudTrail by hand, a SIEM, or nobody and you just ask&lt;br&gt;
around?&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>django</category>
      <category>devops</category>
    </item>
    <item>
      <title>In information_schema, a generated column looks exactly like a plain one</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Mon, 29 Jun 2026 11:56:05 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/in-informationschema-a-generated-column-looks-exactly-like-a-plain-one-499c</link>
      <guid>https://dev.to/hitoshi1964/in-informationschema-a-generated-column-looks-exactly-like-a-plain-one-499c</guid>
      <description>&lt;p&gt;I was rendering a &lt;code&gt;\d&lt;/code&gt;-style column list — name, type, nullable, default — straight off &lt;code&gt;information_schema.columns&lt;/code&gt;. Clean, portable, boring. Then I pointed it at a table with a generated column and the view was quietly, confidently wrong.&lt;/p&gt;

&lt;p&gt;The generated column showed up as a perfectly ordinary column. Type: fine. Nullable: fine. Default: &lt;code&gt;NULL&lt;/code&gt;. No hint anywhere that the value is &lt;em&gt;computed&lt;/em&gt;, not stored. If you trusted my panel, you'd think you could just write to 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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;qty&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_nullable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_default&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; column_name | data_type | is_nullable | column_default
-------------+-----------+-------------+----------------
 qty         | integer   | YES         |
 price       | numeric   | YES         |
 total       | numeric   | YES         |          ← computed, but you can't tell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;total&lt;/code&gt; is a column you literally cannot &lt;code&gt;INSERT&lt;/code&gt; into, and &lt;code&gt;information_schema&lt;/code&gt; describes it identically to &lt;code&gt;qty&lt;/code&gt;. The standard view just doesn't carry the bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit lives in pg_attribute
&lt;/h2&gt;

&lt;p&gt;PostgreSQL records "is this column generated" in &lt;code&gt;pg_attribute.attgenerated&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;''&lt;/code&gt; — ordinary column&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;'s'&lt;/code&gt; — &lt;code&gt;STORED&lt;/code&gt; (the value is materialized on write)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;'v'&lt;/code&gt; — &lt;code&gt;VIRTUAL&lt;/code&gt; (computed on read; &lt;strong&gt;new in PostgreSQL 18&lt;/strong&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So you stop trusting &lt;code&gt;information_schema&lt;/code&gt; for this one fact and join down to the catalog:&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_nullable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attgenerated&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attgenerated&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
            &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;pg_get_expr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adrelid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;gen_expr&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_attribute&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
       &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%I.%I'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&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="k"&gt;c&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;regclass&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attname&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attisdropped&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_attrdef&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt;
       &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attrelid&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adnum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attnum&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&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="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ordinal_position&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; column_name | generated |   gen_expr
-------------+-----------+---------------
 qty         |           |
 price       |           |
 total       | s         | (qty * price)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;total&lt;/code&gt; carries its truth: generated, stored, computed from &lt;code&gt;qty * price&lt;/code&gt;. Three small gotchas earned that result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Join &lt;code&gt;pg_attribute&lt;/code&gt; by name, and skip dropped columns.&lt;/strong&gt; &lt;code&gt;attnum&lt;/code&gt; is only stable if you exclude dropped columns (&lt;code&gt;NOT a.attisdropped&lt;/code&gt;) — a dropped column leaves a tombstone row that shifts nothing if you account for it and corrupts your mapping if you don't. Matching on &lt;code&gt;attname&lt;/code&gt; against &lt;code&gt;information_schema&lt;/code&gt;'s already-filtered list keeps the two sides aligned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The expression comes from &lt;code&gt;pg_attrdef&lt;/code&gt;, not &lt;code&gt;pg_attribute&lt;/code&gt;.&lt;/strong&gt; The generation expression is stored where defaults live, and you decompile it with &lt;code&gt;pg_get_expr(ad.adbin, ad.adrelid)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard &lt;code&gt;pg_get_expr&lt;/code&gt; with the &lt;code&gt;CASE&lt;/code&gt;.&lt;/strong&gt; This is the one that bites. A &lt;em&gt;plain&lt;/em&gt; column with a normal default also has a &lt;code&gt;pg_attrdef&lt;/code&gt; row — its &lt;code&gt;adbin&lt;/code&gt; is the default literal, not a generation expression. Without &lt;code&gt;WHEN a.attgenerated &amp;lt;&amp;gt; ''&lt;/code&gt; you'd happily print &lt;code&gt;now()&lt;/code&gt; or &lt;code&gt;0&lt;/code&gt; into a "generated from" column and mislabel every defaulted column in the table. The &lt;code&gt;CASE&lt;/code&gt; keeps &lt;code&gt;pg_get_expr&lt;/code&gt; pointed only at columns that are actually generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why now: PostgreSQL 18 flips the default
&lt;/h2&gt;

&lt;p&gt;Generated columns aren't new — &lt;code&gt;STORED&lt;/code&gt; arrived in PostgreSQL 12, and &lt;code&gt;attgenerated&lt;/code&gt; has existed since then, so the query above is version-robust all the way back. What changed is the &lt;em&gt;blast radius&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;PostgreSQL 18 adds &lt;code&gt;VIRTUAL&lt;/code&gt; generated columns (computed on read, taking no storage) and makes &lt;strong&gt;VIRTUAL the default&lt;/strong&gt; — write &lt;code&gt;GENERATED ALWAYS AS (…)&lt;/code&gt; with no &lt;code&gt;STORED&lt;/code&gt;/&lt;code&gt;VIRTUAL&lt;/code&gt; keyword and you get a virtual one. Which means generated columns are about to get a lot more common in schemas written by people who never typed the word "generated" with intent. Every &lt;code&gt;\d&lt;/code&gt;-clone built on &lt;code&gt;information_schema&lt;/code&gt; is going to start being wrong about more tables, not fewer. The fix is the same one byte: read &lt;code&gt;attgenerated&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;MySQL has the identical trap from the other direction: &lt;code&gt;information_schema.COLUMNS&lt;/code&gt; &lt;em&gt;does&lt;/em&gt; carry the info, just smuggled into a free-text &lt;code&gt;EXTRA&lt;/code&gt; field that also holds &lt;code&gt;auto_increment&lt;/code&gt; and &lt;code&gt;on update&lt;/code&gt; flags:&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;COLUMN_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EXTRA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GENERATION_EXPRESSION&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLUMNS&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;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="k"&gt;TABLE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&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;EXTRA&lt;/code&gt; reads &lt;code&gt;STORED GENERATED&lt;/code&gt; or &lt;code&gt;VIRTUAL GENERATED&lt;/code&gt; (and &lt;code&gt;GENERATION_EXPRESSION&lt;/code&gt; is &lt;code&gt;''&lt;/code&gt; for ordinary columns). You substring-match it rather than &lt;code&gt;=&lt;/code&gt;, because the flags can combine. Same concept, same two kinds — stored vs virtual — different cubbyhole.&lt;/p&gt;

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

&lt;p&gt;This doesn't make a generated column &lt;em&gt;writable&lt;/em&gt; or change anything about it — it just stops a schema viewer from lying by omission. If all you ever do is &lt;code&gt;SELECT *&lt;/code&gt;, you never noticed the gap. The moment you build tooling that says "here are this table's columns and here's what you can put in them," the one byte in &lt;code&gt;attgenerated&lt;/code&gt; is the difference between a true map and a confident wrong one.&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 does &lt;em&gt;your&lt;/em&gt; &lt;code&gt;\d&lt;/code&gt; clone quietly get wrong?&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>database</category>
    </item>
    <item>
      <title>My dashboard showed every number and helped you decide nothing</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Fri, 26 Jun 2026 10:32:46 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/my-dashboard-showed-every-number-and-helped-you-decide-nothing-46hb</link>
      <guid>https://dev.to/hitoshi1964/my-dashboard-showed-every-number-and-helped-you-decide-nothing-46hb</guid>
      <description>&lt;p&gt;My dashboard looked great. Total assets, systems, environments. Counts broken&lt;br&gt;
down by resource type, by category, by provider. Little badges everywhere. It&lt;br&gt;
&lt;em&gt;felt&lt;/em&gt; like a control room.&lt;/p&gt;

&lt;p&gt;Then I noticed something about my own behavior: I never actually &lt;strong&gt;did&lt;/strong&gt; anything&lt;br&gt;
from it. I'd glance at the numbers, come away none the wiser, and then click into&lt;br&gt;
a sub-page to find out whether I should care. The dashboard was the thing I passed&lt;br&gt;
&lt;em&gt;through&lt;/em&gt; on the way to the thing I needed.&lt;/p&gt;

&lt;p&gt;That's the tell. A dashboard that you skim and leave isn't a dashboard — it's a&lt;br&gt;
table of contents with nicer fonts.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three layers, and where mine stopped
&lt;/h2&gt;

&lt;p&gt;A useful dashboard answers three questions in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What's happening?&lt;/strong&gt; — the signal. "Drift: 5."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is that urgent?&lt;/strong&gt; — the context. 5 &lt;em&gt;up from 0 last scan&lt;/em&gt; is an incident; 5
&lt;em&gt;down from 12&lt;/em&gt; is progress. Same number, opposite meaning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What do I do about it?&lt;/strong&gt; — the action. A link straight into the workflow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mine was 100% layer 1. Here's literally what fed it:&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;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;total_assets&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total_systems&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;sys_qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total_envs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;env_qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;by_type&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;by_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;by_category&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;by_category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;by_provider&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;by_provider&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;Raw counts. Not one of them tells you whether today is better or worse than&lt;br&gt;
yesterday, and not one of them is a thing you can click and act on.&lt;/p&gt;
&lt;h2&gt;
  
  
  The embarrassing part: I had already built layer 2
&lt;/h2&gt;

&lt;p&gt;This is the bit that stung. The context I was missing wasn't actually missing. I'd&lt;br&gt;
&lt;em&gt;already built it&lt;/em&gt; — it was just sitting in pages nobody lands on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;DriftSnapshot&lt;/code&gt; history table that records drift after every scan (the trend)&lt;/li&gt;
&lt;li&gt;end-of-life data for every tracked runtime and dependency&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;ScanJob&lt;/code&gt; row with &lt;code&gt;finished_at&lt;/code&gt; for every scan (the freshness)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All the materials for "is it urgent?" existed. I'd just never wired them into the&lt;br&gt;
one screen where someone decides what to do next. The fix wasn't new data. It was&lt;br&gt;
&lt;strong&gt;moving signal to where the decision happens.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The hero band
&lt;/h2&gt;

&lt;p&gt;So I added a persistent row of three tiles above everything else — the first thing&lt;br&gt;
you see, answering all three questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tile&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Drift&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;open drift across envs&lt;/td&gt;
&lt;td&gt;▲/▼ vs the previous snapshot&lt;/td&gt;
&lt;td&gt;→ drift history of the worst env&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;End of life&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;dependencies past EOL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+N nearing EOL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;→ runtimes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Freshness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;last completed scan&lt;/td&gt;
&lt;td&gt;turns amber after 24h&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fch0jhkvcdhsttqft94ob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fch0jhkvcdhsttqft94ob.png" alt="Dashboard hero row: drift with a trend delta, EOL count, and scan freshness" width="799" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The interesting work is the context column. Take the drift delta. I want it to say&lt;br&gt;
"5 drifted &lt;strong&gt;(▲2 since last scan)&lt;/strong&gt;". That means: for each environment, compare its&lt;br&gt;
newest snapshot to the one before it. The naive version is a query per environment.&lt;br&gt;
But you can do it in one, by ordering and grouping in Python:&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&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;objects&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;environment__system__organization&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;environment_id&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;-detected_at&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;environment_id&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;changed_count&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;added_count&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;latest_by_env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev_by_env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;env_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;environment_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;changed_count&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;added_count&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;env_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;latest_by_env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# first seen = newest (ordered desc)
&lt;/span&gt;        &lt;span class="n"&gt;latest_by_env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;env_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;env_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prev_by_env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# second seen = the one before it
&lt;/span&gt;        &lt;span class="n"&gt;prev_by_env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;env_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One ordered scan, two dicts. (It's one query, but it does lean on a sort across all&lt;br&gt;
snapshots — at serious scale that's the cost to watch, not the round-trips.) The&lt;br&gt;
delta only compares environments that actually &lt;em&gt;have&lt;/em&gt; a previous snapshot. A&lt;br&gt;
brand-new environment shows its count with no misleading "▲5" — there was no prior&lt;br&gt;
scan to compare against:&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;drift_current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latest_by_env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;has_history&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev_by_env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;drift_delta&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;has_history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latest_by_env&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="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;prev_by_env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev_by_env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;drift_delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;cur&lt;/code&gt; re-sums only the environments that have history, so the delta&lt;br&gt;
compares like with like — it deliberately isn't the same number as &lt;code&gt;drift_current&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The layout gotcha: the hero kept disappearing
&lt;/h2&gt;

&lt;p&gt;My app is server-rendered Django with htmx. The sidebar doesn't navigate pages — it&lt;br&gt;
swaps the contents of &lt;code&gt;#main-content&lt;/code&gt; in place. My first attempt put the hero band&lt;br&gt;
&lt;em&gt;inside&lt;/em&gt; that container, and it worked beautifully until I clicked "All Resources"&lt;br&gt;
and watched my brand-new dashboard evaporate. Of course it did: I'd just told htmx&lt;br&gt;
to replace the element it lived in.&lt;/p&gt;

&lt;p&gt;The fix is a one-line placement decision — the band has to live &lt;strong&gt;outside&lt;/strong&gt; the&lt;br&gt;
swap target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;/header&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;include&lt;/span&gt; &lt;span class="s1"&gt;'_dashboard_hero.html'&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;   &lt;span class="c"&gt;{# persistent — survives htmx swaps #}&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"main-content"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"p-6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;include&lt;/span&gt; &lt;span class="s1"&gt;'_system_list.html'&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you build a "shell + swappable body" UI, anything that's supposed to be&lt;br&gt;
&lt;em&gt;always-on&lt;/em&gt; — global signals, freshness, alerts — belongs above the swap boundary,&lt;br&gt;
not in it. Obvious in hindsight; invisible until you click the wrong button.&lt;/p&gt;
&lt;h2&gt;
  
  
  Freshness and the empty state
&lt;/h2&gt;

&lt;p&gt;Two details that decide whether people trust a dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freshness&lt;/strong&gt;, because a confident number from stale data is worse than no number.&lt;br&gt;
Anything older than a day gets an amber tint:&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;last_scan_stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;last_scan&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&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;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_scan&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&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;86400&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The empty state&lt;/strong&gt;, because a fresh install has zero of everything, and zero&lt;br&gt;
should read as &lt;em&gt;calm&lt;/em&gt;, not &lt;em&gt;broken&lt;/em&gt;. When there's no drift, the tile isn't a stark&lt;br&gt;
"0" — it's a green "No drift detected." A clean account should feel reassuring, not&lt;br&gt;
like something failed to load.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately left undone
&lt;/h2&gt;

&lt;p&gt;Two honest edges, because the point is to ship the decision layer, not to gold-plate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The signals are still computed &lt;strong&gt;live on every request&lt;/strong&gt;. Fine at my scale; the
right next step is a periodic job writing a summary row the dashboard just reads.
Until aggregation actually hurts, precompute is speculative.&lt;/li&gt;
&lt;li&gt;The new tiles are &lt;strong&gt;English-only&lt;/strong&gt; for now — the translations are a follow-up.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;If you skim your own dashboard and then leave to go &lt;em&gt;find out if you should care&lt;/em&gt;,
it's all signal and no decision. Start from "what is the user trying to decide
here?", not "what data can I show?"&lt;/li&gt;
&lt;li&gt;A raw count is layer 1. The same count &lt;strong&gt;versus yesterday&lt;/strong&gt; is what makes it
actionable. The delta is usually cheaper than you fear — one ordered query and a
pass in Python.&lt;/li&gt;
&lt;li&gt;In a shell/swappable-body UI, always-on signals must live &lt;em&gt;outside&lt;/em&gt; the swap
target, or they vanish on the first navigation.&lt;/li&gt;
&lt;li&gt;Show data freshness, and make the empty state feel calm. Both are trust.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is the dashboard of a self-hosted tool that tracks how your live AWS drifts&lt;br&gt;
from Terraform, plus runtime EOL — 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 you design a dashboard, where do you draw&lt;br&gt;
the line on context — just a number, a delta vs last run, or a full sparkline?&lt;/p&gt;

</description>
      <category>django</category>
      <category>ux</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>No SPA: a multi-panel database UI in Django + htmx + a sprinkle of Alpine</title>
      <dc:creator>TABATA Hitoshi</dc:creator>
      <pubDate>Fri, 26 Jun 2026 10:31:13 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/no-spa-a-multi-panel-database-ui-in-django-htmx-a-sprinkle-of-alpine-452g</link>
      <guid>https://dev.to/hitoshi1964/no-spa-a-multi-panel-database-ui-in-django-htmx-a-sprinkle-of-alpine-452g</guid>
      <description>&lt;p&gt;I built a database console with a table browser, a SQL runner, an index lab, an EXPLAIN-plan differ, live activity and locks panels, snapshots, and a streaming backup/restore. A dozen panels, lots of moving parts.&lt;/p&gt;

&lt;p&gt;There is no build step. No &lt;code&gt;node_modules&lt;/code&gt;. No bundler, no framework CLI, no &lt;code&gt;npm run&lt;/code&gt; anything. The &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; is three &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags from a CDN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.tailwindcss.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/htmx.org@2.0.4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole frontend toolchain. And the surprising part isn't that it works — it's that it made the app &lt;em&gt;deeper&lt;/em&gt;, because adding a panel got boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one pattern
&lt;/h2&gt;

&lt;p&gt;Every section of the UI is the same three pieces: &lt;strong&gt;one nav button, one view, one template.&lt;/strong&gt; Click the button, htmx fetches a partial, swaps it into the main pane. That's it.&lt;/p&gt;

&lt;p&gt;The nav button just declares where to fetch and where to put it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;hx-get=&lt;/span&gt;&lt;span class="s"&gt;"{% url 'overview' connection.pk %}"&lt;/span&gt;
        &lt;span class="na"&gt;hx-target=&lt;/span&gt;&lt;span class="s"&gt;"#detail"&lt;/span&gt; &lt;span class="na"&gt;hx-swap=&lt;/span&gt;&lt;span class="s"&gt;"innerHTML"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;⌂ overview&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;hx-get=&lt;/span&gt;&lt;span class="s"&gt;"{% url 'query' connection.pk %}"&lt;/span&gt;
        &lt;span class="na"&gt;hx-target=&lt;/span&gt;&lt;span class="s"&gt;"#detail"&lt;/span&gt; &lt;span class="na"&gt;hx-swap=&lt;/span&gt;&lt;span class="s"&gt;"innerHTML"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;SQL&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;hx-get=&lt;/span&gt;&lt;span class="s"&gt;"{% url 'history' connection.pk %}"&lt;/span&gt;
        &lt;span class="na"&gt;hx-target=&lt;/span&gt;&lt;span class="s"&gt;"#detail"&lt;/span&gt; &lt;span class="na"&gt;hx-swap=&lt;/span&gt;&lt;span class="s"&gt;"innerHTML"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;History&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The view does the database work and renders a plain Django template fragment — no JSON, no serializer, no client-side rendering:&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;table_detail&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="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Columns + a row preview for one table (htmx partial into #detail).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_object_or_404&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connection&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;render&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partials/detail.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;columns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indexes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_indexes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preview_rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preview_rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;rows&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;The template is the same HTML you'd write for a full-page render, minus the &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; wrapper. The server already knows how to turn data into markup — that's what templates are. htmx just lets you ship a &lt;em&gt;fragment&lt;/em&gt; of that markup to a &lt;em&gt;fragment&lt;/em&gt; of the page.&lt;/p&gt;

&lt;p&gt;So "add a panel" is purely mechanical: add a &lt;code&gt;path()&lt;/code&gt;, write a view, write a template, drop in a button. There's no new state to wire into a client store, no API contract to keep in sync, no round-trip of "shape the JSON / reshape it back into DOM." That low ceremony is &lt;em&gt;why&lt;/em&gt; the panel count grew — coverage got wide because each addition was cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forms are just buttons that POST
&lt;/h2&gt;

&lt;p&gt;Mutations are the same shape, with &lt;code&gt;hx-post&lt;/code&gt; and a confirm where it bites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;hx-post=&lt;/span&gt;&lt;span class="s"&gt;"{% url 'table_truncate' connection.pk %}"&lt;/span&gt;
      &lt;span class="na"&gt;hx-target=&lt;/span&gt;&lt;span class="s"&gt;"#detail"&lt;/span&gt; &lt;span class="na"&gt;hx-swap=&lt;/span&gt;&lt;span class="s"&gt;"innerHTML"&lt;/span&gt;
      &lt;span class="na"&gt;hx-confirm=&lt;/span&gt;&lt;span class="s"&gt;"Truncate this table? This permanently deletes every row."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn btn-danger"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Truncate&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The view re-renders the same detail partial afterward, so the panel updates itself with the now-empty preview. No optimistic UI, no manual DOM patching — the server re-states the truth and htmx swaps it in.&lt;/p&gt;

&lt;p&gt;When one action needs to touch &lt;em&gt;two&lt;/em&gt; places — say a rename updates the main pane &lt;strong&gt;and&lt;/strong&gt; the sidebar tree — you don't reach for client state. You append an out-of-band fragment to the same response:&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;# Same round trip: re-render the sidebar and let htmx place it by id.
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;render_to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partials/table_list.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tables&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;oob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"table-list"&lt;/span&gt;&lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt; &lt;span class="na"&gt;oob&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt; &lt;span class="na"&gt;hx-swap-oob=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="na"&gt;endif&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two regions, one request, zero JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Alpine earns its keep
&lt;/h2&gt;

&lt;p&gt;If the server owns &lt;em&gt;navigation and data&lt;/em&gt;, what's left for JS? Only the genuinely client-side bits — state the server has no opinion about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a slide-over &lt;strong&gt;drawer&lt;/strong&gt; that toggles open/closed,&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;filter builder&lt;/strong&gt; where you add/remove condition rows,&lt;/li&gt;
&lt;li&gt;copy-to-clipboard, and a spinner while a file download streams.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's exactly Alpine's lane: a few lines of local component state, no global store for things the database already knows. The filter builder is fifteen lines of &lt;code&gt;x-data&lt;/code&gt;, not a Redux slice.&lt;/p&gt;

&lt;p&gt;And there's almost no boilerplate tax for the no-build choice. CSRF is wired once, on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, so no form needs a hidden token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;body&lt;/span&gt; &lt;span class="na"&gt;hx-headers=&lt;/span&gt;&lt;span class="s"&gt;'{"X-CSRFToken": "{{ csrf_token }}"}'&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The honest part: where this breaks
&lt;/h2&gt;

&lt;p&gt;This is not "SPAs are bad." htmx-over-the-wire is the right tool when &lt;strong&gt;the server stays the source of truth&lt;/strong&gt; and interactions are request/response shaped — click, fetch, swap. A database console is &lt;em&gt;exactly&lt;/em&gt; that: every panel is a question you ask the database and a fresh answer it renders.&lt;/p&gt;

&lt;p&gt;It would be the wrong tool the moment you need rich, &lt;em&gt;offline-ish&lt;/em&gt; client state: a drag-and-drop canvas, a collaborative editor, an interactive chart you pan and zoom, anything where the UI holds significant truth the server isn't round-tripping. Push htmx there and you'll fight it — re-fetching for interactions that should never hit the network. Each section swap is also a real HTTP request, so a truly chatty, keystroke-latency UI wants a client framework.&lt;/p&gt;

&lt;p&gt;But for a tool that's fundamentally "show me what the database says, then let me change it," giving up the build step cost nothing and bought a codebase where the next feature is four small, obvious files.&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 command do &lt;em&gt;you&lt;/em&gt; reach for that should be a button?&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>django</category>
      <category>htmx</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
