<?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: ひとし 田畑</title>
    <description>The latest articles on DEV Community by ひとし 田畑 (@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%2F05c5857a-b691-4600-911d-acc268f00185.png</url>
      <title>DEV Community: ひとし 田畑</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>My drift detector knew a security group changed — not that it was dangerous, or who opened it</title>
      <dc:creator>ひとし 田畑</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>ひとし 田畑</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>ひとし 田畑</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>ひとし 田畑</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>
    <item>
      <title>I shipped a database console with no login — on purpose</title>
      <dc:creator>ひとし 田畑</dc:creator>
      <pubDate>Thu, 25 Jun 2026 10:20:17 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/i-shipped-a-database-console-with-no-login-on-purpose-3gj6</link>
      <guid>https://dev.to/hitoshi1964/i-shipped-a-database-console-with-no-login-on-purpose-3gj6</guid>
      <description>&lt;p&gt;"You built a web UI that runs SQL against a database, and there's &lt;em&gt;no login?&lt;/em&gt;"&lt;/p&gt;

&lt;p&gt;Yes. On purpose. And the reaction is worth unpacking, because "add authentication" is a reflex, and reflexes skip the question that actually matters: &lt;strong&gt;what's the threat you're defending against?&lt;/strong&gt; For a tool that runs on your own machine, next to your own database, a login turns out to be the wrong tool for the real danger — and it drags in a liability you don't want.&lt;/p&gt;

&lt;h2&gt;
  
  
  What auth would actually cost
&lt;/h2&gt;

&lt;p&gt;The moment you add accounts, you're holding credentials on a server. Now you own: password hashing, session management, reset flows, lockouts, and — because this thing also stores &lt;em&gt;database&lt;/em&gt; connection passwords — an encryption-at-rest story for those too. You've signed up for a multi-tenant security posture to protect a tool that one person runs on &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And here's the thing: none of that defends against the attack that can actually reach a local tool. If anything, being "logged in" makes it &lt;em&gt;worse&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real threat: the tab you forgot about
&lt;/h2&gt;

&lt;p&gt;A web server on &lt;code&gt;localhost:8000&lt;/code&gt; isn't invisible just because it's local. Every other website open in your browser can send requests to it. So the realistic attack on a no-auth local console isn't "a stranger logs in as you" — it's a &lt;strong&gt;drive-by&lt;/strong&gt;: some page you have open in another tab quietly fires a cross-origin &lt;code&gt;POST&lt;/code&gt; at &lt;code&gt;http://localhost:8000/...&lt;/code&gt; and your database executes a &lt;code&gt;DROP&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Notice what a login does against that attack: &lt;strong&gt;nothing.&lt;/strong&gt; You'd be logged in. The malicious form submits &lt;em&gt;as you&lt;/em&gt;, with your cookies, to your authenticated session. Auth defends against the wrong actor — a remote impostor — when the actual actor is a webpage abusing the trust your browser already extends to you.&lt;/p&gt;

&lt;p&gt;The defense that &lt;em&gt;does&lt;/em&gt; work is the one for exactly this shape of attack: &lt;strong&gt;CSRF protection&lt;/strong&gt; and &lt;strong&gt;clickjacking protection&lt;/strong&gt;. So those are the two things cli2ui actually spends its security budget on:&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;# settings.py — even though the tool is local-only and has no sessions:
&lt;/span&gt;&lt;span class="n"&gt;MIDDLEWARE&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;django.middleware.security.SecurityMiddleware&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django.middleware.csrf.CsrfViewMiddleware&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Sending X-Frame-Options: DENY so the UI can't be framed stops a
&lt;/span&gt;    &lt;span class="c1"&gt;# clickjacking page from tricking you into clicking its buttons.
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django.middleware.clickjacking.XFrameOptionsMiddleware&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="c1"&gt;# Cookie-based CSRF works standalone here (no SessionMiddleware needed),
# and the trusted origins are pinned to localhost only.
&lt;/span&gt;&lt;span class="n"&gt;CSRF_TRUSTED_ORIGINS&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;http://localhost:8000&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;http://127.0.0.1:8000&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;Without CSRF, any website you visit could fire that cross-origin form POST at &lt;code&gt;localhost&lt;/code&gt; and mutate — or drop — your database. &lt;em&gt;That&lt;/em&gt; is the threat a local SQL console has to take seriously, and it's a per-request token, not a login. (The htmx UI sends the token via an &lt;code&gt;hx-headers&lt;/code&gt; attribute on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, so there's no &lt;code&gt;{% csrf_token %}&lt;/code&gt; sprinkled through every form.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the hardening that still matters
&lt;/h2&gt;

&lt;p&gt;Dropping auth doesn't mean dropping care. The destructive surface is real, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DEBUG&lt;/code&gt; is off by default.&lt;/strong&gt; A Django error page leaks tracebacks, settings, and SQL — and this tool sits &lt;em&gt;right next to a database&lt;/em&gt;. You opt into &lt;code&gt;DJANGO_DEBUG=1&lt;/code&gt; only when you're actually debugging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identifiers are bound, not formatted.&lt;/strong&gt; Schema / table / column names go through &lt;code&gt;psycopg2.sql.Identifier&lt;/code&gt;; the few raw-SQL spots (like an index access method) use a fixed allow-list. The ad-hoc SQL runner executes what you typed — that's the feature — but everything &lt;em&gt;around&lt;/em&gt; it doesn't concatenate your table name into a string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The runner defaults to read-only at the server.&lt;/strong&gt; &lt;code&gt;SET TRANSACTION READ ONLY&lt;/code&gt; means Postgres itself refuses writes; write mode is opt-in and snapshots the database first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest part: this is a position, not a law
&lt;/h2&gt;

&lt;p&gt;I'm not arguing "auth is bad." I'm arguing auth is the wrong &lt;em&gt;first&lt;/em&gt; move for a &lt;strong&gt;single-user, local&lt;/strong&gt; tool, and that the reflex to add it skips the threat model. The README says this plainly and loudly: &lt;strong&gt;do not expose cli2ui to an untrusted network.&lt;/strong&gt; The moment any of these change —&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more than one person uses it,&lt;/li&gt;
&lt;li&gt;it's reachable from outside your machine or trusted network,&lt;/li&gt;
&lt;li&gt;it's deployed as a shared service —&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— the threat model flips, "a remote impostor" becomes real, and you &lt;em&gt;do&lt;/em&gt; need accounts, encrypted credential storage, and the whole apparatus. At that point this is the wrong design. That boundary is the actual decision, stated up front, instead of bolting on a login so the architecture &lt;em&gt;looks&lt;/em&gt; secure while the drive-by POST sails through.&lt;/p&gt;

&lt;p&gt;(For full honesty: cli2ui stores your database connection passwords in plaintext in a local SQLite file — which is fine for a single-user local tool and indefensible the instant it isn't. Same boundary, same rule.)&lt;/p&gt;

&lt;p&gt;"No login" isn't the absence of a security decision. It's a security decision — scoped to a threat model I wrote down, defended where the attack actually lands.&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>security</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I added TOTP 2FA to my Django app in ~40 lines and no 2FA library — but one line decides whether it's real</title>
      <dc:creator>ひとし 田畑</dc:creator>
      <pubDate>Wed, 24 Jun 2026 10:49:20 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/i-added-totp-2fa-to-my-django-app-in-40-lines-and-no-2fa-library-but-one-line-decides-whether-3mc0</link>
      <guid>https://dev.to/hitoshi1964/i-added-totp-2fa-to-my-django-app-in-40-lines-and-no-2fa-library-but-one-line-decides-whether-3mc0</guid>
      <description>&lt;p&gt;I wanted two-factor auth on my self-hosted app, the kind where you scan a QR code&lt;br&gt;
into Google Authenticator and type a 6-digit code. I reached for&lt;br&gt;
&lt;code&gt;django-two-factor-auth&lt;/code&gt; out of habit, looked at how much machinery it drags in for&lt;br&gt;
a one-person tool, and backed away. The actual primitive — TOTP — is tiny:&lt;br&gt;
&lt;code&gt;pyotp&lt;/code&gt; does the math, &lt;code&gt;qrcode&lt;/code&gt; draws the QR. The whole thing is about 40 lines.&lt;/p&gt;

&lt;p&gt;But "about 40 lines" hides one line that decides whether your 2FA is real or pure&lt;br&gt;
theater. I'll get there. First, enrollment.&lt;/p&gt;
&lt;h2&gt;
  
  
  Enrollment: the secret stays &lt;em&gt;pending&lt;/em&gt; until they prove they scanned it
&lt;/h2&gt;

&lt;p&gt;The naive flow is: generate a secret, save it on the user, show the QR. The bug in&lt;br&gt;
that flow is subtle and mean — if the user fumbles the scan, or scans into the wrong&lt;br&gt;
device, you've now flipped on 2FA with a secret they can't reproduce, and &lt;strong&gt;you've&lt;br&gt;
locked them out of their own account.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the secret lives in the &lt;em&gt;session&lt;/em&gt;, not the user row, until they type a code that&lt;br&gt;
proves they have 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;def&lt;/span&gt; &lt;span class="nf"&gt;totp_setup_view&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qrcode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
    &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random_base32&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;totp_pending_secret&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;secret&lt;/span&gt;      &lt;span class="c1"&gt;# NOT saved to the user yet
&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="ow"&gt;or&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;
    &lt;span class="n"&gt;uri&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;provisioning_uri&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issuer_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Cloud Asset Manager&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;qrcode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PNG&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;qr_b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="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;_profile_totp_setup.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;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;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;qr_b64&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;qr_b64&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;provisioning_uri()&lt;/code&gt; builds the &lt;code&gt;otpauth://...&lt;/code&gt; URI the authenticator app expects;&lt;br&gt;
&lt;code&gt;qrcode&lt;/code&gt; turns it into a PNG; base64 inlines it straight into the page so there's no&lt;br&gt;
image endpoint to wire up. Only when they come back with a working code do I persist&lt;br&gt;
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;def&lt;/span&gt; &lt;span class="nf"&gt;totp_confirm_view&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;
    &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&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="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;totp_pending_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;code&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="n"&gt;POST&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;code&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="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;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;...your session expired, start over...&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="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;_profile_totp_setup.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;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;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;The verification code is incorrect&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_or_create_profile&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;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totp_secret&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;two_factor_enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;request&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="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;totp_pending_secret&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="c1"&gt;# ...success partial...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prove first, persist second. The DB never holds a secret the user hasn't&lt;br&gt;
demonstrated they can generate codes for.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one line: do NOT log them in before the code
&lt;/h2&gt;

&lt;p&gt;Here's the part that decides whether any of this is worth doing. At login, the&lt;br&gt;
password checks out. The tempting move is to call &lt;code&gt;auth_login()&lt;/code&gt; and &lt;em&gt;then&lt;/em&gt; show the&lt;br&gt;
code prompt. If you do that, the user is &lt;strong&gt;already authenticated&lt;/strong&gt; — the 6-digit&lt;br&gt;
field is a speed bump they can navigate away from. That's not 2FA, it's a decoration.&lt;/p&gt;

&lt;p&gt;The correct shape: on a 2FA user, stash a &lt;em&gt;pending&lt;/em&gt; marker and redirect — but leave&lt;br&gt;
them anonymous:&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;authenticate&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;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;password&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;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;two_factor_enabled&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totp_secret&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;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;totp_pending_user_id&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;     &lt;span class="c1"&gt;# a marker, NOT a login
&lt;/span&gt;        &lt;span class="n"&gt;next_url&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="n"&gt;GET&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;next&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;/&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;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/totp-verify/?next=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;next_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;auth_login&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;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                  &lt;span class="c1"&gt;# only here, only without 2FA
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&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;GET&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;next&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth_login()&lt;/code&gt; for the second factor happens in exactly one place — &lt;em&gt;after&lt;/em&gt; the code&lt;br&gt;
verifies:&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;totp_verify_view&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;pending_id&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="n"&gt;session&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;totp_pending_user_id&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pending_id&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;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/login/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# no half-finished state to land on
&lt;/span&gt;
    &lt;span class="k"&gt;if&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;method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyotp&lt;/span&gt;
        &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&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;code&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_User&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;get&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;pending_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UserProfile&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&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;pyotp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TOTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totp_secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;del&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;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;totp_pending_user_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="nf"&gt;auth_login&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;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# NOW the session is authenticated
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&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;GET&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;next&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;totp_verify.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;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Wrong code&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;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;totp_verify.html&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;Between password and code the user holds a session key (&lt;code&gt;totp_pending_user_id&lt;/code&gt;) and&lt;br&gt;
nothing else. They can't reach any real page, because Django doesn't think they're&lt;br&gt;
logged in. The &lt;code&gt;if not pending_id: redirect('/login/')&lt;/code&gt; guard means there's no way to&lt;br&gt;
land on the verify page out of order, either.&lt;/p&gt;

&lt;p&gt;One related snag if you run gatekeeping middleware: my app forces every&lt;br&gt;
authenticated request through an org-membership check, and I had to add&lt;br&gt;
&lt;code&gt;/totp-verify/&lt;/code&gt; to its exempt prefixes — otherwise the redirect dance fights the&lt;br&gt;
middleware. Worth checking your own middleware doesn't intercept the in-between step.&lt;/p&gt;
&lt;h2&gt;
  
  
  Turning it off should cost something
&lt;/h2&gt;

&lt;p&gt;Disabling 2FA is a security downgrade, so it shouldn't be a one-click thing an&lt;br&gt;
attacker on an unlocked session can do. Re-ask for the password:&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;totp_disable_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_password&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;POST&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;password&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;...password is incorrect...&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_or_create_profile&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;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;two_factor_enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totp_secret&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd still harden
&lt;/h2&gt;

&lt;p&gt;Being honest about the edges, since the whole point of self-hosting is you own them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The secret is stored in plaintext&lt;/strong&gt; in a &lt;code&gt;CharField&lt;/code&gt;. For a single-tenant,
self-hosted box that's the same trust boundary as your password hashes — but
encrypting it at rest (or a KMS-backed field) is the obvious next step if the DB
is a bigger worry than the app server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No recovery codes yet.&lt;/strong&gt; Lose the phone, lose the account (short of an admin
reset). One-time backup codes are the standard answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clock skew:&lt;/strong&gt; &lt;code&gt;pyotp&lt;/code&gt;'s &lt;code&gt;verify()&lt;/code&gt; accepts only the current 30-second step by
default. If users report "valid code rejected," &lt;code&gt;verify(code, valid_window=1)&lt;/code&gt;
tolerates one step either side.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;You probably don't need a 2FA &lt;em&gt;framework&lt;/em&gt; for TOTP. &lt;code&gt;pyotp&lt;/code&gt; + &lt;code&gt;qrcode&lt;/code&gt; is ~40
lines and you understand every one of them.&lt;/li&gt;
&lt;li&gt;Keep the enrollment secret in the session until the user proves a working code,
then persist. Never flip 2FA on with a secret they might not have.&lt;/li&gt;
&lt;li&gt;The line that matters: &lt;strong&gt;don't &lt;code&gt;auth_login()&lt;/code&gt; until the second factor verifies.&lt;/strong&gt;
Between password and code, the user is anonymous holding a pending marker — not
logged in waiting to be asked nicely.&lt;/li&gt;
&lt;li&gt;Gate the &lt;em&gt;disable&lt;/em&gt; path behind a password re-entry, and watch for middleware that
intercepts the in-between verify step.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is the auth flow in a self-hosted AWS-vs-Terraform drift detector — 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;. Did you roll your&lt;br&gt;
own TOTP or reach for &lt;code&gt;django-two-factor-auth&lt;/code&gt; / &lt;code&gt;django-otp&lt;/code&gt;? Curious where people&lt;br&gt;
draw the build-vs-library line for auth specifically.&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Streaming a pg_restore through Python without deadlocking</title>
      <dc:creator>ひとし 田畑</dc:creator>
      <pubDate>Wed, 24 Jun 2026 10:39:21 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/streaming-a-pgrestore-through-python-without-deadlocking-2lje</link>
      <guid>https://dev.to/hitoshi1964/streaming-a-pgrestore-through-python-without-deadlocking-2lje</guid>
      <description>&lt;p&gt;You want to restore a database dump that arrived as an upload — a file object, maybe gigabytes — and you don't want to spool the whole thing to disk or load it into memory first. So you reach for the obvious thing: open &lt;code&gt;pg_restore&lt;/code&gt; with &lt;code&gt;subprocess&lt;/code&gt;, write the dump to its stdin in chunks, capture stderr so you can show the user a real error if it fails.&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;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popen&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;pg_restore&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;-d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# so we can report a real error
&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;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works on your test dump. It works in CI. Then someone restores a real database and the process &lt;strong&gt;hangs forever&lt;/strong&gt; — no error, no progress, both sides just... stop. Welcome to the pipe-buffer deadlock, one of the most reliable ways to wedge a &lt;code&gt;subprocess&lt;/code&gt; pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it hangs
&lt;/h2&gt;

&lt;p&gt;A pipe is a fixed-size OS buffer — classically around 64KB. When it fills up, whoever's writing to it &lt;strong&gt;blocks&lt;/strong&gt; until someone reads the other end.&lt;/p&gt;

&lt;p&gt;Now trace the standoff:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You're busy in a loop writing the dump to the child's &lt;strong&gt;stdin&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_restore&lt;/code&gt; is chewing through it and writing progress and warnings to &lt;strong&gt;stderr&lt;/strong&gt; — which you said you'd capture (&lt;code&gt;stderr=PIPE&lt;/code&gt;) but aren't reading yet, because you're stuck in step 1.&lt;/li&gt;
&lt;li&gt;The stderr pipe fills to 64KB. &lt;code&gt;pg_restore&lt;/code&gt; now &lt;strong&gt;blocks&lt;/strong&gt; on its next write to stderr.&lt;/li&gt;
&lt;li&gt;Blocked on stderr, &lt;code&gt;pg_restore&lt;/code&gt; stops reading its stdin.&lt;/li&gt;
&lt;li&gt;With nobody draining stdin, &lt;em&gt;your&lt;/em&gt; &lt;code&gt;stdin.write()&lt;/code&gt; fills its pipe and &lt;strong&gt;blocks&lt;/strong&gt; too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both processes are now asleep waiting for the other to move. Nothing times out. Nothing errors. It just hangs. And it only shows up on dumps big or chatty enough to overflow that one buffer — which is exactly why it sails through testing and dies in production.&lt;/p&gt;

&lt;p&gt;The usual advice is "use &lt;code&gt;communicate()&lt;/code&gt;," and &lt;code&gt;communicate()&lt;/code&gt; &lt;em&gt;does&lt;/em&gt; solve the deadlock — by reading stdout/stderr on threads for you. But it wants you to hand it the entire input at once, which buffers your whole multi-gigabyte dump in memory. That's the thing we were specifically trying not to do. The moment you want to &lt;strong&gt;stream&lt;/strong&gt; stdin &lt;em&gt;and&lt;/em&gt; capture stderr, you're back to handling the pipes yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: drain stderr on a thread
&lt;/h2&gt;

&lt;p&gt;You don't need a bigger buffer. You need someone reading stderr &lt;em&gt;the whole time&lt;/em&gt; you're writing stdin. So give that job to a thread:&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;err_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bytes&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;drainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;err_chunks&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;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;daemon&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;span class="n"&gt;drainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;stdin&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;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# stderr can never back up on us now
&lt;/span&gt;
&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RESTORE_TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;drainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&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;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;EngineError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pull_cause&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err_chunks&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The drainer reads stderr in a tight &lt;code&gt;read(8192)&lt;/code&gt;-until-&lt;code&gt;b""&lt;/code&gt; loop, so the child's stderr buffer never fills. Meanwhile the main thread streams stdin to its heart's content. The &lt;code&gt;with proc.stdin&lt;/code&gt; block closes stdin when the dump is exhausted — that EOF is how &lt;code&gt;pg_restore&lt;/code&gt; knows the input is done — and then we &lt;code&gt;wait()&lt;/code&gt;, join the drainer, and read the captured stderr only &lt;em&gt;after&lt;/em&gt; the tool has exited. No buffer, no deadlock, and we still get the real error message on failure.&lt;/p&gt;

&lt;p&gt;That's the entire trick. Everything below is the part that turns it from a snippet into something you'd trust with a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The details that bite
&lt;/h2&gt;

&lt;p&gt;This is roughly what &lt;a href="https://cli2ui.com" rel="noopener noreferrer"&gt;cli2ui&lt;/a&gt;'s streaming restore does, and the corners worth knowing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Peek the first bytes to pick the tool.&lt;/strong&gt; A custom-format archive starts with the &lt;code&gt;PGDMP&lt;/code&gt; marker and goes through &lt;code&gt;pg_restore&lt;/code&gt;; a plain SQL dump goes through &lt;code&gt;psql&lt;/code&gt;. So read the leading 5 bytes, decide, then write &lt;em&gt;those bytes back&lt;/em&gt; before the rest of the stream:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;  &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;is_custom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PGDMP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
  &lt;span class="bp"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;stdin&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;head&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# don't lose the bytes you peeked
&lt;/span&gt;      &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
          &lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&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;p&gt;&lt;strong&gt;Catch &lt;code&gt;BrokenPipeError&lt;/code&gt;.&lt;/strong&gt; If the tool hits an error and exits &lt;em&gt;while you're still writing&lt;/em&gt;, your next &lt;code&gt;stdin.write()&lt;/code&gt; raises &lt;code&gt;BrokenPipeError&lt;/code&gt;. That's not a bug to crash on — it means the child died early and the real reason is sitting in the stderr you drained. Catch it, &lt;code&gt;wait()&lt;/code&gt;, and report from &lt;code&gt;err_chunks&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make failure mean failure.&lt;/strong&gt; By default &lt;code&gt;pg_restore&lt;/code&gt; limps past errors and tells you a count at the end; &lt;code&gt;psql&lt;/code&gt; happily runs the next statement after a failed one. For a restore you want all-or-nothing, so: &lt;code&gt;pg_restore --exit-on-error&lt;/code&gt;, and for the SQL path &lt;code&gt;psql -v ON_ERROR_STOP=1 --single-transaction&lt;/code&gt; (the whole restore in one transaction — a failure leaves the database untouched).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Daemon thread + a join timeout.&lt;/strong&gt; The drainer is &lt;code&gt;daemon=True&lt;/code&gt; so a wedged reader can never keep your process alive, and &lt;code&gt;wait()&lt;/code&gt;/&lt;code&gt;join()&lt;/code&gt; both carry timeouts so a genuinely stuck tool gets killed instead of hanging your request forever — the very thing we set out to avoid.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The deadlock isn't really about Postgres — it's about pipes. Any time you stream into a child's stdin &lt;em&gt;and&lt;/em&gt; capture its stderr (or stdout), you've signed up to read the other end concurrently, or the OS buffer will eventually introduce the two of you to a deadlock. A thread is the boring, correct answer: one side writes, the other drains, and they meet at the &lt;code&gt;join&lt;/code&gt;.&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>python</category>
      <category>postgres</category>
      <category>devops</category>
    </item>
    <item>
      <title>The feature that wrote a database row every minute — even when nothing happened</title>
      <dc:creator>ひとし 田畑</dc:creator>
      <pubDate>Tue, 23 Jun 2026 10:33:40 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/the-feature-that-wrote-a-database-row-every-minute-even-when-nothing-happened-3e9c</link>
      <guid>https://dev.to/hitoshi1964/the-feature-that-wrote-a-database-row-every-minute-even-when-nothing-happened-3e9c</guid>
      <description>&lt;p&gt;My drift detector keeps a history so you can see a trend line: how much your AWS&lt;br&gt;
diverged from Terraform over time, run by run. To draw that line I record a&lt;br&gt;
&lt;code&gt;DriftSnapshot&lt;/code&gt; after every scan, every tfstate import, every S3 sync.&lt;/p&gt;

&lt;p&gt;Here's the part I didn't think through: I record one &lt;strong&gt;even when there's zero&lt;br&gt;
drift&lt;/strong&gt;. That's on purpose — a flat line is data too; "nothing drifted for three&lt;br&gt;
weeks" is exactly what you want the chart to show. But it also means a scheduler&lt;br&gt;
ticking every minute writes a row every minute, forever, whether or not anything&lt;br&gt;
changed. Append-only, no ceiling. A slow-motion time bomb in a table nobody looks at.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why I didn't notice for a while
&lt;/h2&gt;

&lt;p&gt;The history view caps what it &lt;em&gt;shows&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;snapshots&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;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&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the UI looked fine — a tidy 100 rows. Meanwhile the table underneath had tens&lt;br&gt;
of thousands. The display limit was quietly hiding the growth from the one place&lt;br&gt;
I'd have spotted it. The lesson that stuck: &lt;strong&gt;a &lt;code&gt;LIMIT&lt;/code&gt; in your read path is not&lt;br&gt;
retention.&lt;/strong&gt; It bounds the query, not the data.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix has to live where the rows are born
&lt;/h2&gt;

&lt;p&gt;My first instinct was a nightly cron that trims old rows. That works, but it&lt;br&gt;
leaves the table unbounded &lt;em&gt;between&lt;/em&gt; runs, and it's one more moving part to forget.&lt;br&gt;
The rows all come from a single function — every scan/import/sync funnels through&lt;br&gt;
the same &lt;code&gt;_record_drift_snapshot()&lt;/code&gt;. So the cleanest place to enforce a ceiling is&lt;br&gt;
right there, at the moment of creation:&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;snapshot&lt;/span&gt; &lt;span class="o"&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;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;changed_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;added_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;added&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;unchanged_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;unchanged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;detail&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;changed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;added&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;added&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# we append a row every run (even at zero drift) — trim the oldest beyond the cap
&lt;/span&gt;&lt;span class="n"&gt;DriftSnapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prune&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;environment&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;snapshot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One chokepoint, so the table is bounded &lt;em&gt;continuously&lt;/em&gt; — not "eventually, when the&lt;br&gt;
cron next fires."&lt;/p&gt;
&lt;h2&gt;
  
  
  Keeping the newest N, per environment
&lt;/h2&gt;

&lt;p&gt;The prune itself is two queries. Find the PKs of the newest N you want to keep,&lt;br&gt;
then delete everything else for that environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@classmethod&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prune&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="o"&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="n"&gt;keep&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="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="n"&gt;keep&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;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_SNAPSHOT_RETENTION&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;  &lt;span class="c1"&gt;# 0 / unset = unlimited, opt out cleanly
&lt;/span&gt;
    &lt;span class="n"&gt;keep_ids&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;cls&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&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;environment&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;-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_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pk&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flat&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;keep&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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;cls&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&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk__in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;keep_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I'd flag if you copy this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope by &lt;code&gt;environment&lt;/code&gt;.&lt;/strong&gt; The cap is "newest N &lt;em&gt;per environment&lt;/em&gt;," not N total.
A global &lt;code&gt;[:keep]&lt;/code&gt; would let a busy prod environment evict a quiet staging one's
entire history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.values_list('pk', ...)[:keep]&lt;/code&gt; then &lt;code&gt;.exclude(pk__in=...)&lt;/code&gt;.&lt;/strong&gt; The slice
becomes a &lt;code&gt;LIMIT&lt;/code&gt; in SQL, so you never pull thousands of rows into Python just to
decide what to delete. For per-write pruning where &lt;code&gt;keep&lt;/code&gt; is small, that matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cap is a setting, defaulting to something sane, with &lt;code&gt;&amp;lt;= 0&lt;/code&gt; meaning "unlimited"&lt;br&gt;
so anyone who actually wants the full append-only log can opt out:&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_SNAPSHOT_RETENTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&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_SNAPSHOT_RETENTION&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;500&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&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;h2&gt;
  
  
  Don't forget the rows you already have
&lt;/h2&gt;

&lt;p&gt;The chokepoint guard fixes &lt;em&gt;new&lt;/em&gt; growth. It does nothing for the table that's&lt;br&gt;
already bloated — those old rows were written before the cap existed and will sit&lt;br&gt;
there until something deletes them. So I added a management command to backfill the&lt;br&gt;
cleanup across every environment, with a dry run because deleting history deserves a&lt;br&gt;
look-before-you-leap:&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="nv"&gt;$ &lt;/span&gt;python manage.py prune_drift_snapshots &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
prod:    would delete 41,902 &lt;span class="o"&gt;(&lt;/span&gt;of 42,402&lt;span class="o"&gt;)&lt;/span&gt;
staging: would delete 0 &lt;span class="o"&gt;(&lt;/span&gt;of 88&lt;span class="o"&gt;)&lt;/span&gt;
Done: would delete 41,902 snapshot&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;, keeping 500 per environment.

&lt;span class="nv"&gt;$ &lt;/span&gt;python manage.py prune_drift_snapshots
prod: deleted 41,902 &lt;span class="o"&gt;(&lt;/span&gt;kept 500&lt;span class="o"&gt;)&lt;/span&gt;
Done: deleted 41,902 snapshot&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;, keeping 500 per environment.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;prune()&lt;/code&gt; underneath, just iterated over all environments — the per-write&lt;br&gt;
guard and the bulk cleanup share one implementation.&lt;/p&gt;

&lt;p&gt;One nice side effect: &lt;strong&gt;none of this needed a migration.&lt;/strong&gt; It's a classmethod and a&lt;br&gt;
call site, not a schema change. The existing rows are fine; they just stop being&lt;br&gt;
immortal.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;An "append-only history" feature that records &lt;strong&gt;even when nothing changed&lt;/strong&gt; is
unbounded by design. Decide its retention the day you build it, not the day the
disk fills.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;LIMIT&lt;/code&gt;/&lt;code&gt;[:N]&lt;/code&gt; in your &lt;em&gt;read&lt;/em&gt; path bounds the query, not the table. It will
happily hide unbounded growth from your own UI.&lt;/li&gt;
&lt;li&gt;Enforce the ceiling at the single point where rows are created, so the table is
bounded continuously — not at a periodic job that leaves gaps.&lt;/li&gt;
&lt;li&gt;Ship a separate cleanup command for the rows that accumulated before the cap, and
give it a &lt;code&gt;--dry-run&lt;/code&gt;. New-growth guard and historical backfill aren't the same job.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This is the drift-history view in a self-hosted tool that tracks how your live AWS&lt;br&gt;
drifts from Terraform over time — 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;. How do you handle append-only tables that exist&lt;br&gt;
to be charted — hard cap, time-based TTL, or roll-ups into a summary table?&lt;/p&gt;

</description>
      <category>django</category>
      <category>postgres</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Try a Postgres index without HypoPG: CREATE INDEX in a transaction you roll back</title>
      <dc:creator>ひとし 田畑</dc:creator>
      <pubDate>Tue, 23 Jun 2026 10:29:32 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/try-a-postgres-index-without-hypopg-create-index-in-a-transaction-you-roll-back-2bko</link>
      <guid>https://dev.to/hitoshi1964/try-a-postgres-index-without-hypopg-create-index-in-a-transaction-you-roll-back-2bko</guid>
      <description>&lt;p&gt;You have a slow query and a hunch: &lt;em&gt;an index on &lt;code&gt;(customer_id, created_at)&lt;/code&gt; would fix this.&lt;/em&gt; But you're not going to &lt;code&gt;CREATE INDEX&lt;/code&gt; on production to find out — that takes a lock, does real work, and now you own an index you may not even want.&lt;/p&gt;

&lt;p&gt;The usual "proper" answer is &lt;strong&gt;HypoPG&lt;/strong&gt;, an extension that fakes a hypothetical index so the planner &lt;em&gt;thinks&lt;/em&gt; it exists. It's great — when you can install it. On managed Postgres, or a prod box you don't own, or a teammate's machine, you often can't. And because the index is fake, HypoPG tells you &lt;em&gt;the planner would use it&lt;/em&gt; but never &lt;em&gt;how much faster the query actually runs&lt;/em&gt; — there's no real index to execute against.&lt;/p&gt;

&lt;p&gt;There's a lower-tech trick that needs no extension and gives you &lt;strong&gt;real measured timing&lt;/strong&gt;: build the index for real, inside a transaction you throw away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- EXPLAIN ANALYZE actually runs the query — this is the "before" timing.&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FORMAT&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- A real index, but visible only inside this transaction.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;_trial_idx&lt;/span&gt; &lt;span class="k"&gt;ON&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;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Same query again — the planner can now see the index and may pick it.&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FORMAT&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ROLLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- the index is never committed; it vanishes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres lets a &lt;code&gt;CREATE INDEX&lt;/code&gt; live inside a transaction, and DDL is transactional: other sessions never see the index, and &lt;code&gt;ROLLBACK&lt;/code&gt; drops it. Between the two &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; runs you get the real before/after — same plan tree, same &lt;em&gt;actual&lt;/em&gt; timing — and you can check the thing you actually wanted to know: did the planner &lt;strong&gt;choose&lt;/strong&gt; your index, or ignore it?&lt;/p&gt;

&lt;p&gt;That last part is the honest answer. Read the after-plan for an Index Scan on &lt;code&gt;_trial_idx&lt;/code&gt;. If it's still a Seq Scan, the index wouldn't have helped — and you learned that for free, in a transaction, on whatever database you happened to have in front of you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one gotcha: CONCURRENTLY
&lt;/h2&gt;

&lt;p&gt;You can't run &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; inside a transaction — Postgres forbids it. And CONCURRENTLY is exactly what you'd want &lt;strong&gt;in production&lt;/strong&gt;, because it builds the index without holding a write lock on the table. So there's a split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inside the throwaway transaction&lt;/strong&gt;, build a &lt;em&gt;plain&lt;/em&gt; &lt;code&gt;CREATE INDEX&lt;/code&gt; (non-concurrent). It briefly takes a stronger lock, but it's your own short-lived session and it's gone on rollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The statement you'd actually run for real&lt;/strong&gt; should be &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is exactly what &lt;a href="https://cli2ui.com" rel="noopener noreferrer"&gt;cli2ui&lt;/a&gt;'s "index lab" does. It builds a plain throwaway index to measure against, but the DDL it &lt;em&gt;shows you to copy&lt;/em&gt; is the CONCURRENTLY version:&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;with&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;whatif_cursor&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# autocommit=False, ALWAYS rolls back
&lt;/span&gt;    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# before
&lt;/span&gt;    &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hypo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# plain CREATE INDEX, lives in this tx
&lt;/span&gt;    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;explain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# after
&lt;/span&gt;    &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;ddl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;real&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# the CONCURRENTLY version, for display
# rollback happens here — the trial index never persists
&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uses_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_trial_idx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# did the planner actually pick it?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;whatif_cursor&lt;/code&gt; is a single primitive: a transaction that &lt;strong&gt;always&lt;/strong&gt; rolls back (in a &lt;code&gt;finally&lt;/code&gt;), with &lt;code&gt;statement_timeout&lt;/code&gt; and &lt;code&gt;lock_timeout&lt;/code&gt; pre-set so a trial can't hang or sit on a catalog/table lock.&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;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;autocommit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET LOCAL statement_timeout = %s&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;timeout_ms&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET LOCAL lock_timeout = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2s&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="n"&gt;cur&lt;/span&gt;
&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# the what-if is never persisted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A real &lt;code&gt;CREATE INDEX&lt;/code&gt; does real work.&lt;/strong&gt; Unlike HypoPG, you're actually building the index — on a big table that's slow, and that's the price of getting &lt;em&gt;real&lt;/em&gt; timing instead of an estimate. For a huge production table, HypoPG's fake index is the better tool; for "I have this table right here, will the index help," this is the faster thing to reach for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; executes the query.&lt;/strong&gt; It runs inside the rolled-back transaction, so writes wouldn't persist either — but it really runs, so don't aim it at a ten-minute query without a &lt;code&gt;statement_timeout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The plain build takes a brief lock.&lt;/strong&gt; A non-concurrent &lt;code&gt;CREATE INDEX&lt;/code&gt; locks the table against writes for the duration. In your own short-lived transaction on a dev/staging box that's fine; it's precisely why the &lt;em&gt;production&lt;/em&gt; DDL is CONCURRENTLY.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's gone on rollback&lt;/strong&gt; — which is the entire point. Nothing to clean up, nothing left behind on prod, nothing to forget about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing you actually wanted — &lt;em&gt;would this index change the plan, and by how much&lt;/em&gt; — answered on the database in front of you, with no extension to install and nothing to undo afterward.&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>postgres</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Your Django background scheduler is probably running twice. Here's why, and the migrate chicken-and-egg.</title>
      <dc:creator>ひとし 田畑</dc:creator>
      <pubDate>Mon, 22 Jun 2026 11:40:12 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/your-django-background-scheduler-is-probably-running-twice-heres-why-and-the-migrate-453c</link>
      <guid>https://dev.to/hitoshi1964/your-django-background-scheduler-is-probably-running-twice-heres-why-and-the-migrate-453c</guid>
      <description>&lt;p&gt;I needed periodic background work in a Django app — scan AWS every N minutes, sync&lt;br&gt;
remote tfstate, refresh data daily. No Celery, no Redis, no extra moving parts for a&lt;br&gt;
one-person ops tool. &lt;code&gt;django-apscheduler&lt;/code&gt; with a &lt;code&gt;BackgroundScheduler&lt;/code&gt; started in&lt;br&gt;
&lt;code&gt;AppConfig.ready()&lt;/code&gt; is perfect for that scale.&lt;/p&gt;

&lt;p&gt;And then it ran every job twice. Then it crashed on a fresh database. Both have&lt;br&gt;
boring, specific causes that every "apscheduler in Django" tutorial skips, so here&lt;br&gt;
they are.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gotcha #1: &lt;code&gt;runserver&lt;/code&gt; starts your app twice
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AppConfig.ready()&lt;/code&gt; feels like "run once at startup." Under &lt;code&gt;runserver&lt;/code&gt; it isn't —&lt;br&gt;
the autoreloader forks a &lt;strong&gt;parent watcher&lt;/strong&gt; and a &lt;strong&gt;child&lt;/strong&gt; that actually serves, and&lt;br&gt;
&lt;code&gt;ready()&lt;/code&gt; runs in &lt;em&gt;both&lt;/em&gt;. Start your scheduler there naively and you get two&lt;br&gt;
schedulers, every job fires twice.&lt;/p&gt;

&lt;p&gt;Django leaves you a tell: the child process has &lt;code&gt;RUN_MAIN=true&lt;/code&gt;. So the scheduler&lt;br&gt;
only starts in the child:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;runserver&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;RUN_MAIN&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;true&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotcha #2: it also starts during &lt;code&gt;migrate&lt;/code&gt;, &lt;code&gt;test&lt;/code&gt;, and &lt;code&gt;shell&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ready()&lt;/code&gt; runs for &lt;strong&gt;every&lt;/strong&gt; management command. &lt;code&gt;python manage.py migrate&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;collectstatic&lt;/code&gt;, &lt;code&gt;shell&lt;/code&gt;, and your test run all import the app, all fire &lt;code&gt;ready()&lt;/code&gt;,&lt;br&gt;
all happily spin up a background thread pool you never wanted. During tests it&lt;br&gt;
pollutes state and leaks threads; during &lt;code&gt;migrate&lt;/code&gt; it's actively dangerous (more on&lt;br&gt;
that below).&lt;/p&gt;

&lt;p&gt;So the real entry point isn't "start the scheduler," it's "should I even be allowed&lt;br&gt;
to?" — a guard that inspects how the process was launched:&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;_NO_SCHEDULER_COMMANDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;migrate&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;makemigrations&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;collectstatic&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;shell&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;dbshell&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;check&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;createsuperuser&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;flush&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;loaddata&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;dumpdata&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&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;should_start_scheduler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pytest&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modules&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;test&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;# never in tests
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_NO_SCHEDULER_COMMANDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;# never during one-off mgmt commands
&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;runserver&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;RUN_MAIN&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;true&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;# child only
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;                            &lt;span class="c1"&gt;# gunicorn / prod
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ready()&lt;/code&gt; calls this &lt;em&gt;before&lt;/em&gt; touching apscheduler. The principle: a process that&lt;br&gt;
exists to run a migration or a shell has no business launching a recurring job&lt;br&gt;
engine.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gotcha #3: the migrate chicken-and-egg
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;django-apscheduler&lt;/code&gt; persists jobs in a database table (&lt;code&gt;DjangoJobStore&lt;/code&gt;). But that&lt;br&gt;
table is created &lt;em&gt;by a migration&lt;/em&gt; — and on a brand-new database, &lt;code&gt;ready()&lt;/code&gt; runs&lt;br&gt;
before you've migrated. So the scheduler tries to register a job, queries a table&lt;br&gt;
that doesn't exist yet, and the whole app faceplants on first boot. The &lt;code&gt;migrate&lt;/code&gt;&lt;br&gt;
guard above helps, but a fresh &lt;code&gt;runserver&lt;/code&gt; before migrating still hits it.&lt;/p&gt;

&lt;p&gt;The fix is to look before you leap:&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;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;connection&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;django_apscheduler_djangojob&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;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;introspection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table_names&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;Scheduler skipped: tables not found — run migrate first.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If my own jobstore tables aren't there yet, don't start; log it and move on. The&lt;br&gt;
next boot after &lt;code&gt;migrate&lt;/code&gt; starts cleanly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Don't let a slow tick stack up
&lt;/h2&gt;

&lt;p&gt;Even with a single scheduler, a job that runs every minute but occasionally takes&lt;br&gt;
&lt;em&gt;longer&lt;/em&gt; than a minute will pile up overlapping runs. apscheduler has the knobs;&lt;br&gt;
the job just has to ask:&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;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;_tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IntervalTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&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="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;boto3_scan_tick&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="c1"&gt;# never two ticks at once
&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="c1"&gt;# missed runs collapse into one, not a backlog burst
&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="c1"&gt;# re-registering replaces, doesn't duplicate
&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;max_instances=1&lt;/code&gt; + &lt;code&gt;coalesce=True&lt;/code&gt; means a scan that overruns just delays the next&lt;br&gt;
tick instead of running concurrently with itself — which matters a lot when the tick&lt;br&gt;
makes AWS API calls and writes rows.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AppConfig.ready()&lt;/code&gt; is not "once at startup." Under &lt;code&gt;runserver&lt;/code&gt; it runs in both the
reloader parent and child — gate the scheduler on &lt;code&gt;RUN_MAIN == 'true'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ready()&lt;/code&gt; also runs for &lt;code&gt;migrate&lt;/code&gt;/&lt;code&gt;test&lt;/code&gt;/&lt;code&gt;shell&lt;/code&gt;. Guard with a &lt;code&gt;should_start_scheduler()&lt;/code&gt;
that inspects &lt;code&gt;sys.argv&lt;/code&gt; / &lt;code&gt;sys.modules&lt;/code&gt;, so one-off commands don't spin up jobs.&lt;/li&gt;
&lt;li&gt;If your jobstore lives in the DB, check the table exists before registering jobs —
otherwise a fresh, un-migrated database crashes on boot.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;max_instances=1&lt;/code&gt; + &lt;code&gt;coalesce=True&lt;/code&gt; so a slow tick delays the next run instead
of stacking overlapping ones.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This scheduler drives the periodic AWS drift scans in a self-hosted tool that&lt;br&gt;
reconciles tfstate against live AWS — no Celery, no broker, just apscheduler in the&lt;br&gt;
web process. Open source (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;.&lt;br&gt;
What's your Django background-job setup — apscheduler, Celery beat, or an external&lt;br&gt;
cron hitting a management command?&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Diffing two EXPLAIN plans as trees, not text</title>
      <dc:creator>ひとし 田畑</dc:creator>
      <pubDate>Fri, 19 Jun 2026 10:50:17 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/diffing-two-explain-plans-as-trees-not-text-3iif</link>
      <guid>https://dev.to/hitoshi1964/diffing-two-explain-plans-as-trees-not-text-3iif</guid>
      <description>&lt;p&gt;You added an index, re-ran &lt;code&gt;EXPLAIN&lt;/code&gt;, and now you want the obvious answer: &lt;em&gt;did the plan actually change?&lt;/em&gt; So you diff the two outputs as text. And it lights up red everywhere — because &lt;code&gt;cost=&lt;/code&gt; and &lt;code&gt;rows=&lt;/code&gt; shift on nearly every line, even when the strategy is identical. The one line you cared about — &lt;code&gt;Seq Scan on orders&lt;/code&gt; becoming &lt;code&gt;Index Scan using orders_created_idx on orders&lt;/code&gt; — is buried in a wall of numeric churn.&lt;/p&gt;

&lt;p&gt;Text is the wrong representation. An EXPLAIN plan is a &lt;strong&gt;tree&lt;/strong&gt;, and the question "what changed?" is a tree-diff, not a string-diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the tree, not the text
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN (FORMAT JSON)&lt;/code&gt; hands you the plan as structured data instead of pretty-printed lines. Parse it into a small node type — node kind, the table, estimated rows, cost, and children:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlanNode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;node_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;        &lt;span class="c1"&gt;# "Seq Scan", "Hash Join", "Sort", …
&lt;/span&gt;    &lt;span class="n"&gt;relation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# the table, if it's a scan
&lt;/span&gt;    &lt;span class="n"&gt;plan_rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;total_cost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PlanNode&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;Now you have two trees, &lt;em&gt;before&lt;/em&gt; and &lt;em&gt;after&lt;/em&gt;. The naive diff is to walk both in lockstep and compare node &lt;code&gt;i&lt;/code&gt; to node &lt;code&gt;i&lt;/code&gt;. That breaks the instant the planner inserts a node: add one &lt;code&gt;Sort&lt;/code&gt; or a parallel &lt;code&gt;Gather&lt;/code&gt; near the top and every node below it shifts position. A positional diff then reports the entire subtree as changed, which is exactly the noise you were trying to escape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Align first, then classify
&lt;/h2&gt;

&lt;p&gt;The fix is to &lt;strong&gt;align&lt;/strong&gt; the two node sequences before comparing them — the same job &lt;code&gt;git diff&lt;/code&gt; does on lines. Flatten each tree to a list, then let stdlib's &lt;code&gt;difflib.SequenceMatcher&lt;/code&gt; find the longest matching run and the insertions/deletions around it.&lt;/p&gt;

&lt;p&gt;The trick that makes the output readable is &lt;em&gt;what you align on&lt;/em&gt;. If you align on the full node label, a &lt;code&gt;Seq Scan on orders&lt;/code&gt; and an &lt;code&gt;Index Scan using … on orders&lt;/code&gt; look like two different things — you get a delete &lt;strong&gt;and&lt;/strong&gt; an add, when what actually happened is one node changed its mind. So align on a &lt;strong&gt;shape key&lt;/strong&gt; that deliberately ignores the access method:&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;_SCANS&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;Seq Scan&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;Index Scan&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;Index Only Scan&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;Bitmap Heap Scan&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;Bitmap Index Scan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;_JOINS&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;Nested Loop&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;Hash Join&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;Merge Join&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;shape_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Any scan on `orders` keys the same, whatever the method.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;node_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_SCANS&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;node_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relation&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;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;node_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_JOINS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;join&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;                  &lt;span class="c1"&gt;# any join keys the same
&lt;/span&gt;    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;node_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this key, &lt;code&gt;Seq Scan on orders&lt;/code&gt; and &lt;code&gt;Index Scan on orders&lt;/code&gt; both key to &lt;code&gt;("scan", "orders")&lt;/code&gt;, so they &lt;strong&gt;align into one pair&lt;/strong&gt;. Then you compare the real labels and mark that pair &lt;code&gt;changed&lt;/code&gt; — the flip you wanted, as a single row. A genuinely new node (a &lt;code&gt;Sort&lt;/code&gt; that wasn't there before) has no partner and falls out as &lt;code&gt;added&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The alignment itself is a dozen lines:&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;diff_plans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;keys_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;shape_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fa&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;keys_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;shape_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;sm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;difflib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SequenceMatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;keys_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;keys_b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autojunk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j2&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_opcodes&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;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;equal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;               &lt;span class="c1"&gt;# same shape: changed-label or truly same
&lt;/span&gt;            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;di&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j2&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="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;di&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dj&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&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="nf"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;di&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;di&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i2&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&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="nf"&gt;added&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dj&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;dj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j2&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&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="nf"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fa&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;di&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;di&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i2&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&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="nf"&gt;added&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dj&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;dj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j2&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;rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every node ends up in one of four buckets — &lt;strong&gt;same / changed / added / removed&lt;/strong&gt; — and the strategy flips you actually care about stop hiding behind shifting cost numbers.&lt;/p&gt;

&lt;p&gt;One more deliberate choice: &lt;strong&gt;don't put tree depth in the shape key.&lt;/strong&gt; Inserting a wrapper node bumps the depth of everything beneath it; if depth were part of the key, that one insertion would re-diff the whole subtree. Leaving depth out lets the sequence alignment keep the rest paired up, and you still carry depth along just for indentation when you render.&lt;/p&gt;

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

&lt;p&gt;Shape-key alignment is a &lt;strong&gt;heuristic for human eyeballing, not a proof.&lt;/strong&gt; A self-join — two scans of the same table — keys identically, so the two can mis-pair; difflib aligns by sequence order, so an unusual reordering can put the wrong nodes together. It's right the vast majority of the time and it makes "what changed?" legible in a glance, but if it ever looks wrong, fall back to the full node detail. And remember &lt;code&gt;plan_rows&lt;/code&gt;/&lt;code&gt;total_cost&lt;/code&gt; are estimates — only &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; gives you real counts and timings to diff.&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;. The plan-tree diff above is the real &lt;code&gt;plan_diff.py&lt;/code&gt; it uses for "before vs after an index." 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>postgres</category>
      <category>database</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>Your runtimes have an expiry date. I baked the EOL calendar into the app so it works offline.</title>
      <dc:creator>ひとし 田畑</dc:creator>
      <pubDate>Fri, 19 Jun 2026 10:48:27 +0000</pubDate>
      <link>https://dev.to/hitoshi1964/your-runtimes-have-an-expiry-date-i-baked-the-eol-calendar-into-the-app-so-it-works-offline-1jj3</link>
      <guid>https://dev.to/hitoshi1964/your-runtimes-have-an-expiry-date-i-baked-the-eol-calendar-into-the-app-so-it-works-offline-1jj3</guid>
      <description>&lt;p&gt;Every runtime and piece of middleware you run has a quiet expiry date. PHP 8.0&lt;br&gt;
went EOL in late 2023. Node 16 in 2023. Postgres 12 in 2024. After that: no&lt;br&gt;
security patches. The problem is never "we didn't know EOL was a thing" — it's that&lt;br&gt;
the EOL calendar lives in a dozen vendor pages and nobody checks them until an&lt;br&gt;
auditor does.&lt;/p&gt;

&lt;p&gt;I wanted my self-hosted ops tool to flag this automatically. The obvious move is to&lt;br&gt;
call an API like &lt;a href="https://endoflife.date" rel="noopener noreferrer"&gt;endoflife.date&lt;/a&gt;. But I made the default&lt;br&gt;
&lt;strong&gt;offline&lt;/strong&gt;: the EOL calendar is baked into the app, and the external fetch is&lt;br&gt;
strictly opt-in. Here's why, and the part that was actually fiddly — version&lt;br&gt;
matching.&lt;/p&gt;
&lt;h2&gt;
  
  
  Offline by default: a self-hosted tool shouldn't phone home
&lt;/h2&gt;

&lt;p&gt;If you ship a tool people run inside their own infra, "calls an external API on&lt;br&gt;
startup" is a liability, not a feature. Air-gapped environments break. Security&lt;br&gt;
reviewers ask why your inventory tool is making outbound requests. So the EOL data&lt;br&gt;
is a plain dict compiled into the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# {canonical_name: {cycle: eol_date_or_None}}   None means "actively supported"
&lt;/span&gt;&lt;span class="n"&gt;_EOL&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;python&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;3.7&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2023&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.9&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.11&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.12&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="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;postgresql&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;12&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;13&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;16&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="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# redis, php, nodejs, nginx, mysql, ruby, go, java, ...
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works the moment you &lt;code&gt;docker compose up&lt;/code&gt;, no network required. If you &lt;em&gt;want&lt;/em&gt;&lt;br&gt;
fresher data, you flip &lt;code&gt;EOL_REFRESH_ENABLED=true&lt;/code&gt; and a daily job pulls from&lt;br&gt;
endoflife.date and &lt;strong&gt;overlays&lt;/strong&gt; the result on top of the baked-in dict:&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;_effective&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;eff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&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;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_EOL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;   &lt;span class="c1"&gt;# start from the offline baseline
&lt;/span&gt;    &lt;span class="n"&gt;snap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load_snapshot_data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                  &lt;span class="c1"&gt;# latest fetched snapshot, if any
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;snap&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;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cycles&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;eff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cycles&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;eff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Offline data is the floor; the network is an enhancement, never a dependency. (The&lt;br&gt;
fetch side validates the product slug against a regex and only ever talks https to&lt;br&gt;
endoflife.date — an inventory tool making arbitrary outbound requests is exactly the&lt;br&gt;
SSRF footgun you don't want to ship.)&lt;/p&gt;
&lt;h2&gt;
  
  
  The fiddly part: matching a version string to a cycle
&lt;/h2&gt;

&lt;p&gt;Here's where it stops being a lookup table. A dependency reports its version as a&lt;br&gt;
string — &lt;code&gt;"3.11.4"&lt;/code&gt;, &lt;code&gt;"8"&lt;/code&gt;, &lt;code&gt;"1.24.2"&lt;/code&gt; — but the EOL calendar is keyed by &lt;em&gt;release&lt;br&gt;
cycle&lt;/em&gt;: &lt;code&gt;"3.11"&lt;/code&gt; for Python, &lt;code&gt;"8"&lt;/code&gt; for MySQL. You can't just &lt;code&gt;dict[version]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rule that works: &lt;strong&gt;try the longest cycle key first (major.minor), then fall back&lt;br&gt;
to major only.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n_parts&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                      &lt;span class="c1"&gt;# "3.11.4" → try "3.11", then "3"
&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;_major_minor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_parts&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;cycle&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;cycles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;eol_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_parse_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cycles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cycle&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;eol_date&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="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;                         &lt;span class="c1"&gt;# cycle exists, no EOL date = supported
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;eol_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;eol&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;eol_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;warning&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;                    &lt;span class="c1"&gt;# within 6 months
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python is tracked at &lt;code&gt;major.minor&lt;/code&gt; (3.11 ≠ 3.12), but MySQL &lt;code&gt;"8.0.36"&lt;/code&gt; should match&lt;br&gt;
the &lt;code&gt;"8.0"&lt;/code&gt; cycle and Node &lt;code&gt;"20.11.1"&lt;/code&gt; should match &lt;code&gt;"20"&lt;/code&gt;. Longest-match-first gets&lt;br&gt;
both right without per-product special-casing. The four states — &lt;code&gt;eol&lt;/code&gt; / &lt;code&gt;warning&lt;/code&gt; /&lt;br&gt;
&lt;code&gt;ok&lt;/code&gt; / &lt;code&gt;unknown&lt;/code&gt; — matter too: &lt;strong&gt;&lt;code&gt;unknown&lt;/code&gt; is not &lt;code&gt;ok&lt;/code&gt;.&lt;/strong&gt; "I have no data for this"&lt;br&gt;
and "this is supported" are different answers, and collapsing them is how you ship a&lt;br&gt;
green dashboard that's quietly lying.&lt;/p&gt;
&lt;h2&gt;
  
  
  The "this has no EOL" case
&lt;/h2&gt;

&lt;p&gt;Not everything &lt;em&gt;has&lt;/em&gt; an end-of-life. Gunicorn, Celery, Composer — there's no vendor&lt;br&gt;
EOL cycle to track. Naively those show up as &lt;code&gt;unknown&lt;/code&gt; forever, which is noise. The&lt;br&gt;
trick is an alias table that can map a name to &lt;code&gt;None&lt;/code&gt; meaning "no EOL concept":&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;_ALIASES&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;node&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;nodejs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# normalize spelling
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;postgres&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;postgresql&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;gunicorn&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="c1"&gt;# explicitly: no formal EOL — don't nag about it
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;celery&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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;_ALIASES&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="nf"&gt;_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&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;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;canonical()&lt;/code&gt; does double duty: it normalizes spellings (&lt;code&gt;node&lt;/code&gt; → &lt;code&gt;nodejs&lt;/code&gt;) &lt;em&gt;and&lt;/em&gt;&lt;br&gt;
encodes "we deliberately don't track this" as &lt;code&gt;None&lt;/code&gt;, which short-circuits to&lt;br&gt;
&lt;code&gt;unknown&lt;/code&gt; and keeps it out of the warnings. Suppressing a check on purpose is a&lt;br&gt;
feature; the alias table is where that intent lives.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;For a self-hosted tool, make EOL data &lt;strong&gt;offline-first&lt;/strong&gt;: bake in a baseline dict,
make the external refresh opt-in, and &lt;em&gt;overlay&lt;/em&gt; fetched data rather than depend on
it. It runs air-gapped and doesn't surprise a security reviewer.&lt;/li&gt;
&lt;li&gt;Don't index the EOL table by raw version. Match &lt;strong&gt;longest cycle first&lt;/strong&gt;
(major.minor → major) so &lt;code&gt;3.11.4&lt;/code&gt;, &lt;code&gt;8.0.36&lt;/code&gt;, and &lt;code&gt;20.11.1&lt;/code&gt; all land on the right
cycle.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;unknown&lt;/code&gt; distinct from &lt;code&gt;ok&lt;/code&gt;, and give yourself a way to say "this has no EOL"
on purpose so the signal stays clean.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This EOL check is one layer of a self-hosted tool that inventories your AWS assets&lt;br&gt;
&lt;em&gt;and&lt;/em&gt; the apps/middleware running on each environment, then flags drift and aging&lt;br&gt;
runtimes. Open source (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;.&lt;br&gt;
How do you track middleware/runtime EOL today — a spreadsheet, Dependabot, or hope?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>python</category>
      <category>security</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
