<?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: Kazu</title>
    <description>The latest articles on DEV Community by Kazu (@shinagawa-web).</description>
    <link>https://dev.to/shinagawa-web</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%2F3422958%2F561728f2-3289-4079-9cba-cc1c855c8b68.png</url>
      <title>DEV Community: Kazu</title>
      <link>https://dev.to/shinagawa-web</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shinagawa-web"/>
    <language>en</language>
    <item>
      <title>"nginx doesn't run your config top to bottom: two traps with rewrite, try_files, and if"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-doesnt-run-your-config-top-to-bottom-two-traps-with-rewrite-tryfiles-and-if-2hlm</link>
      <guid>https://dev.to/shinagawa-web/nginx-doesnt-run-your-config-top-to-bottom-two-traps-with-rewrite-tryfiles-and-if-2hlm</guid>
      <description>&lt;p&gt;You write a &lt;code&gt;rewrite&lt;/code&gt;, then a &lt;code&gt;try_files&lt;/code&gt; right under it, and you read it as "the rewrite on top runs first, the try_files below runs after." You put an &lt;code&gt;if&lt;/code&gt; inside a &lt;code&gt;location&lt;/code&gt; and read it as "when the condition is true, this stuff gets added to the outer config." Both readings trace the config file straight down, line by line, in the order it's written. And both have burned me.&lt;/p&gt;

&lt;p&gt;Let me show you the two symptoms first. In each case the config reads cleanly, yet the behavior doesn't line up. Why that happens — the order in which nginx processes a request — is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 1: rewrite and try_files side by side, and you get a 404
&lt;/h2&gt;

&lt;p&gt;In some location you want to rewrite the request once, then fall back to a static file. The naive version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&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 intent is "rewrite &lt;code&gt;/app/foo&lt;/code&gt; to &lt;code&gt;/public/foo&lt;/code&gt;, serve the file if it exists, 404 otherwise." Since &lt;code&gt;rewrite&lt;/code&gt; is on top and &lt;code&gt;try_files&lt;/code&gt; is below, the rewrite takes effect first and then try_files handles the fallback — that's how it reads.&lt;/p&gt;

&lt;p&gt;But throw &lt;code&gt;/app/logo.png&lt;/code&gt; at it and you get a 404, even when &lt;code&gt;/public/logo.png&lt;/code&gt; really exists.&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;&lt;span class="nb"&gt;ls&lt;/span&gt; /public/logo.png
/public/logo.png            &lt;span class="c"&gt;# the file is definitely there&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://localhost/app/logo.png
HTTP/1.1 404 Not Found      &lt;span class="c"&gt;# and yet, 404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try_files&lt;/code&gt; in this location is never evaluated once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 2: config placed inside an &lt;code&gt;if&lt;/code&gt; block doesn't take effect
&lt;/h2&gt;

&lt;p&gt;In another location you want to add one header conditionally. The naive version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/download/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Served-By&lt;/span&gt; &lt;span class="s"&gt;"nginx"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$arg_debug&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Debug&lt;/span&gt; &lt;span class="s"&gt;"on"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# only want this when ?debug=1&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&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 intent is "always add &lt;code&gt;X-Served-By&lt;/code&gt;, and add &lt;code&gt;X-Debug&lt;/code&gt; on top of it only when &lt;code&gt;?debug=1&lt;/code&gt;." But hit &lt;code&gt;/download/foo?debug=1&lt;/code&gt; and &lt;code&gt;X-Debug&lt;/code&gt; shows up while &lt;code&gt;X-Served-By&lt;/code&gt;, which you set on the outside, has vanished. The moment the condition goes true, part of the outer config drops.&lt;/p&gt;

&lt;p&gt;Both configs read cleanly when you scan the lines top to bottom. And both miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works: nginx's HTTP processing phases
&lt;/h2&gt;

&lt;p&gt;nginx runs each request through a fixed sequence of processing phases. Which phase a given directive runs in is determined per directive type, and has nothing to do with where the line sits in the config file. Both symptoms come out of exactly this one fact.&lt;/p&gt;

&lt;p&gt;Start with the big picture. Lay out the phases an HTTP request passes through, in order, and you get this (source: the official &lt;a href="https://nginx.org/en/docs/dev/development_guide.html#http_phases" rel="noopener noreferrer"&gt;Phases section of the Development Guide&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;post-read       right after the request is read
    ↓
server-rewrite  rewrite directly under server (before location selection)
    ↓
find-config     pick the location
    ↓
rewrite         rewrite / if inside the location
    ↓
post-rewrite    if the URI changed, go back to find-config
    ↓
preaccess       connection count, rate limiting, etc.
    ↓
access          allow / deny / auth
    ↓
post-access
    ↓
precontent      try_files
    ↓
content         serving files via proxy_pass / root
    ↓
log             access log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;rewrite&lt;/code&gt; runs before location selection (server-rewrite) when written directly under &lt;code&gt;server&lt;/code&gt;, and after it (rewrite) when written inside a &lt;code&gt;location&lt;/code&gt;. Where you put it changes the phase, which changes the order it runs in. What decides the order is not the line sequence but "which block, and which type of directive" — that's how you read this list. Now let's follow how each of the two symptoms travels along this timeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 1, followed by phase
&lt;/h3&gt;

&lt;p&gt;The part that matters for &lt;code&gt;rewrite ... last&lt;/code&gt; and &lt;code&gt;try_files&lt;/code&gt; is this stretch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─▶ find-config     pick the location (first pass: /app/. with last, the second pass picks a different one)
  │       ↓
  │   rewrite         rewrite ... last rewrites to /public/foo
  │       ↓
  └── post-rewrite    URI changed → back to find-config
          ↓
      precontent      try_files runs here. we're now in the different location picked on the second pass.
                      the try_files written in the original /app/ is never reached → 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#rewrite" rel="noopener noreferrer"&gt;&lt;code&gt;last&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;rewrite ... last&lt;/code&gt; means "once you've rewritten the URI, redo location selection with the new URI." So the instant &lt;code&gt;/app/foo&lt;/code&gt; becomes &lt;code&gt;/public/foo&lt;/code&gt;, the post-rewrite phase calls find-config back and picks a location all over again. The rewritten &lt;code&gt;/public/foo&lt;/code&gt; no longer matches &lt;code&gt;/app/&lt;/code&gt;, so it moves to a different location. Meanwhile &lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files" rel="noopener noreferrer"&gt;&lt;code&gt;try_files&lt;/code&gt;&lt;/a&gt; runs way later, in the precontent phase. It may get evaluated wherever the request landed, but the &lt;code&gt;try_files&lt;/code&gt; written in the original &lt;code&gt;/app/&lt;/code&gt; is never reached again. If the destination has no file and no fallback, that's where the 404 comes from. It's not that your &lt;code&gt;=404&lt;/code&gt; fired — the request dead-ended somewhere else, never reaching the original &lt;code&gt;try_files&lt;/code&gt;. "Written below, so it runs after" is not what happens; the phases differ, so regardless of line position the rewrite fires first and jumps out of the location.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2, followed by phase
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;if&lt;/code&gt; and &lt;code&gt;add_header&lt;/code&gt; take effect in two phases that are far apart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;find-config     pick the location (/download/)
    ↓
rewrite         if is true → switch the config context to the inner block
    ↓           (the switch carries over into all later phases)
preaccess       connection count, rate limiting, etc.
    ↓
access          allow / deny / auth
    ↓
post-access
    ↓
precontent      try_files
    ↓
content         apply add_header when the response is being sent
                it looks at the block we switched into (X-Debug only)
                → the outer X-Served-By is gone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;if&lt;/code&gt; inside a location is an "implicit nested location" — it creates a config block of its own. And that &lt;code&gt;if&lt;/code&gt; is a rewrite-phase directive. When the condition goes true, this phase switches the request's config context into the inner block, and that state carries over into the phases that follow. Much later, when &lt;a href="https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header" rel="noopener noreferrer"&gt;&lt;code&gt;add_header&lt;/code&gt;&lt;/a&gt; is applied at the point of sending response headers, what it reads is the block we switched into. All that's inside is &lt;code&gt;X-Debug&lt;/code&gt;, and on top of that, &lt;code&gt;add_header&lt;/code&gt; does not inherit from the outer scope once there's even one in the same block (it replaces rather than accumulates), so &lt;code&gt;X-Served-By&lt;/code&gt; disappears entirely. If the condition is false, no switch happens and &lt;code&gt;X-Served-By&lt;/code&gt; shows up as usual. The outer header vanishing is not about line order — it's because the switch that happened in the rewrite phase is still in effect in the later phases. What people call &lt;a href="https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/" rel="noopener noreferrer"&gt;if is evil&lt;/a&gt; is, at bottom, this "context switch in the rewrite phase."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom 1
&lt;/h3&gt;

&lt;p&gt;If you want to keep processing in the same location after the rewrite, change &lt;code&gt;last&lt;/code&gt; to &lt;code&gt;break&lt;/code&gt;. With &lt;code&gt;break&lt;/code&gt;, the post-rewrite return to find-config doesn't happen, so the request can go down to the precontent &lt;code&gt;try_files&lt;/code&gt; while staying in the same location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# last → break&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&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;If instead you want to route the request to a different location depending on the URI, leave &lt;code&gt;last&lt;/code&gt; as is. In that case, put the fallback in the destination location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/public/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# put the fallback in the destination&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which one is right comes down to a design call: do you want to keep processing in the same location after the rewrite, or hand off to a different one?&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2
&lt;/h3&gt;

&lt;p&gt;Don't put content-handling directives (&lt;code&gt;add_header&lt;/code&gt;, &lt;code&gt;proxy_pass&lt;/code&gt;, &lt;code&gt;try_files&lt;/code&gt;, and so on) inside an &lt;code&gt;if&lt;/code&gt;. Treat &lt;code&gt;return&lt;/code&gt; and &lt;code&gt;rewrite&lt;/code&gt; as about the only things that are safe inside &lt;code&gt;if&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The condition here is a query argument (&lt;code&gt;?debug=1&lt;/code&gt;), and &lt;code&gt;location&lt;/code&gt; can't match on the query string. For branching on a value like this, push it out of &lt;code&gt;if&lt;/code&gt; and into a &lt;code&gt;map&lt;/code&gt; that builds a variable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$arg_debug&lt;/span&gt; &lt;span class="nv"&gt;$x_debug&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"1"&lt;/span&gt;     &lt;span class="s"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/download/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Served-By&lt;/span&gt; &lt;span class="s"&gt;"nginx"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Debug&lt;/span&gt;     &lt;span class="nv"&gt;$x_debug&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;add_header&lt;/code&gt; lines sit at the same level, so no nested block replaces anything and both take effect. When &lt;code&gt;$arg_debug&lt;/code&gt; isn't &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;$x_debug&lt;/code&gt; is empty, and nginx &lt;a href="https://github.com/nginx/nginx.org/issues/50" rel="noopener noreferrer"&gt;won't emit an &lt;code&gt;add_header&lt;/code&gt; with an empty value&lt;/a&gt;, so &lt;code&gt;X-Debug&lt;/code&gt; shows up only during debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking which location you're in right now
&lt;/h2&gt;

&lt;p&gt;The feel for phases sticks once you get your hands dirty. The quick way is to plant one marker in a suspect location to tell whether the request settled there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Loc&lt;/span&gt; &lt;span class="s"&gt;"app"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# marker that shows up if you stay in this location&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&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;Look at the headers with &lt;code&gt;curl -sI http://localhost/app/foo&lt;/code&gt;, and if &lt;code&gt;X-Loc: app&lt;/code&gt; isn't there, the request has already left this location via &lt;code&gt;rewrite ... last&lt;/code&gt; (the &lt;code&gt;add_header&lt;/code&gt; you wrote here has no effect once it's gone). Change &lt;code&gt;last&lt;/code&gt; to &lt;code&gt;break&lt;/code&gt;, hit it again, and if &lt;code&gt;X-Loc: app&lt;/code&gt; appears, the request stayed in the same location and made it down to precontent. One marker header lets you see, across phases, which location the request is in right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;When you read an nginx config, your eyes follow the lines top to bottom. But what nginx actually does is run the request along a timeline of phases. When you're questioning a config, don't trace the line order — trace it in this order instead: "which phase does this directive run in? and at that point, which location is the request in?" That usually makes things line up.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>nginx Tools for When Your Config Gets Out of Hand</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-tools-for-when-your-config-gets-out-of-hand-2075</link>
      <guid>https://dev.to/shinagawa-web/nginx-tools-for-when-your-config-gets-out-of-hand-2075</guid>
      <description>&lt;p&gt;nginx config files grow. What starts simple gets layers added on top: another reverse proxy, TLS settings, routing logic that branches and branches again.&lt;/p&gt;

&lt;p&gt;Before long you're looking at 20+ &lt;code&gt;location&lt;/code&gt; blocks and can't say off the top of your head which one handles which request. You add a single &lt;code&gt;add_header&lt;/code&gt; line and some other header silently disappears. You fix an &lt;code&gt;alias&lt;/code&gt; path and prod throws 404s while staging is fine. &lt;code&gt;nginx -t&lt;/code&gt; says "syntax is ok." But you don't feel ok about it.&lt;/p&gt;

&lt;p&gt;Every config change comes with a quiet dread: &lt;em&gt;will this break something?&lt;/em&gt; And that dread usually has a basis. nginx config mistakes are silent. Nothing shows up in the error log. The browser gets a normal-looking response. You don't find out something is broken until much later.&lt;/p&gt;

&lt;p&gt;nginx itself is well-documented, but the ecosystem around it is harder to survey. Validation tools, config generators, test frameworks — searching turns up scattered information. This article organizes the common pain points into six categories and maps the tools that address each one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Problem 1: Prevent config mistakes upfront&lt;/li&gt;
&lt;li&gt;Problem 2: Generate config files&lt;/li&gt;
&lt;li&gt;Problem 3: Automate testing&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 4: Collect metrics&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 5: Run nginx on Kubernetes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 6: Visualize logs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Gaps that still aren't filled&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Problem 1: Prevent config mistakes upfront
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; checks syntax. But syntactically valid config can still have security problems. SSRF, path traversal via &lt;code&gt;alias&lt;/code&gt; (e.g., a request to &lt;code&gt;/files/../../etc/passwd&lt;/code&gt; reaches files it shouldn't), HTTP splitting — none of these trigger a syntax error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gixy: static analysis focused on security
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/dvershinin/gixy" rel="noopener noreferrer"&gt;https://github.com/dvershinin/gixy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Originally built at Yandex, now actively maintained as a community fork. It checks for well-known security risks: SSRF, alias traversal, HTTP splitting. Install the &lt;code&gt;gixy-ng&lt;/code&gt; fork from PyPI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gixy-ng
gixy /etc/nginx/nginx.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output looks like this — it tells you which directive is the problem and why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;[&lt;span class="n"&gt;WARN&lt;/span&gt;] [&lt;span class="n"&gt;http_splitting&lt;/span&gt;] &lt;span class="n"&gt;Possible&lt;/span&gt; &lt;span class="n"&gt;HTTP&lt;/span&gt;-&lt;span class="n"&gt;Splitting&lt;/span&gt; &lt;span class="n"&gt;via&lt;/span&gt; &lt;span class="n"&gt;HTTP&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;.
&lt;span class="n"&gt;Directive&lt;/span&gt;: &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="n"&gt;X&lt;/span&gt;-&lt;span class="n"&gt;Forwarded&lt;/span&gt;-&lt;span class="n"&gt;For&lt;/span&gt; $&lt;span class="n"&gt;http_x_forwarded_for&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good fit when you've just inherited an nginx config and want a quick security audit before touching it. Detection is limited to known patterns and there's no custom rule support, but as a first security gate in CI it offers the best return on effort.&lt;/p&gt;

&lt;h3&gt;
  
  
  crossplane: treat config as data
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/crossplane" rel="noopener noreferrer"&gt;https://github.com/nginxinc/crossplane&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Python library that parses nginx config files into JSON. Useful not just for validation but for dynamically generating config or loading it in tests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;crossplane&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crossplane&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/etc/nginx/nginx.conf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Check for parse errors
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&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="c1"&gt;# Walk the config tree to find specific directives
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&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;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;block&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;item&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;directive&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;name&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;item&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;block&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;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;config&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;parsed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Example: flag any proxy_pass that uses plain HTTP
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;proxy_pass&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;args&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="n"&gt;url&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Warning: proxy_pass uses plain HTTP: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parsed output is a tree of directives and their arguments. With a traversal like &lt;code&gt;find_directives&lt;/code&gt;, you can express project-specific rules in Python: "are all &lt;code&gt;proxy_pass&lt;/code&gt; values HTTPS?", "is &lt;code&gt;server_tokens off&lt;/code&gt; set?" Where Gixy is limited to known patterns, crossplane lets you write your own.&lt;/p&gt;

&lt;p&gt;Scripting against config files and automating structural checks in CI are where this earns its place. If you don't have a concrete need to generate or programmatically inspect config, skip it for now. Adding it "because it looks useful" tends to mean it sits unused.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find security issues quickly&lt;/td&gt;
&lt;td&gt;Gixy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manipulate or validate config in code&lt;/td&gt;
&lt;td&gt;crossplane&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gixy alone covers the security CI gate. If you're writing config generation or structural validation logic in Python, you need crossplane. They don't overlap, so using both is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Generate config files
&lt;/h2&gt;

&lt;p&gt;Writing TLS config from scratch means making a lot of small decisions: cipher suites, HTTP/2 support, HSTS headers. Starting from a known-good template beats hand-rolling it and getting something subtly wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginxconfig.io: generate best-practice config in the browser
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.digitalocean.com/community/tools/nginx" rel="noopener noreferrer"&gt;https://www.digitalocean.com/community/tools/nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A web app by DigitalOcean that generates a complete nginx config from a series of UI choices. Select your TLS cipher suites, gzip settings, HTTP/2, security headers, and it outputs ready-to-use config files. Free, no account required.&lt;/p&gt;

&lt;p&gt;Use it when bootstrapping a new server or bringing TLS settings up to current best practices. It's an excellent starting point, but don't deploy the output unchanged. Treat it as a template and adjust for your project's requirements.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-proxy: auto-generate config in Docker environments
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginx-proxy/nginx-proxy" rel="noopener noreferrer"&gt;https://github.com/nginx-proxy/nginx-proxy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A container that watches Docker start/stop events and automatically updates nginx config. Set a &lt;code&gt;VIRTUAL_HOST&lt;/code&gt; environment variable on your container and nginx-proxy generates the reverse proxy config for it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;VIRTUAL_HOST=example.com&lt;/span&gt;
  &lt;span class="na"&gt;nginx-proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginxproxy/nginx-proxy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/tmp/docker.sock:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;app&lt;/code&gt; starts, nginx-proxy automatically creates config to route &lt;code&gt;example.com&lt;/code&gt; traffic to it.&lt;/p&gt;

&lt;p&gt;In Docker Compose setups where manually updating nginx on every deploy is friction, this removes that burden. Traefik and Caddy do the same thing. If you're not already committed to nginx (existing config, team familiarity), compare them first. If you're already running nginx, nginx-proxy is the natural fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regular Linux server&lt;/td&gt;
&lt;td&gt;nginxconfig.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker / Docker Compose&lt;/td&gt;
&lt;td&gt;nginx-proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No Docker? nginxconfig.io is enough. In Docker environments, nginx-proxy's automation pays off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Automate testing
&lt;/h2&gt;

&lt;p&gt;Manually sending test requests after every config change doesn't scale. Automating it means the same checks run every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test::Nginx: declarative test framework
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/openresty/test-nginx" rel="noopener noreferrer"&gt;https://github.com/openresty/test-nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Perl-based test framework from the OpenResty community. It starts a real nginx instance, sends requests, and checks responses. The format is declarative — you describe what config to use, what request to send, and what response to expect. Tests read clearly as a result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;TEST&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;basic&lt;/span&gt; &lt;span class="nv"&gt;proxy&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;config&lt;/span&gt;
&lt;span class="nv"&gt;location&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;http:&lt;/span&gt;&lt;span class="sr"&gt;//&lt;/span&gt;&lt;span class="mf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt;
&lt;span class="nv"&gt;GET&lt;/span&gt; &lt;span class="sr"&gt;/api/us&lt;/span&gt;&lt;span class="nv"&gt;ers&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;response_body&lt;/span&gt;
&lt;span class="p"&gt;[{"&lt;/span&gt;&lt;span class="s2"&gt;id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fits nginx module development and verifying that config changes don't alter behavior. Perl is a real barrier for most teams today. If you're not writing custom nginx modules, hurl covers most of what you actually need.&lt;/p&gt;

&lt;h3&gt;
  
  
  hurl: a DSL for HTTP testing
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://hurl.dev/" rel="noopener noreferrer"&gt;https://hurl.dev/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;hurl lets you write HTTP requests and assertions in a plain-text format, then run them from the shell. Easy to slot into CI. Not nginx-specific — it works for HTTP testing in general.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET http://localhost/api/users
HTTP 200
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.length" &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hurl &lt;span class="nt"&gt;--test&lt;/span&gt; api.hurl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reach for it when you want to confirm API behavior hasn't broken after a config change, or when the team has no Perl experience. Because it's not nginx-specific, it doesn't go deep on nginx internals. It works well for endpoint reachability checks, but for testing precise nginx behavior (match precedence, path rewriting), Test::Nginx is the right tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Testing nginx config or modules directly&lt;/td&gt;
&lt;td&gt;Test::Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checking that the API behind nginx still works&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team has no Perl experience&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test::Nginx for precise nginx behavior verification. hurl for HTTP interface testing with a lower adoption cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Collect metrics
&lt;/h2&gt;

&lt;p&gt;Without visibility into how many requests nginx is handling and where the bottlenecks are, there's no basis for making informed decisions when problems surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-module-vts: embedded stats module
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/vozlt/nginx-module-vts" rel="noopener noreferrer"&gt;https://github.com/vozlt/nginx-module-vts&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A module that adds traffic statistics to nginx — request counts, error rates, latency per virtual host and upstream. Built in at compile time. Exposes a &lt;code&gt;/status&lt;/code&gt; endpoint that returns JSON.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;vhost_traffic_status_display&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;vhost_traffic_status_display_format&lt;/span&gt; &lt;span class="s"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detailed traffic stats are the payoff — when you have a build environment and can compile nginx from source. Requiring a source build is the biggest barrier. If you're using a packaged nginx install, this isn't an option. If you already have Prometheus, the overhead of a custom build may not be worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-prometheus-exporter: bridge stub_status to Prometheus
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/nginx-prometheus-exporter" rel="noopener noreferrer"&gt;https://github.com/nginxinc/nginx-prometheus-exporter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reads nginx's built-in &lt;code&gt;stub_status&lt;/code&gt; data (connection counts, request totals) and exposes it in Prometheus format. Runs as a separate process alongside nginx — no changes to nginx itself required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/nginx_status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;stub_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./nginx-prometheus-exporter &lt;span class="nt"&gt;-nginx&lt;/span&gt;.scrape-uri&lt;span class="o"&gt;=&lt;/span&gt;http://localhost/nginx_status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Prometheus + Grafana stacks, this is the easiest way to add nginx metrics without touching nginx itself. &lt;code&gt;stub_status&lt;/code&gt; only gives you connection counts and request totals — no per-upstream breakdown. Confirm that's enough before committing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Already running Prometheus&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need per-host, per-upstream, per-status-code stats&lt;/td&gt;
&lt;td&gt;nginx-module-vts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using packaged nginx and don't want to rebuild&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're on Prometheus, nginx-prometheus-exporter is by far the easier path. If you need granular metrics and control your build, look at vts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: Run nginx on Kubernetes
&lt;/h2&gt;

&lt;p&gt;When exposing services externally in Kubernetes, you use an Ingress resource — a rule that maps incoming paths to backend services. nginx-based Ingress controllers are widely used for this. Two controllers with similar names exist, and they're frequently confused.&lt;/p&gt;

&lt;h3&gt;
  
  
  ingress-nginx: the Kubernetes community controller
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/kubernetes/ingress-nginx" rel="noopener noreferrer"&gt;https://github.com/kubernetes/ingress-nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Maintained by the Kubernetes community (&lt;code&gt;kubernetes/ingress-nginx&lt;/code&gt;). Used as the default by many Kubernetes distributions. Large amount of documentation and community knowledge available.&lt;/p&gt;

&lt;p&gt;In March 2025, a set of serious vulnerabilities called IngressNightmare was disclosed (CVE-2025-1974 and others, CVSS 9.8). Remote code execution was possible through the admission controller component. Fixed in versions 1.12.1 and 1.11.5. If you're running an older version, upgrade now.&lt;/p&gt;

&lt;p&gt;On a general-purpose Kubernetes cluster running standard Ingress, this is the obvious starting point. Switching Ingress controllers later is painful, so the initial choice matters. When in doubt, ingress-nginx has fewer dead ends from a documentation standpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  kubernetes-ingress: the NGINX Inc. controller
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/kubernetes-ingress" rel="noopener noreferrer"&gt;https://github.com/nginxinc/kubernetes-ingress&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Maintained directly by NGINX Inc. (&lt;code&gt;nginxinc/kubernetes-ingress&lt;/code&gt;). Supports both open-source nginx and NGINX Plus (the commercial version).&lt;/p&gt;

&lt;p&gt;The case for it is NGINX Plus features (advanced load balancing, active health checks) or a support contract with NGINX Inc. If you don't need NGINX Plus, there's little reason to choose this over ingress-nginx. For OSS nginx, the community controller has better coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Community resources matter&lt;/td&gt;
&lt;td&gt;ingress-nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planning to use NGINX Plus&lt;/td&gt;
&lt;td&gt;kubernetes-ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starting on EKS / GKE or similar managed cluster&lt;/td&gt;
&lt;td&gt;ingress-nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you go with ingress-nginx, run version 1.12.1 or 1.11.5 or later. Check your current version before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 6: Visualize logs
&lt;/h2&gt;

&lt;p&gt;nginx access logs pile up as text. Figuring out which paths are getting hammered, or where errors are spiking, is hard to do from raw log files.&lt;/p&gt;

&lt;h3&gt;
  
  
  GoAccess: real-time stats in the terminal
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://goaccess.io/" rel="noopener noreferrer"&gt;https://goaccess.io/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A TUI tool that aggregates nginx access logs and displays them in real time. Graphs and stats update live in the terminal. No log server or database needed — just install and run. Can also generate HTML reports for browser viewing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;goaccess /var/log/nginx/access.log &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="c"&gt;# Starts in interactive mode to select the log format&lt;/span&gt;

&lt;span class="c"&gt;# Stream live logs in COMBINED format&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/nginx/access.log | goaccess &lt;span class="nt"&gt;--log-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;COMBINED -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reach for it when you need answers quickly, especially on a server with no log collection infrastructure in place. It reads local log files directly, so multi-server environments are awkward. Think of it as a per-server quick-look tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loki + Grafana Alloy: long-term log storage and search
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://grafana.com/oss/loki/" rel="noopener noreferrer"&gt;https://grafana.com/oss/loki/&lt;/a&gt; / &lt;a href="https://grafana.com/docs/alloy/latest/" rel="noopener noreferrer"&gt;https://grafana.com/docs/alloy/latest/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Loki stores and queries logs efficiently, with Grafana for dashboards. Grafana Alloy handles log collection. Alloy replaces Promtail, which reached end-of-life in March 2026 — for new setups, use Alloy.&lt;/p&gt;

&lt;p&gt;Example Alloy config (sending nginx logs to Loki):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="s2"&gt;"nginx"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="nx"&gt;__path__&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/var/log/nginx/access.log"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;job&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"nginx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="nx"&gt;forward_to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://loki:3100/loki/api/v1/push"&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;It earns its place when you need to aggregate logs from multiple servers, search historical logs by time range, or view nginx logs alongside other metrics in Grafana. Running the full stack yourself carries real operational overhead. Start with Grafana Cloud's free tier, or bring this in when someone on the team already knows how to operate Grafana.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Need to check something right now&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already running Grafana&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need long-term log retention and cross-server search&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging with direct SSH access to the server&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GoAccess gives you immediate answers. Loki + Alloy is for ongoing operations. They're not mutually exclusive — GoAccess for fast situational awareness, Loki for accumulating and analyzing over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: match tool to problem
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find security risks in config&lt;/td&gt;
&lt;td&gt;Gixy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manipulate config programmatically&lt;/td&gt;
&lt;td&gt;crossplane&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate correct config (Linux server)&lt;/td&gt;
&lt;td&gt;nginxconfig.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-manage Docker reverse proxies&lt;/td&gt;
&lt;td&gt;nginx-proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test nginx config behavior&lt;/td&gt;
&lt;td&gt;Test::Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add HTTP connectivity checks to CI&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collect metrics via Prometheus&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need detailed per-upstream stats (module)&lt;/td&gt;
&lt;td&gt;nginx-module-vts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set up Ingress on Kubernetes&lt;/td&gt;
&lt;td&gt;ingress-nginx (1.12.1+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need NGINX Plus features&lt;/td&gt;
&lt;td&gt;kubernetes-ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visualize logs immediately&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-term log storage and search&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;nginx itself is stable and mature, but the ecosystem moves fast — Promtail's EOL is a good example. Periodically check the maintenance status of tools you rely on, or you'll find out about a support cutoff at the worst time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gaps that still aren't filled
&lt;/h2&gt;

&lt;p&gt;The tools covered here leave some problems unsolved or underserved. If you know tools that address any of these, drop them in the comments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic upstream management
&lt;/h3&gt;

&lt;p&gt;Updating upstreams without an nginx reload is a common requirement. With open-source nginx, the only options are OpenResty (Lua scripting for dynamic upstream control) or NGINX Plus (API-driven management). Vanilla nginx requires rewriting config and reloading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Config drift tracking across environments
&lt;/h3&gt;

&lt;p&gt;There's no standard tool for tracking and visualizing how nginx config differs between dev, staging, and production. Git-based management is the practical answer, but cross-environment diff review and detecting prod-only config still means manual work.&lt;/p&gt;

&lt;h3&gt;
  
  
  IDE / LSP support
&lt;/h3&gt;

&lt;p&gt;VSCode extensions for nginx.conf exist, but completion and inline validation are incomplete. No editor today reliably explains &lt;code&gt;location&lt;/code&gt; block match priority or how &lt;code&gt;proxy_pass&lt;/code&gt; transforms paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  WAF
&lt;/h3&gt;

&lt;p&gt;ModSecurity has a solid track record as an nginx WAF, but rule management and false-positive tuning are heavy ongoing work. There's nothing you can drop in and operate at a low maintenance level.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/owasp-modsecurity/ModSecurity-nginx" rel="noopener noreferrer"&gt;https://github.com/owasp-modsecurity/ModSecurity-nginx&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Distributed tracing
&lt;/h3&gt;

&lt;p&gt;The nginx OpenTelemetry module (&lt;code&gt;ngx_otel_module&lt;/code&gt;) shipped in 2023 but is still maturing. Trace collection with Jaeger or Tempo is possible, but documentation and real-world operational experience are thin.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/nginx-otel" rel="noopener noreferrer"&gt;https://github.com/nginxinc/nginx-otel&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>$request_time Looks Fast, But Users Say It''s Slow: Breaking nginx Latency into 4 Parts</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 28 Jul 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/requesttime-looks-fast-but-users-say-its-slow-breaking-nginx-latency-into-4-parts-1pn9</link>
      <guid>https://dev.to/shinagawa-web/requesttime-looks-fast-but-users-say-its-slow-breaking-nginx-latency-into-4-parts-1pn9</guid>
      <description>&lt;p&gt;Your nginx &lt;code&gt;$request_time&lt;/code&gt; looks fine in the dashboard — p95 is 120ms, totally reasonable.&lt;/p&gt;

&lt;p&gt;But users keep reporting that things "occasionally freeze." You dig through the logs looking for slow requests and find nothing obvious. Overall p95 is healthy. The upstream app team says "our side is fast." nginx looks fast too. Yet the complaints are real. You don't know where to look.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$request_time&lt;/code&gt; collapses everything that happened during the request into a single number. Fast phases and slow phases all get averaged together, making it impossible to see where the time actually went.&lt;/p&gt;

&lt;p&gt;nginx does record timestamps at each intermediate checkpoint, though. Subtract them, and latency breaks into 4 distinct parts.&lt;/p&gt;

&lt;p&gt;The log variables covered here and what they measure are the same regardless of nginx version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one number hides
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;$request_time&lt;/code&gt; measures everything from when nginx reads the first byte from the client to when it finishes sending the last byte of the response. In a reverse proxy setup, that single number includes at least all of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;time to establish a connection to the upstream backend&lt;/li&gt;
&lt;li&gt;time until the backend starts returning a response (backend processing time)&lt;/li&gt;
&lt;li&gt;time to receive the full response from the backend&lt;/li&gt;
&lt;li&gt;nginx's own processing and the time to send the response back to the client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this gets summed into one &lt;code&gt;$request_time&lt;/code&gt;. So even if p95 is 120ms, you cannot tell from that number alone whether the breakdown is "20ms backend, 100ms sending" or "100ms connecting, 20ms everything else." Those scenarios call for completely different fixes, but &lt;code&gt;$request_time&lt;/code&gt; erases the distinction.&lt;/p&gt;

&lt;p&gt;Also worth noting: the TLS handshake on the client side happens &lt;em&gt;before&lt;/em&gt; &lt;code&gt;$request_time&lt;/code&gt; starts, since the clock starts at "first byte received." If you suspect TLS overhead and stare at &lt;code&gt;$request_time&lt;/code&gt;, it won't show up there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start: add variables to your log format
&lt;/h2&gt;

&lt;p&gt;The breakdown analysis below requires &lt;code&gt;$upstream_connect_time&lt;/code&gt;, &lt;code&gt;$upstream_header_time&lt;/code&gt;, and &lt;code&gt;$upstream_response_time&lt;/code&gt; to be present in your access log. Open your own logs and there's a good chance none of these are there.&lt;/p&gt;

&lt;p&gt;The reason is simple: nginx's default &lt;code&gt;combined&lt;/code&gt; log format doesn't include any of them. &lt;code&gt;combined&lt;/code&gt; logs eight fields — remote address, user, timestamp, request line, status, response bytes, referer, and user agent. &lt;code&gt;$request_time&lt;/code&gt; isn't even there. Upstream timing variables are excluded by design.&lt;/p&gt;

&lt;p&gt;Until you define a custom &lt;code&gt;log_format&lt;/code&gt; and reload nginx, this breakdown is physically impossible. You can't subtract what doesn't exist. Fix the log definition first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;log_format&lt;/span&gt; &lt;span class="s"&gt;latency&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="nv"&gt;$remote_addr&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                   &lt;span class="s"&gt;'rt=&lt;/span&gt;&lt;span class="nv"&gt;$request_time&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                   &lt;span class="s"&gt;'uct=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_connect_time&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                   &lt;span class="s"&gt;'uht=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_header_time&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                   &lt;span class="s"&gt;'urt=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_response_time&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;access_log&lt;/span&gt; &lt;span class="n"&gt;/var/log/nginx/access.log&lt;/span&gt; &lt;span class="s"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After adding this and running &lt;code&gt;nginx -s reload&lt;/code&gt;, the raw data you need starts appearing in logs. Logs written before the reload won't have these fields, so there's no way to retroactively see the breakdown for historical requests.&lt;/p&gt;

&lt;p&gt;There's also an approach that captures timing directly from the kernel without touching the nginx config. &lt;a href="https://github.com/shinagawa-web/ngxray" rel="noopener noreferrer"&gt;ngxray&lt;/a&gt; explores that direction (currently in development).&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking it into 4 parts
&lt;/h2&gt;

&lt;p&gt;When nginx operates as a reverse proxy, it records three timestamps for the upstream interaction. These are the basis for the breakdown.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$upstream_connect_time&lt;/code&gt;: time until the connection to the upstream is established (includes TLS handshake if the upstream is HTTPS)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$upstream_header_time&lt;/code&gt;: time until nginx starts receiving the response headers from the upstream&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$upstream_response_time&lt;/code&gt;: time until nginx has received the full response from the upstream&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are measured from the same starting point — the moment nginx begins processing the upstream. They're not three independent intervals; they're cumulative values. &lt;code&gt;$upstream_response_time&lt;/code&gt; contains &lt;code&gt;$upstream_header_time&lt;/code&gt;, and &lt;code&gt;$upstream_header_time&lt;/code&gt; contains &lt;code&gt;$upstream_connect_time&lt;/code&gt;. They're nested.&lt;/p&gt;

&lt;p&gt;Once you understand they're cumulative, subtraction gives you the individual intervals.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;① Connect&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$upstream_connect_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time to connect to upstream (TLS included)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;② Backend processing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$upstream_header_time − $upstream_connect_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time until upstream starts responding (TTFB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③ Response transfer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$upstream_response_time − $upstream_header_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time to receive the full response from upstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;④ Client + nginx internal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$request_time − $upstream_response_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Request body read, nginx internals, sending to client&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Don't read ④ as "nginx is slow." It captures request body reading, rewrite/access phases, and the time to finish delivering the response to the client. When users on slow connections are the only ones complaining, ①②③ will look fine while ④ carries all the time. Slow clients only become visible through this breakdown. This assumes &lt;code&gt;proxy_buffering&lt;/code&gt; is on (the default), where nginx buffers the full upstream response before sending to the client. If buffering is off, slow-client impact can bleed back into ③, so suspect both in that case.&lt;/p&gt;

&lt;p&gt;And don't look at averages. Break each of the four parts into p50 / p95 / p99 separately. Whichever part has a spiking p99 is your answer.&lt;/p&gt;

&lt;p&gt;For example, laying out p50 / p95 / p99 per part might reveal a distribution like this (these numbers are for illustration):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Part             p50     p95     p99
① connect         1ms     2ms     3ms
② backend        15ms    40ms    60ms
③ transfer        3ms     8ms    12ms
④ client+nginx    2ms     5ms   380ms   ← here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even when &lt;code&gt;$request_time&lt;/code&gt; p95 looks clean at 120ms, the p99 for ④ alone can spike to 380ms. That's what "occasionally freezes" actually is — buried in the overall average and p95, but visible as soon as you look at p99 per part.&lt;/p&gt;

&lt;p&gt;Once you know which part is the problem, the next action follows directly: ① is thick → upstream keepalive probably isn't configured (covered in the next section); ② is thick → backend processing is the bottleneck; ③ is thick → look at transfer size or upstream bandwidth; ④ is thick → suspect client connection speed or response body size.&lt;/p&gt;

&lt;p&gt;Even the &lt;a href="https://blog.nginx.org/blog/using-nginx-logging-for-application-performance-monitoring" rel="noopener noreferrer"&gt;nginx official blog's explanation&lt;/a&gt; writes that &lt;code&gt;$upstream_header_time&lt;/code&gt; measures the time "between establishing a connection and …" — it's ambiguous whether that excludes &lt;code&gt;$upstream_connect_time&lt;/code&gt; or includes it. Rather than memorizing each variable's exact definition, it's more reliable to internalize the structure: all three accumulate from the same origin, and you subtract to get intervals.&lt;/p&gt;

&lt;h3&gt;
  
  
  Computing p50/p95/p99 per part
&lt;/h3&gt;

&lt;p&gt;"Look at p99 per part" is easy to say, but how do you actually get those numbers? Using the &lt;code&gt;latency&lt;/code&gt; format defined above, here's an awk example to compute percentiles.&lt;/p&gt;

&lt;p&gt;To extract percentiles for part ④, client-side (&lt;code&gt;rt − urt&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{
  for (i = 1; i &amp;lt;= NF; i++) {
    if ($i ~ /^rt=/)  { rt  = substr($i, 4) }
    if ($i ~ /^urt=/) { urt = substr($i, 5) }
  }
  if (rt != "" &amp;amp;&amp;amp; urt != "" &amp;amp;&amp;amp; urt + 0 &amp;gt;= 0) a[++n] = rt - urt
}
END {
  asort(a)
  printf "p50=%.3f  p95=%.3f  p99=%.3f\n",
    a[int(n * 0.50)], a[int(n * 0.95)], a[int(n * 0.99)]
}'&lt;/span&gt; /var/log/nginx/access.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The structure is the same for every other part. For ② backend processing use &lt;code&gt;uht - uct&lt;/code&gt;; for ③ response transfer use &lt;code&gt;urt - uht&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In production you can run equivalent aggregations in Loki, CloudWatch Logs Insights, or Datadog log queries. The key is not "overall p95" but "p99 per part, listed separately." No matter how good a single number looks, the problem stays hidden until you split the breakdown and look at each part's p99.&lt;/p&gt;

&lt;h2&gt;
  
  
  When ① is always non-zero
&lt;/h2&gt;

&lt;p&gt;When you break down the parts, you occasionally see a pattern where ②③④ are all small but &lt;code&gt;$upstream_connect_time&lt;/code&gt; registers as non-zero on every single request. The responses themselves are lightweight, but there's a small constant overhead on every request.&lt;/p&gt;

&lt;p&gt;The TCP connection to the upstream isn't being reused. When connections are kept alive and reused, &lt;code&gt;$upstream_connect_time&lt;/code&gt; should be near 0 (or &lt;code&gt;-&lt;/code&gt;) after the first request. If it's consistently non-zero on every request, nginx is creating a new connection for each one — keepalive to the upstream isn't configured. This tends to get overlooked in long-running systems.&lt;/p&gt;

&lt;p&gt;Two directives work together here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;keepalive 32&lt;/code&gt; sets the maximum number of persistent connections the worker keeps in the pool. &lt;code&gt;proxy_http_version 1.1&lt;/code&gt; and &lt;code&gt;proxy_set_header Connection ""&lt;/code&gt; are required because HTTP/1.0 sends &lt;code&gt;Connection: close&lt;/code&gt; by default, which causes the upstream to close the connection immediately. Without those two lines, &lt;code&gt;keepalive&lt;/code&gt; does nothing.&lt;/p&gt;

&lt;p&gt;After adding the config and running &lt;code&gt;nginx -s reload&lt;/code&gt;, check the &lt;code&gt;uct=&lt;/code&gt; values in the access log. The first request will be non-zero since it's establishing the connection. If subsequent requests show &lt;code&gt;0.000&lt;/code&gt; or &lt;code&gt;-&lt;/code&gt;, connection reuse is working.&lt;/p&gt;

&lt;p&gt;Repeatedly opening new connections also leads to TIME_WAIT buildup and ephemeral port exhaustion, but that's a different layer of concern. For this article, the key read is: "① consistently non-zero = connections aren't being reused."&lt;/p&gt;

&lt;h2&gt;
  
  
  When the breakdown breaks: commas and colons
&lt;/h2&gt;

&lt;p&gt;There's another pattern that breaks the subtraction: looking at a &lt;code&gt;$upstream_*&lt;/code&gt; value and finding more than one number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uct=0.001, 0.003 uht=0.012, 0.045 urt=0.012, 1.230
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a broken log. nginx logs multiple values when a single request involves multiple upstream interactions. The delimiter carries meaning.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Comma-separated&lt;/strong&gt; (&lt;code&gt;0.001, 0.003&lt;/code&gt;): the request hit multiple upstream servers in sequence. The typical case is the first server failed and the second was tried as a retry/failover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colon-separated&lt;/strong&gt; (&lt;code&gt;0.001 : 0.003&lt;/code&gt;): an internal redirect via &lt;code&gt;X-Accel-Redirect&lt;/code&gt; or similar occurred, spanning multiple upstream groups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you treat this as a single value and compute &lt;code&gt;$request_time − $upstream_response_time&lt;/code&gt;, the subtraction breaks completely. The string &lt;code&gt;"0.012, 1.230"&lt;/code&gt; isn't a number.&lt;/p&gt;

&lt;p&gt;More importantly, multiple values are a signal of something abnormal. Two comma-separated values mean there was a retry — the first upstream attempt failed. That failure was buried in the average and invisible. When a slow request shows comma-separated upstream times, investigate the retry before worrying about the interval breakdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing: where is your time going?
&lt;/h2&gt;

&lt;p&gt;Rather than wrapping this up as a checklist of solutions, here are questions to bring to your own logs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does your access log include &lt;code&gt;$upstream_connect_time&lt;/code&gt;, &lt;code&gt;$upstream_header_time&lt;/code&gt;, and &lt;code&gt;$upstream_response_time&lt;/code&gt;? Are you still running the &lt;code&gt;combined&lt;/code&gt; format?&lt;/li&gt;
&lt;li&gt;Are you looking at p50 / p95 / p99 separately for each of the 4 parts, not just &lt;code&gt;$request_time&lt;/code&gt; as a whole?&lt;/li&gt;
&lt;li&gt;Have you ever computed &lt;code&gt;$request_time − $upstream_response_time&lt;/code&gt; (part ④, client + nginx internal)?&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Is &lt;code&gt;$upstream_connect_time&lt;/code&gt; non-zero on every request?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Can you interpret what it means when &lt;code&gt;$upstream_*&lt;/code&gt; has multiple values separated by commas or colons?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Can you explain "numbers look fast but users say slow" using per-part p99 rather than averages?&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any answer is no, you haven't broken down latency yet. As long as that's the case, "fast numbers, slow experience" stays a permanent contradiction.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$request_time&lt;/code&gt; doesn't lie. It just compresses too much into one number. Split it four ways and you can always pinpoint where the time went. Start by opening your access log and checking whether &lt;code&gt;$upstream_response_time&lt;/code&gt; is even being recorded.&lt;/p&gt;

&lt;h2&gt;
  
  
  References (official documentation)
&lt;/h2&gt;

&lt;p&gt;Every variable and behavior discussed here is documented in the official nginx docs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html" rel="noopener noreferrer"&gt;ngx_http_upstream_module&lt;/a&gt;: definitions of &lt;code&gt;$upstream_connect_time&lt;/code&gt;, &lt;code&gt;$upstream_header_time&lt;/code&gt;, &lt;code&gt;$upstream_response_time&lt;/code&gt;; meaning of comma/colon separators; TLS handshake handling&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_log_module.html" rel="noopener noreferrer"&gt;ngx_http_log_module&lt;/a&gt;: &lt;code&gt;log_format&lt;/code&gt; and the default &lt;code&gt;combined&lt;/code&gt; format definition&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.nginx.org/blog/using-nginx-logging-for-application-performance-monitoring" rel="noopener noreferrer"&gt;Using NGINX Logging for Application Performance Monitoring&lt;/a&gt;: upstream timing variable usage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c" rel="noopener noreferrer"&gt;nginx source &lt;code&gt;ngx_http_upstream.c&lt;/code&gt;&lt;/a&gt;: confirmation that all three variables share the same origin (&lt;code&gt;u-&amp;gt;start_time&lt;/code&gt;) and the nested structure&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>programming</category>
      <category>performance</category>
    </item>
    <item>
      <title>Silent nginx Config Bugs That Pass nginx -t: if, location, add_header, alias</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-t-passed-but-the-behavior-is-wrong-config-patterns-that-break-silently-outside-the-syntax-2nnc</link>
      <guid>https://dev.to/shinagawa-web/nginx-t-passed-but-the-behavior-is-wrong-config-patterns-that-break-silently-outside-the-syntax-2nnc</guid>
      <description>&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; returns &lt;code&gt;syntax is ok&lt;/code&gt; and &lt;code&gt;test is successful&lt;/code&gt;. &lt;code&gt;nginx -s reload&lt;/code&gt; finishes without a complaint. Nothing shows up in the error log.&lt;/p&gt;

&lt;p&gt;And yet the behavior is wrong.&lt;/p&gt;

&lt;p&gt;I've done this to myself more times than I'd like to admit. The one that hurt most was a CSP header I shipped to production that never reached a single browser for a full day — I only noticed because I got suspicious that no violation reports were coming in. There have been others: a file I put under &lt;code&gt;/static/&lt;/code&gt; getting swallowed by a different location, old behavior lingering after a reload. In every case &lt;code&gt;nginx -t&lt;/code&gt; passed, and nothing was grammatically wrong anywhere.&lt;/p&gt;

&lt;p&gt;The nature of this gap is clear: &lt;code&gt;nginx -t&lt;/code&gt; validates only syntactic correctness, and never looks at semantic correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;nginx -t&lt;/code&gt; actually guarantees for you
&lt;/h2&gt;

&lt;p&gt;What &lt;code&gt;nginx -t&lt;/code&gt; does, roughly speaking, is check "is this config file in a shape that nginx can load?" Does the directive name exist? Is the argument count right? Are the &lt;code&gt;{}&lt;/code&gt; blocks closed? Are the value types valid? In compiler terms, it goes about as far as syntax errors and maybe type checking.&lt;/p&gt;

&lt;p&gt;Everything past that point — "will this config process requests the way you intended?" — is outside the scope of &lt;code&gt;nginx -t&lt;/code&gt;. This is where the blind spot is. When the test passes, you quietly reread it as "the config is correct." But all that passed was the syntax test.&lt;/p&gt;

&lt;p&gt;Outside &lt;code&gt;nginx -t&lt;/code&gt;, there are three layers of breakage.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Semantic level&lt;/strong&gt; — the config isn't read the way you intended. The grammar is perfect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security level&lt;/strong&gt; — it works exactly as you intended. It just also works exactly as the attacker intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime level&lt;/strong&gt; — the config text is irrelevant. It's the behavior of the reload operation itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The higher layers are the ones you step on daily, and their cause is easy to look for inside the config file. The lower you go, the more it becomes the kind of incident where reading the config file gives you no answer at all. We'll go top to bottom. Reading this with your own &lt;code&gt;nginx.conf&lt;/code&gt; open beside you should help.&lt;/p&gt;

&lt;p&gt;Note that this article assumes the behavior of the nginx 1.31 (mainline) series. The stable series is 1.30, and the behavior covered here — &lt;code&gt;if&lt;/code&gt;, location selection, &lt;code&gt;add_header&lt;/code&gt; inheritance, &lt;code&gt;alias&lt;/code&gt;, and reload — is the same across both. Where a version makes a difference, I'll call it out explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Semantic level: the grammar is correct, but it's read differently
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The contents of &lt;code&gt;if&lt;/code&gt; aren't evaluated in the order you think
&lt;/h3&gt;

&lt;p&gt;When you want to "branch on a condition" in an nginx config, &lt;code&gt;if&lt;/code&gt; is the first thing you reach for. But an &lt;code&gt;if&lt;/code&gt; inside a location has long been called "if is evil" in the nginx community. It sounds like a religious argument if you only hear the name, but it's a statement about behavior.&lt;/p&gt;

&lt;p&gt;First, why it breaks. &lt;code&gt;if&lt;/code&gt; is a directive of the &lt;code&gt;rewrite&lt;/code&gt; module, evaluated in an early phase of request processing (the rewrite phase). And the nastier part: when you write an &lt;code&gt;if&lt;/code&gt; inside a location, that &lt;code&gt;if&lt;/code&gt; block is treated as an &lt;em&gt;implicit nested location&lt;/em&gt;. When the condition is true, nginx switches the request's entire configuration context into that inner location. Not every directive gets dropped (many settings like &lt;code&gt;root&lt;/code&gt; or &lt;code&gt;proxy_set_header&lt;/code&gt; carry over). But content-processing directives like &lt;code&gt;try_files&lt;/code&gt; or &lt;code&gt;proxy_pass&lt;/code&gt; may not carry over the way you expect. That's the trap.&lt;/p&gt;

&lt;p&gt;Let's look at a concrete example. A common, simple setup: serve a static site, but route just the requests under &lt;code&gt;/api/&lt;/code&gt; to a backend. It's completely valid grammatically and passes &lt;code&gt;nginx -t&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# serve static files&lt;/span&gt;

    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;^/api/)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# route only /api/ to the backend...&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 author's intent is "normally serve static files, and only proxy to the backend when the path starts with &lt;code&gt;/api/&lt;/code&gt;." But &lt;code&gt;if&lt;/code&gt; creates an implicit nested location, so when the condition is true the config context switches wholesale into it. Inside there's only &lt;code&gt;proxy_pass&lt;/code&gt;; the outer content processing like &lt;code&gt;root&lt;/code&gt; and &lt;code&gt;try_files&lt;/code&gt; isn't carried into the switched-to location. Worse, once there's an &lt;code&gt;if&lt;/code&gt; in the location, &lt;code&gt;try_files&lt;/code&gt; can stop being evaluated as expected even for requests where the condition is false. The grammar is correct and &lt;code&gt;nginx -t&lt;/code&gt; says nothing, so you get "occasional 404s" and "occasional un-proxied requests" without ever noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection and avoidance:&lt;/strong&gt; The things that are genuinely safe inside an &lt;code&gt;if&lt;/code&gt; in a location are, in practice, only &lt;code&gt;return ...&lt;/code&gt;, &lt;code&gt;rewrite ... last&lt;/code&gt;, &lt;code&gt;set ...&lt;/code&gt; (and &lt;code&gt;break&lt;/code&gt;). For anything else — &lt;code&gt;try_files&lt;/code&gt;, &lt;code&gt;proxy_pass&lt;/code&gt;, header manipulation — the moment you want to do it inside an &lt;code&gt;if&lt;/code&gt;, that's a sign the design is wrong. When you want to "route by path" like here, the answer is not &lt;code&gt;if&lt;/code&gt; but splitting the location. Routing in nginx is location's job; &lt;code&gt;if&lt;/code&gt; breaks it by cutting in.&lt;/p&gt;

&lt;p&gt;Rewriting the earlier example by splitting the location looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# static serving is this block's only job&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# /api/ is a separate location from the start&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Making &lt;code&gt;/api/&lt;/code&gt; its own location means no implicit nesting and no config-context switch. The static-serving &lt;code&gt;location /&lt;/code&gt; and the proxying &lt;code&gt;location /api/&lt;/code&gt; each hold only their own processing. Replacing conditional branching with "location as routing" rather than "&lt;code&gt;if&lt;/code&gt; as a control statement" is the basic form of avoiding &lt;code&gt;if&lt;/code&gt;. When you want to branch on a value rather than a path, push the condition out into a &lt;code&gt;map&lt;/code&gt; (a lookup table that maps an input value to an output value) with the same mindset, and use the resulting variable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Location priority is not the order you wrote them in
&lt;/h3&gt;

&lt;p&gt;When you write multiple &lt;code&gt;location&lt;/code&gt; blocks, don't you assume "the one written higher up takes priority"? I did. And it bit me.&lt;/p&gt;

&lt;p&gt;nginx's location selection runs on a rule that differs from the order you wrote. The procedure is this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If an &lt;code&gt;=&lt;/code&gt; (exact match) location matches the URI exactly, it's decided right there and nothing else is examined.&lt;/li&gt;
&lt;li&gt;Otherwise, among the prefix locations (no modifier, or &lt;code&gt;^~&lt;/code&gt;), pick and remember the one with the longest match. Order within the config file is irrelevant here. The longest match wins.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If the selected prefix has &lt;code&gt;^~&lt;/code&gt;, it's decided there and regex is not checked.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Otherwise, check the regex locations (&lt;code&gt;~&lt;/code&gt;, &lt;code&gt;~*&lt;/code&gt;) in written order, and the first that matches wins. Here, written order matters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If no regex matches, use the prefix remembered in step 2.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Prefixes are longest-match (order-independent), regexes are first-match by written order, and regexes take priority over prefixes." That last point is what gets people. A reproduction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.(png|jpe?g|gif)$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- a different root&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's trace a request to &lt;code&gt;/static/logo.png&lt;/code&gt;. As a prefix, &lt;code&gt;/static/&lt;/code&gt; is the longest match and gets "remembered." But &lt;code&gt;/static/&lt;/code&gt; has no &lt;code&gt;^~&lt;/code&gt;. So nginx proceeds to the regex check, &lt;code&gt;\.(png|jpe?g|gif)$&lt;/code&gt; matches, and that one wins. The result: &lt;code&gt;/static/logo.png&lt;/code&gt; goes looking in &lt;code&gt;/var/www/cache&lt;/code&gt;. The &lt;code&gt;/static/&lt;/code&gt; block is skipped past. The grammar is perfect, &lt;code&gt;nginx -t&lt;/code&gt; passes. Only your assumption — "I'm handling all static files under &lt;code&gt;/static/&lt;/code&gt;" — is off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection:&lt;/strong&gt; Don't guess which location you're hitting — confirm it. The easy way is to temporarily add a marker header to the suspect locations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Loc&lt;/span&gt; &lt;span class="s"&gt;"static"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;...&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="s"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.(png|jpe?g|gif)$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Loc&lt;/span&gt; &lt;span class="s"&gt;"regex"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;...&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then hit &lt;code&gt;curl -sI http://localhost/static/logo.png | grep X-Loc&lt;/code&gt; and you'll know instantly which one answered. Nailing down "where am I hitting right now" as a fact comes first.&lt;/p&gt;

&lt;p&gt;Once you have that, if you want to lock it down, put &lt;code&gt;^~&lt;/code&gt; on the prefix to stop the regex check itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;^~&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;# decided the moment this prefix is the longest match; regex is not examined&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&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;h3&gt;
  
  
  With &lt;code&gt;add_header&lt;/code&gt;, adding one in the child makes all the parents disappear
&lt;/h3&gt;

&lt;p&gt;This is the trap that most symbolizes the "semantic level" of the three layers, and the one I was stuck on the longest.&lt;/p&gt;

&lt;p&gt;As a premise, many people write security headers like HSTS or CSP once in the &lt;code&gt;server&lt;/code&gt; block, intending them to apply to every location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=63072000"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Content-Security-Policy&lt;/span&gt; &lt;span class="s"&gt;"default-src&lt;/span&gt; &lt;span class="s"&gt;'self'"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-store"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- the moment you add this&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You just added one cache-control header to &lt;code&gt;/api/&lt;/code&gt;. But nginx's &lt;code&gt;add_header&lt;/code&gt; is specified so that if there's even one &lt;code&gt;add_header&lt;/code&gt; at the current level, it does not inherit any &lt;code&gt;add_header&lt;/code&gt; from the parent level at all. It's replacement, not merging. The result: HSTS and CSP disappear from the &lt;code&gt;/api/&lt;/code&gt; response, and all that's left is &lt;code&gt;Cache-Control&lt;/code&gt;. Only the security headers go silently missing.&lt;/p&gt;

&lt;p&gt;This "array-style directives don't inherit (don't merge with) the parent once defined in a child" behavior is not unique to &lt;code&gt;add_header&lt;/code&gt;. &lt;code&gt;proxy_set_header&lt;/code&gt; and &lt;code&gt;fastcgi_param&lt;/code&gt; have the same trap. Write this, for example, and &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-For&lt;/code&gt; stop being passed to the backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt;            &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Request-Id&lt;/span&gt; &lt;span class="nv"&gt;$request_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- just added one&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;# neither Host nor X-Forwarded-For is inherited here&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;&lt;strong&gt;Detection:&lt;/strong&gt; Missing headers — the only sure method is to actually look at the response. At each endpoint where you "think" you set a header, compare against the real thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/         | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'strict-transport|content-security'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/api/foo  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'strict-transport|content-security'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Present at the top but gone under &lt;code&gt;/api/&lt;/code&gt; is the classic signature of this trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to deal with it:&lt;/strong&gt; The most portable fix is to re-list every header you need in the child block. Split the common part into a separate file and &lt;code&gt;include&lt;/code&gt; it, so you gather the duplication into one place while re-declaring it at each level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# security-headers.conf&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=63072000"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Content-Security-Policy&lt;/span&gt;   &lt;span class="s"&gt;"default-src&lt;/span&gt; &lt;span class="s"&gt;'self'"&lt;/span&gt; &lt;span class="s"&gt;always&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 nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;security-headers.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;security-headers.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# re-read and re-declare in the child too&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-store"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;add_header_inherit merge;&lt;/code&gt; added in nginx 1.29.3 lets you change the behavior to "inherit the parent, then add the child" (it's in both the current stable 1.30 series and mainline 1.31 series). But it's not in the 1.28-and-earlier stable series, so it may not be available on your version — getting the premise "it's replacement" into your bones is more effective first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forget one trailing slash on &lt;code&gt;alias&lt;/code&gt;, and you can climb outside the directory
&lt;/h3&gt;

&lt;p&gt;Everything so far has been about "behavior that differs from intent." This &lt;code&gt;alias&lt;/code&gt; trap is the same kind of mistake — a single-slash difference — but the quality of the outcome is different. The config looks like it works as intended and does, yet you can read files outside the published directory. It's not just a behavior bug; it's a security hole as-is.&lt;/p&gt;

&lt;p&gt;A reproduction. It's the commonplace setup of "I want to serve static files from a different directory":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/assets&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;           &lt;span class="c1"&gt;# &amp;lt;- no trailing slash&lt;/span&gt;
    &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/var/www/static/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;- this one has a trailing slash&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;/assets/logo.png&lt;/code&gt; is served normally. &lt;code&gt;nginx -t&lt;/code&gt; passes, and as far as the browser is concerned there's no problem. It looks like it works as intended.&lt;/p&gt;

&lt;p&gt;Why it's dangerous. &lt;code&gt;alias&lt;/code&gt; builds the real file path by replacing the portion of the URI that matched the location (here, &lt;code&gt;/assets&lt;/code&gt;) with the value of &lt;code&gt;alias&lt;/code&gt;. When the location is &lt;code&gt;/assets&lt;/code&gt; (no slash), the string following &lt;code&gt;/assets&lt;/code&gt; gets stuck on directly at the end. So a request for &lt;code&gt;/assets../&lt;/code&gt; resolves to &lt;code&gt;/var/www/static/&lt;/code&gt; + &lt;code&gt;../&lt;/code&gt; = &lt;code&gt;/var/www/static/../&lt;/code&gt; = &lt;code&gt;/var/www/&lt;/code&gt;. That means with a little trick like &lt;code&gt;/assets../../etc/passwd&lt;/code&gt;, you can climb outside the intended published directory. It's a classic path traversal (an attack that walks back up directories to read non-public files on the server). Normal access never notices it at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection and fix:&lt;/strong&gt; The way to spot it is simple — line up the trailing slashes on the location and the alias. Either both have one, or neither does. In the example above, add a trailing slash to the location side too, like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/assets/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;- add the trailing slash so they match&lt;/span&gt;
    &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/var/www/static/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;- alias side has a trailing slash too&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/assets../&lt;/code&gt; no longer matches this location in the first place, and the climb is gone. Normal requests like &lt;code&gt;/assets/logo.png&lt;/code&gt; are still served exactly as before.&lt;/p&gt;

&lt;p&gt;If the location and the directory name match in the first place, the safest move is to drop &lt;code&gt;alias&lt;/code&gt; and lean on &lt;code&gt;root&lt;/code&gt; instead. &lt;code&gt;root&lt;/code&gt; doesn't replace the location portion — it appends the URI as-is under the directory — so this trailing-slash problem simply can't occur:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/assets/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/static&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# /assets/logo.png -&amp;gt; /var/www/static/assets/logo.png&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Note that &lt;code&gt;root&lt;/code&gt; appends the URI path verbatim, so the real directory has to be laid out as &lt;code&gt;/var/www/static/assets/&lt;/code&gt; too. When you don't want to change an existing physical layout, take the &lt;code&gt;alias&lt;/code&gt; + matched-trailing-slash route instead.)&lt;/p&gt;

&lt;p&gt;To audit an existing config, use &lt;code&gt;nginx -T&lt;/code&gt; (below) to dump all locations, and eyeball just the blocks containing &lt;code&gt;alias&lt;/code&gt; for the trailing-slash correspondence. To reproduce it at hand, poke one level up with &lt;code&gt;curl&lt;/code&gt;: if &lt;code&gt;curl -sI 'http://localhost/assets../'&lt;/code&gt; returns not a 200 or 403 but "something that shouldn't be visible," you've got a hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security level: it works exactly as intended, which is why it's dangerous
&lt;/h2&gt;

&lt;p&gt;The semantic-level traps were "behavior that differs from intent." From here, the quality changes. The config works exactly as you intended. It just also works exactly as the attacker intended. So it passes not only &lt;code&gt;nginx -t&lt;/code&gt; but even a visual review, on a "well, it's working" basis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Passing a client-touchable variable straight into &lt;code&gt;proxy_pass&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Using a variable for the &lt;code&gt;proxy_pass&lt;/code&gt; destination lets you decide the destination dynamically per request. Handy. But it's a different story if the source of that variable is client-derived.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/fetch/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="nv"&gt;$arg_target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# ?target=... decides the destination&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a request like &lt;code&gt;/fetch/?target=internal-admin:8080/&lt;/code&gt;, nginx can relay the request to any host inside your network. This is a textbook entry point for SSRF (server-side request forgery, an attack that uses your server as a stepping stone to send requests to places it otherwise couldn't reach). Including a variable in &lt;code&gt;proxy_pass&lt;/code&gt; also changes how the URI is handled and how name resolution behaves (you need a &lt;code&gt;resolver&lt;/code&gt; if you use a hostname), and while you're distracted by those details, it's easy to overlook the danger that the destination is client-controlled.&lt;/p&gt;

&lt;p&gt;I won't chase this further here — SSRF is a topic that deserves its own article. The one thing I want to nail down in this piece: "&lt;code&gt;nginx -t&lt;/code&gt; passes" and "it's safe" are entirely separate things. Whether a value the client can influence flows into &lt;code&gt;proxy_pass&lt;/code&gt;, a &lt;code&gt;rewrite&lt;/code&gt; destination, or a &lt;code&gt;root&lt;/code&gt;/&lt;code&gt;alias&lt;/code&gt; path — that perspective is completely outside the syntax check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime level: the config text is no longer relevant
&lt;/h2&gt;

&lt;p&gt;The last layer has a different flavor. Here the contents of the config file are correct. What "looks" broken is the behavior of the &lt;code&gt;reload&lt;/code&gt; operation itself. Stare at the config and the answer isn't written there.&lt;/p&gt;

&lt;h3&gt;
  
  
  After a reload, the old behavior lingers for a while
&lt;/h3&gt;

&lt;p&gt;You fix the config and &lt;code&gt;nginx -s reload&lt;/code&gt;. It succeeds. And yet, for a while, the old behavior is observed. It looks like a bug, but this is nginx's normal behavior.&lt;/p&gt;

&lt;p&gt;When it receives a reload (&lt;code&gt;SIGHUP&lt;/code&gt; to the master process), nginx doesn't suddenly switch everything over. It starts new worker processes with the new config and tells the old workers to shut down gracefully. Gracefully — that's the point. The old workers stop accepting new connections, but they take care of requests already in flight to the end before terminating.&lt;/p&gt;

&lt;p&gt;Normally this finishes in an instant, so you don't notice. The problem is when there are long-lived connections. WebSockets, large file downloads, long polling. An old worker holding one of these stays put until that connection ends. In other words, a window forms where the old and new configs are alive at the same time. That's the true identity of "the old behavior lingers after a reload." The config has been updated. It's just that a worker with the old config is still running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection:&lt;/strong&gt; This one you look at processes, not the config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,ppid,command | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'[n]ginx'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Old workers linger with the label &lt;code&gt;nginx: worker process is shutting down&lt;/code&gt;. If this "shutting down" keeps sitting alongside normal workers (&lt;code&gt;nginx: worker process&lt;/code&gt;), that's the substance of old-and-new coexisting. If you want to cap how long they linger, set &lt;code&gt;worker_shutdown_timeout&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;worker_shutdown_timeout&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# force old workers to terminate after at most 30 seconds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing here is not to chase this as a bug. The cause isn't written in the config file — it's in the single fact that "reload is that kind of operation."&lt;/p&gt;

&lt;p&gt;Also, confirming worker generations with &lt;code&gt;ps&lt;/code&gt; requires being able to log into that server. I'm also working on a small tool that peeks — from the outside, without touching the running nginx at all — at which worker generation is holding connections after a &lt;code&gt;reload&lt;/code&gt;, using eBPF (&lt;a href="https://github.com/shinagawa-web/ngxray" rel="noopener noreferrer"&gt;ngxray&lt;/a&gt;, work in progress).&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: how to fill in the space outside the syntax check
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; doesn't lie. It just answers a narrow question. "Is it in a loadable shape?" — it answers that. "Is it read as intended?" "Can it be abused by an attacker?" "What happens during a reload?" — it was never answering those to begin with.&lt;/p&gt;

&lt;p&gt;Each of the three layers is filled in differently.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic level&lt;/strong&gt; (&lt;code&gt;if&lt;/code&gt;, location priority, &lt;code&gt;add_header&lt;/code&gt; inheritance drops) can be caught by looking at the actual response. Marker headers and the difference in &lt;code&gt;curl -I&lt;/code&gt;. Get into the habit of comparing behavior with &lt;code&gt;curl -sI&lt;/code&gt; rather than reading the config and convincing yourself. Flattening the "config that's actually in effect" once with &lt;code&gt;nginx -T&lt;/code&gt; (uppercase; it dumps the final config with all &lt;code&gt;include&lt;/code&gt;s expanded) also helps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security level&lt;/strong&gt; is about tracing where client-derived values flow. Whether external input reaches &lt;code&gt;proxy_pass&lt;/code&gt;, a destination, or a path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime level&lt;/strong&gt; is about looking at processes and connections, not the config. Check worker generations with &lt;code&gt;ps&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The syntax check is only the first sheet of the layers. From the second sheet down, for now, I fill it in with human observation and discipline. Having a machine watch this space — reading the config not just by syntax but by "meaning," warning about location overtakes and header inheritance drops — static analysis, and a mechanism that continuously observes the actual responses, is the topic I want to think about next.&lt;/p&gt;

&lt;p&gt;For now, one line is all I want you to take home. When &lt;code&gt;nginx -t&lt;/code&gt; passes, the next thing to hit is &lt;code&gt;curl -sI&lt;/code&gt;. That the syntax is correct and that it works as intended are two things you confirm separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  References (official documentation)
&lt;/h2&gt;

&lt;p&gt;The behavior in this article can all be backed up by official documentation. When you suspect your config, this is the first place to check.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#if" rel="noopener noreferrer"&gt;ngx_http_rewrite_module (&lt;code&gt;if&lt;/code&gt;)&lt;/a&gt; and &lt;a href="https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/" rel="noopener noreferrer"&gt;If Is Evil&lt;/a&gt; — the story of &lt;code&gt;if&lt;/code&gt; creating an implicit location&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#location" rel="noopener noreferrer"&gt;The &lt;code&gt;location&lt;/code&gt; directive&lt;/a&gt; — the selection algorithm (longest prefix and regex priority)&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_headers_module.html" rel="noopener noreferrer"&gt;ngx_http_headers_module (&lt;code&gt;add_header&lt;/code&gt;)&lt;/a&gt; — inheritance rules and &lt;code&gt;add_header_inherit&lt;/code&gt;, added in 1.29.3&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#alias" rel="noopener noreferrer"&gt;The &lt;code&gt;alias&lt;/code&gt; directive&lt;/a&gt; — how the real file path is assembled&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass" rel="noopener noreferrer"&gt;&lt;code&gt;proxy_pass&lt;/code&gt;&lt;/a&gt; — behavior when using a variable in the destination, and &lt;code&gt;resolver&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://nginx.org/en/docs/control.html" rel="noopener noreferrer"&gt;Controlling nginx&lt;/a&gt; and &lt;a href="https://nginx.org/en/docs/ngx_core_module.html#worker_shutdown_timeout" rel="noopener noreferrer"&gt;&lt;code&gt;worker_shutdown_timeout&lt;/code&gt;&lt;/a&gt; — reload and graceful shutdown&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>programming</category>
      <category>security</category>
    </item>
    <item>
      <title>"How I Designed a Go Linter's Rule Engine So Adding a Rule Is One Line"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 14 Jul 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/keeping-add-a-rule-a-one-line-change-inside-gomarklints-rule-engine-pcb</link>
      <guid>https://dev.to/shinagawa-web/keeping-add-a-rule-a-one-line-change-inside-gomarklints-rule-engine-pcb</guid>
      <description>&lt;p&gt;"Just add one more check" — and before you know it, a &lt;code&gt;switch&lt;/code&gt; grows another branch, the config grows another case, and you're copy-pasting the same setup you already wrote somewhere else. If you've ever worked on code where rules or checks keep piling up, you probably recognize this kind of rot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt; is a Markdown linter, and it currently ships 24 checks (rules). Yet adding a new one touches just a function, one line in a table, and two spots in the config. The &lt;code&gt;switch&lt;/code&gt; doesn't grow. No wiring to add. The cost of adding a rule stays flat no matter how many there are. This post takes that apart from the inside — how that state is kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, let's run gomarklint
&lt;/h2&gt;

&lt;p&gt;Before we go inside, let's see what the tool actually does, once. gomarklint is simple: it reads Markdown files, finds structural and link problems, and points at them with a &lt;code&gt;file:line&lt;/code&gt;. Say you have a README with a link that goes nowhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# My Project&lt;/span&gt;

See the &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;)&lt;/span&gt; for details.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run gomarklint on it and you get:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gomarklint README.md
&lt;span class="go"&gt;
Errors in README.md:
  README.md:3: [error] no-empty-links: link has empty destination: [docs]()

✖ 1 issues found
✓ Checked 1 file(s), 3 line(s) in 1ms
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Because an &lt;code&gt;[error]&lt;/code&gt; was printed, the exit code is non-zero. In CI, this is where the build fails.&lt;/p&gt;

&lt;p&gt;When this post says "rule," it means one of those checks that produces a single line of output like that. "Find empty links." "Check that heading levels don't skip." "Check for duplicate headings." There are 24 such checks today, and each one returns "which line, and what's wrong." gomarklint's own job is to collect those findings (the &lt;code&gt;LintError&lt;/code&gt; you'll see in code shortly), sort them, and print them.&lt;/p&gt;

&lt;p&gt;Here's the real question. How do you keep those 24 checks at "adding one is a one-line change"? We'll open it up in order — the diagnostic type, how rules are laid out, the shared preprocessing that skips code and HTML, and how severity wires into the exit code — and finish by adding a real rule through an actual PR (&lt;a href="https://github.com/shinagawa-web/gomarklint/issues/106" rel="noopener noreferrer"&gt;#106&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Even if you're not writing a linter — a validator, a CI check, a rule-based plugin system — if you write "a mechanism where items keep growing" in Go, this should be a template you can copy directly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rules pile up, and code usually rots
&lt;/h2&gt;

&lt;p&gt;Take a check like that "find empty links" one, and now imagine 24 of them. Built naively, the dispatch and configuration rot a little more with every rule you add. The usual four traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every new rule makes some &lt;code&gt;switch&lt;/code&gt; longer. You can't tell where a new rule should be registered, so you get missed registrations and double registrations.&lt;/li&gt;
&lt;li&gt;Small setup logic like "don't flag &lt;code&gt;[x]()&lt;/code&gt; inside a code block" gets reimplemented in each rule. Fix it in one, and another stays stale.&lt;/li&gt;
&lt;li&gt;The items you can write in the config file and the default values in code become two sources of truth, and one gets updated while the other drifts.&lt;/li&gt;
&lt;li&gt;The "error or warning?" decision — the same one that just set our exit code — plus its counting and its effect on the exit code get scattered around, and CI fails or passes not quite as intended.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;gomarklint would have stepped on every one of these if left alone. This post walks through how each is avoided, in order.&lt;/p&gt;
&lt;h2&gt;
  
  
  First, express diagnostics, config, and severity as minimal types
&lt;/h2&gt;

&lt;p&gt;At the center of the engine sit three small types. Everything wobbles if these wobble, so they're kept deliberately plain.&lt;/p&gt;

&lt;p&gt;The first is the violation itself.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;File&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Line&lt;/span&gt;     &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;Rule&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Message&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The point is that &lt;code&gt;Rule&lt;/code&gt; and &lt;code&gt;Severity&lt;/code&gt; are in there but are filled in by the engine, not the rule — as we'll see later. A rule function only has to return "which line, and what happened."&lt;/p&gt;

&lt;p&gt;The second is per-rule configuration.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;RuleConfig&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Enabled&lt;/span&gt;  &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="n"&gt;RuleSeverity&lt;/span&gt;
    &lt;span class="n"&gt;Options&lt;/span&gt;  &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;interface&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 third is severity, which has only three values.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;SeverityError&lt;/span&gt;   &lt;span class="n"&gt;RuleSeverity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;   &lt;span class="c"&gt;// presence of this makes the exit non-zero&lt;/span&gt;
    &lt;span class="n"&gt;SeverityWarning&lt;/span&gt; &lt;span class="n"&gt;RuleSeverity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"warning"&lt;/span&gt; &lt;span class="c"&gt;// reported, but doesn't fail the run&lt;/span&gt;
    &lt;span class="n"&gt;SeverityOff&lt;/span&gt;     &lt;span class="n"&gt;RuleSeverity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"off"&lt;/span&gt;     &lt;span class="c"&gt;// disabled&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If there's an &lt;code&gt;error&lt;/code&gt;, CI fails. Hold onto just that one fact, and the rest of the branching comes back to it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rules are functions, not an interface
&lt;/h2&gt;

&lt;p&gt;This is what changed the most in the engine.&lt;/p&gt;

&lt;p&gt;The obvious design is to define a &lt;code&gt;Rule&lt;/code&gt; interface and make each rule implement &lt;code&gt;Check()&lt;/code&gt;. gomarklint doesn't do that. A rule is just a function, and the functions are laid out in a table.&lt;/p&gt;

&lt;p&gt;They even split into two families by signature. One takes just the raw slice of lines — the simple rules.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;simpleRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;fn&lt;/span&gt;   &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;
&lt;span class="p"&gt;}{&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"final-blank-line"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckFinalBlankLine&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 other takes a shared preprocessing result, &lt;code&gt;*preprocess.Context&lt;/code&gt;, which we'll get to. Most rules live on this side now.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;contextRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;fn&lt;/span&gt;   &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;preprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;
&lt;span class="p"&gt;}{&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"no-bare-urls"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckNoBareURLs&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"single-h1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckSingleH1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"duplicate-heading"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckDuplicateHeadings&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"unclosed-code-block"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckUnclosedCodeBlocks&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"empty-alt-text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckEmptyAltText&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"no-empty-links"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckNoEmptyLinks&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c"&gt;// …and more&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Running the rules is then just iterating that table.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&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;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;contextRules&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;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withSeverity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;r&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="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Only the rules that take options — like &lt;code&gt;heading-level&lt;/code&gt;'s &lt;code&gt;minLevel&lt;/code&gt; or the &lt;code&gt;style&lt;/code&gt; of &lt;code&gt;consistent-*&lt;/code&gt;, and &lt;code&gt;max-line-length&lt;/code&gt; — are kept out of the table and listed below with explicit &lt;code&gt;if&lt;/code&gt;s, because they take extra arguments. The trade is: "the option-free majority goes in a table, the option-taking minority is spelled out."&lt;/p&gt;

&lt;p&gt;There's a cost to not using an interface. You can't do dynamic extension like dropping in a rule as a plugin at runtime. But the payoff was bigger.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rules become stateless pure functions. Give input, get output — so tests fall straight out as table-driven.&lt;/li&gt;
&lt;li&gt;Which rules run is fully visible by reading one slice. Nothing hides behind an abstraction.&lt;/li&gt;
&lt;li&gt;Registering a new rule is, literally, one line in a slice.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Write "skip inside code blocks" once
&lt;/h2&gt;

&lt;p&gt;Of those traps, the one that hurts most quietly is "the logic to skip inside code blocks scattered across every rule."&lt;/p&gt;

&lt;p&gt;The heading rule, the link rule, the emphasis rule — all of them want to say "but not inside a fenced code block." Done naively, each rule starts counting "am I inside a fence right now?" on its own. And then one rule misses indented code blocks, another misses HTML comments — inconsistencies creep in. This actually happened in gomarklint, and we took inventory of it in &lt;a href="https://github.com/shinagawa-web/gomarklint/issues/337" rel="noopener noreferrer"&gt;issue #337&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The answer is &lt;code&gt;preprocess.Scan&lt;/code&gt;. It walks the file's lines exactly once, classifies each line into "fenced code / indented code / HTML block / HTML comment," and hands it to every rule as a &lt;code&gt;*preprocess.Context&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;preprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;allErrors&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collectLineErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The rule side no longer counts anything itself. It just asks the shared verdict.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inBlockContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="c"&gt;// inside code/HTML, so this rule ignores it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The clever bit here is that &lt;code&gt;Context&lt;/code&gt; deliberately does not offer a single "is this line skippable?" convenience method. It exposes the four contexts individually. There's a reason: &lt;code&gt;max-line-length&lt;/code&gt; and &lt;code&gt;no-hard-tabs&lt;/code&gt;, for instance, do want to look inside fenced code (line length and tabs should be flagged even there). So "what to skip" is the rule's choice. The &lt;code&gt;inBlockContext&lt;/code&gt; helper for the majority is just a thin OR over those four.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;inBlockContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;preprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&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;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InFencedCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InIndentedCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InHTMLBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InHTMLComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;Context&lt;/code&gt; is trimmed hard for speed. It borrows the input line slice rather than copying it. The context flags are packed one byte per line, and the "sanitized line" with inline code blanked out is stored in a map only for the lines that differ. Since &lt;code&gt;Scan&lt;/code&gt; runs over every file, that one byte matters. But that's a story about designing for speed, so I'll leave the deep dive to the benchmarking post. Here the design win — "write the skip logic once and share it with everyone" — is the real point.&lt;/p&gt;
&lt;h2&gt;
  
  
  Severity and exit code are wired on the engine side
&lt;/h2&gt;

&lt;p&gt;I said the rule functions fill in neither &lt;code&gt;Rule&lt;/code&gt; nor &lt;code&gt;Severity&lt;/code&gt;. What fills them is this &lt;code&gt;withSeverity&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Linter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;withSeverity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleName&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sev&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RuleSeverity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ruleName&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sev&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;errs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The rule body returns only the "facts," and whether it's an error or a warning is decided by the config. The same rule can be an error for one team and a warning for another. Finally, the number of errors is counted, and if there's even one, the exit is non-zero. The basis for the CI gate is consolidated into this one place.&lt;/p&gt;
&lt;h2&gt;
  
  
  Prevent config/code drift with a contract
&lt;/h2&gt;

&lt;p&gt;The flexibility of &lt;code&gt;.gomarklint.json&lt;/code&gt; comes from &lt;code&gt;RuleConfig.UnmarshalJSON&lt;/code&gt; accepting three shorthands.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"no-empty-links"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                        &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"no-trailing-punctuation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;warning&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-line-length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lineLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;full&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;spec&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;true&lt;/code&gt; means "enabled / error," &lt;code&gt;"warning"&lt;/code&gt; means "enabled / warning," and an object is a full spec. The writer's convenience (the human) is absorbed by one method on the parser side.&lt;/p&gt;

&lt;p&gt;Here's the two-sources-of-truth trap. There's a default definition in the Go code too, &lt;code&gt;config.Default()&lt;/code&gt;, and the JSON string that &lt;code&gt;gomarklint init&lt;/code&gt; emits (&lt;code&gt;DefaultConfigJSON&lt;/code&gt;) carries the same list. These two are kept in sync by a manual contract — and the code says so in a comment. It is not auto-generated. When you add a rule, you update both; that's the promise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Actually adding one: no-empty-links (MD042)
&lt;/h2&gt;

&lt;p&gt;With the tooling in place, let's trace it through a real PR. It's &lt;code&gt;no-empty-links&lt;/code&gt; (MD042 in markdownlint terms, empty-link detection), added in &lt;a href="https://github.com/shinagawa-web/gomarklint/issues/106" rel="noopener noreferrer"&gt;issue #106&lt;/a&gt;. It catches links whose destination is empty, like &lt;code&gt;[text]()&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Write one rule function
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;internal/rule/no_empty_links.go&lt;/code&gt;, write one function with the &lt;code&gt;contextRules&lt;/code&gt; signature. The parts we've built so far just work.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CheckNoEmptyLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;preprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Len&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inBlockContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="c"&gt;// ignore links inside code/HTML&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// Line with inline code blanked out, so a link-looking&lt;/span&gt;
        &lt;span class="c"&gt;// `[x]()` inside a code span isn't a false positive.&lt;/span&gt;
        &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sanitized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&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;continue&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;match&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;findEmptyLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LintError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Line&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"no-empty-links: link has empty destination: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;All that's written here is the judgment of "what counts as an empty link" (&lt;code&gt;findEmptyLinks&lt;/code&gt; catches &lt;code&gt;[]()&lt;/code&gt;, &lt;code&gt;[](#)&lt;/code&gt;, &lt;code&gt;[](&amp;lt;&amp;gt;)&lt;/code&gt;, and the image variants). "Skip code blocks," "blank out inline code," "adjust the line number by offset" — those all just call the shared parts that already exist. &lt;code&gt;Rule&lt;/code&gt; and &lt;code&gt;Severity&lt;/code&gt; aren't filled in — the engine adds them later.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Add one line to the table
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;contextRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;}{&lt;/span&gt;
    &lt;span class="c"&gt;// …&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"no-empty-links"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckNoEmptyLinks&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c"&gt;// ← just this&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Dispatch is done. No &lt;code&gt;switch&lt;/code&gt;, no wiring to add.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Register in config (two spots)
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;no-empty-links&lt;/code&gt; to both &lt;code&gt;config.Default()&lt;/code&gt; and &lt;code&gt;DefaultConfigJSON&lt;/code&gt;. This is the "keep in sync by hand" contract in practice.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Add tests
&lt;/h3&gt;

&lt;p&gt;Write &lt;code&gt;internal/rule/no_empty_links_test.go&lt;/code&gt; table-driven, like the other rules. Because the rule is a pure function, you just line up input lines and expected violations.&lt;/p&gt;

&lt;p&gt;Of the four steps, the only one where you really "think" is step 1. The rest is one line, two spots, and a boilerplate test. Even at 24 rules, this shape hasn't broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  Extensibility isn't adding layers
&lt;/h2&gt;

&lt;p&gt;Many linters get slower and harder to extend as they grow, because every rule leans on a shared, complex framework. gomarklint went the other way. Each rule is an independent, stateless function, and only the thing everyone needs — the code/HTML context check — was pulled out into a single preprocessing pass. Simplicity scales better than abstraction. Even now, with the engine carrying 24 rules, that still holds.&lt;/p&gt;

&lt;p&gt;The result is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding a rule is one function + one line in a table + two config spots,&lt;/li&gt;
&lt;li&gt;Which rules run is visible by reading a slice,&lt;/li&gt;
&lt;li&gt;It scales linearly with repository size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Extensibility is often assumed to mean stacking layers — but sometimes it's about not stacking them. If you ever want to enforce your own documentation style guide, you can copy this shape directly. Write one function, add one line to the table. That's it.&lt;/p&gt;

&lt;p&gt;For the background on parsing and the overall architecture, my earlier post &lt;em&gt;Inside gomarklint: Architecture, Rule Engine, and How to Extend It&lt;/em&gt; is also worth a look (the engine has since evolved into the shape described here).&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shinagawa-web" rel="noopener noreferrer"&gt;
        shinagawa-web
      &lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;
        gomarklint
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Catch broken links before your readers do.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;gomarklint&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg"&gt;&lt;img src="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg" alt="Test"&gt;&lt;/a&gt;
&lt;a href="https://codecov.io/gh/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3a62ea1c605d28ca8b76a989447c82d628955be97440820aa775fdaf031acd42/68747470733a2f2f636f6465636f762e696f2f67682f7368696e61676177612d7765622f676f6d61726b6c696e742f67726170682f62616467652e7376673f746f6b656e3d354d4743595a5a593753" alt="codecov"&gt;&lt;/a&gt;
&lt;a href="https://goreportcard.com/report/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/5cc9b8570278e51d95dd927720684afeddb2594c78ae005a0886b29b2ca15039/68747470733a2f2f676f7265706f7274636172642e636f6d2f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e74" alt="Go Report Card"&gt;&lt;/a&gt;
&lt;a href="https://pkg.go.dev/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/6dfb8608e07cc6f5d15a43cf57ad1bcca3ab07f55909cc6b48380c79b1b4d277/68747470733a2f2f706b672e676f2e6465762f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="Go Reference"&gt;&lt;/a&gt;
&lt;a href="https://github.com/shinagawa-web/gomarklint/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://securityscorecards.dev/viewer/?uri=github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/aa4a07c35236d812f2e7f06eded94a41cfedf3d96fce7c9a3264c1a861e4b7d4/68747470733a2f2f6170692e736563757269747973636f726563617264732e6465762f70726f6a656374732f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742f6261646765" alt="OpenSSF Scorecard"&gt;&lt;/a&gt;
&lt;a href="https://www.bestpractices.dev/projects/12970" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/82af1e6eafb8ced8bfed64b0b39bdcb64d047fb6982d66aabd364828fe9ea371/68747470733a2f2f7777772e626573747072616374696365732e6465762f70726f6a656374732f31323937302f6261646765" alt="OpenSSF Best Practices"&gt;&lt;/a&gt;
&lt;a href="https://www.npmjs.com/package/@shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/10a64e2908c3838b4807ecad04edb3b8da11ff42205368324d3a775ec21a5972/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f407368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="npm version"&gt;&lt;/a&gt;
&lt;a href="https://www.npmjs.com/package/@shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/bb3fda742cdbb106cfe477862812f68fafcac76d434a7a2ec7efddfd3159ed67/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f64772f407368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="npm downloads"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;English | &lt;a href="https://github.com/shinagawa-web/gomarklint/README.ja.md" rel="noopener noreferrer"&gt;日本語&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/shinagawa-web/gomarklint/docs/static/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fshinagawa-web%2Fgomarklint%2FHEAD%2Fdocs%2Fstatic%2Fdemo.gif" width="800" alt="gomarklint catching a broken link and structure issues"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Catch broken links before your readers do — and keep your Markdown clean while you're at it. &lt;strong&gt;100,000+ lines in ~170ms&lt;/strong&gt;, single binary, no Node.js required.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick install&lt;/strong&gt; (macOS / Linux):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;curl -fsSL https://raw.githubusercontent.com/shinagawa-web/gomarklint/main/install.sh &lt;span class="pl-k"&gt;|&lt;/span&gt; sh&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Download binary&lt;/strong&gt; (no Go required):&lt;/p&gt;
&lt;p&gt;Download the latest binary for your platform from &lt;a href="https://github.com/shinagawa-web/gomarklint/releases/latest" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; macOS / Linux&lt;/span&gt;
tar -xzf gomarklint_Darwin_x86_64.tar.gz
sudo mv gomarklint /usr/local/bin/
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; or install to user-local directory (no sudo required)&lt;/span&gt;
mkdir -p &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mv gomarklint &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin/&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-powershell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows (PowerShell)&lt;/span&gt;
&lt;span class="pl-c1"&gt;Expand-Archive&lt;/span&gt; &lt;span class="pl-k"&gt;-&lt;/span&gt;Path gomarklint_Windows_x86_64.zip &lt;span class="pl-k"&gt;-&lt;/span&gt;DestinationPath &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Add to PATH (run once)&lt;/span&gt;
[&lt;span class="pl-k"&gt;Environment&lt;/span&gt;]::SetEnvironmentVariable(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;PATH&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;PATH&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;User&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via Homebrew:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;brew install shinagawa-web/tap/gomarklint&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via npm:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm install -g &lt;a class="mentioned-user" href="https://dev.to/shinagawa-web"&gt;@shinagawa-web&lt;/a&gt;/gomarklint&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via &lt;code&gt;go install&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;go install github.com/shinagawa-web/gomarklint/v3@latest&lt;/pre&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Catch broken…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>go</category>
      <category>markdown</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Tired of re-recording my README demo GIF by hand, I turned the whole demo environment into code</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/tired-of-re-recording-my-readme-demo-gif-by-hand-i-turned-the-whole-demo-environment-into-code-29lc</link>
      <guid>https://dev.to/shinagawa-web/tired-of-re-recording-my-readme-demo-gif-by-hand-i-turned-the-whole-demo-environment-into-code-29lc</guid>
      <description>&lt;p&gt;The demo GIF at the top of an OSS README. That little clip that shows you what a tool does in five seconds, before a single line of prose does. That one.&lt;/p&gt;

&lt;p&gt;I used to re-record mine by hand. Every single time.&lt;/p&gt;

&lt;p&gt;Fire up the screen recorder, tidy up the terminal, type the command, stop at a good moment, export to GIF. Three minutes if it goes well. It usually doesn't. I fat-finger one character in the command. I hit that final &lt;code&gt;q&lt;/code&gt; a beat too early and the recording ends before the screen I wanted to show even appears. Again. This time I notice, after exporting, that the terminal font size is different from last time. Again.&lt;/p&gt;

&lt;p&gt;That's how "just one GIF" would casually swallow 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hand-recording produces a slightly different picture every time
&lt;/h2&gt;

&lt;p&gt;The biggest problem with recording by hand is that the picture changes every time you redo it.&lt;/p&gt;

&lt;p&gt;Font, size, color theme, shell prompt, window width. All of it depends on the state of the machine you recorded on, at that moment. My prompt is set up to show the current git branch, and once, after a re-record, the README GIF alone had some unrelated repo name baked into it.&lt;/p&gt;

&lt;p&gt;The quieter problem is staleness. You tweak the UI a little. You add a line to the header. You change a keybinding. Every time, the GIF gets a bit more out of date. But re-recording is a pain, so it stays stale. The very top of the README goes on proudly showing a screen that no longer exists in the current version. The first thing a visitor sees is the thing that gets updated least.&lt;/p&gt;

&lt;p&gt;Anyone who's shipped a CLI tool as OSS has probably felt this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A TUI demo is a whole other level of annoying
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/pgincident" rel="noopener noreferrer"&gt;pgincident&lt;/a&gt;, a tool I maintain, is a TUI for the first response to a Postgres incident. It's the full-screen-in-the-terminal kind.&lt;/p&gt;

&lt;p&gt;A TUI demo is hard in a way a single CLI command isn't. If there's no "state" on the screen, the demo means nothing.&lt;/p&gt;

&lt;p&gt;For an &lt;code&gt;ls&lt;/code&gt; GIF, you just run it in a directory with a few files and it looks fine. But what pgincident wants to show you is a screen of "what's happening in this Postgres right now." A long query is running. A lock wait queue is forming. There's a session that opened a transaction and walked away. Only when all of that is on the screen at once does the viewer go "ah, so that's what this tool is."&lt;/p&gt;

&lt;p&gt;Connect to a clean, quiet Postgres where nothing is happening, and all you record is a screen of empty rows that says nothing. That's not a demo.&lt;/p&gt;

&lt;p&gt;But manufacturing a real long query, a real lock, and a real idle-in-transaction all at once, conveniently, just for the demo — doing that by hand every time isn't realistic. To create a lock artificially you open two sessions, deliberately make one wait, keep that state alive while you start recording in a third terminal, and... just the choreography is a headache. It's a pain to do once, and the thought of reproducing it every time I change the UI means the GIF stays stale even longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;.tape&lt;/code&gt; file — writing terminal operations down
&lt;/h2&gt;

&lt;p&gt;This is where &lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;vhs&lt;/a&gt; comes in. It's a Charm tool, and in one line: you write a script of terminal operations, and it runs them exactly as written and exports a GIF or MP4.&lt;/p&gt;

&lt;p&gt;The script goes in a text file with a &lt;code&gt;.tape&lt;/code&gt; extension. &lt;code&gt;Type&lt;/code&gt; types characters, &lt;code&gt;Enter&lt;/code&gt; presses enter, &lt;code&gt;Sleep&lt;/code&gt; waits. &lt;code&gt;Set&lt;/code&gt; pins down the appearance. This isn't a full reference article, so it's faster to just look at the real thing. Here's pgincident's &lt;code&gt;demo.tape&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# pgincident demo
# Run via: ./scripts/record-demo.sh

Output docs/demo.gif

Set FontSize 14
Set Width 1200
Set Height 700
Set Theme "Dracula"
Set Shell "bash"

# Start pgincident with the dev config
Type "./pgincident --config dev/pgincident-dev.toml"
Sleep 500ms
Enter

# Overview screen — let it render and refresh once
Sleep 4s

# Navigate to Dashboard screen
Type "o"
Sleep 4s

# Move cursor down to a Long-running query row and open SQL detail
Type "j"
Sleep 300ms
Enter
Sleep 3s

# Close detail and quit
Type "q"
Sleep 500ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can pretty much read what it does. Font size, window width, height, color theme — all pinned explicitly by the &lt;code&gt;Set&lt;/code&gt; lines up top. None of my machine's settings leak in. &lt;code&gt;Type "o"&lt;/code&gt; moves to the Dashboard screen, &lt;code&gt;Type "j"&lt;/code&gt; moves the cursor down a row, &lt;code&gt;Enter&lt;/code&gt; opens the SQL detail — the gaze of a README reader, turned straight into a script.&lt;/p&gt;

&lt;p&gt;Even this alone almost entirely kills the "slightly different picture every time" problem. Same script, same picture. No re-shoots for typos. If I mistype something, I fix one character in the &lt;code&gt;.tape&lt;/code&gt; and run it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  But &lt;code&gt;.tape&lt;/code&gt; alone is only half of it
&lt;/h2&gt;

&lt;p&gt;So far the &lt;em&gt;operations&lt;/em&gt; have become code. But remember the TUI difficulty from earlier. The "state" that shows up on screen still isn't set up anywhere.&lt;/p&gt;

&lt;p&gt;A script that launches &lt;code&gt;./pgincident&lt;/code&gt; is useless if the Postgres it connects to is empty — all you record is an empty screen. The script only knows &lt;em&gt;how to operate&lt;/em&gt;. It doesn't know &lt;em&gt;what should be on the screen&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So I wrapped the &lt;code&gt;.tape&lt;/code&gt; in one more script from the outside. That's &lt;code&gt;scripts/record-demo.sh&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/.."&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Building pgincident..."&lt;/span&gt;
go build &lt;span class="nt"&gt;-o&lt;/span&gt; pgincident ./cmd/pgincident

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Starting Postgres..."&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; postgres

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Waiting for Postgres to be ready..."&lt;/span&gt;
&lt;span class="k"&gt;until &lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres pg_isready &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Starting load generator..."&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &amp;lt; dev/loadgen_setup.sql
go run ./cmd/pgincident-loadgen &amp;amp;
&lt;span class="nv"&gt;LOADGEN_PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'kill $LOADGEN_PID 2&amp;gt;/dev/null || true'&lt;/span&gt; EXIT

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Waiting 5s for load to build up..."&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;5

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Recording demo..."&lt;/span&gt;
vhs demo.tape

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Done! -&amp;gt; docs/demo.gif"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading top to bottom, here's what it does. First it &lt;code&gt;go build&lt;/code&gt;s pgincident. It starts Postgres with Docker. It waits until &lt;code&gt;pg_isready&lt;/code&gt; says the connection is up. It pipes in the load-generation SQL and runs the load generator in the background. It waits 5 seconds for the load to build up. Only then does it call &lt;code&gt;vhs demo.tape&lt;/code&gt;. When the recording finishes, the &lt;code&gt;trap&lt;/code&gt; kills the load generator and cleans up.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.tape&lt;/code&gt; records the &lt;em&gt;operations&lt;/em&gt;, and this script sets up the &lt;em&gt;state that shows on screen&lt;/em&gt;. Build, Postgres startup, load generation, recording, cleanup — all folded into a single script, so the whole demo environment is reproducible. This is the part I most wanted to say. Unless you widen "record the demo" into "assemble the demo environment and record it," you can't fully turn a stateful TUI demo into code.&lt;/p&gt;

&lt;p&gt;You type &lt;code&gt;./scripts/record-demo.sh&lt;/code&gt;. After that, with a real long query, a real lock, and a real idle-in-transaction lined up on screen, one GIF gets generated. The manual choreography that used to give me a headache became a single command.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually creates that "state"?
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;record-demo.sh&lt;/code&gt;, the line that looks the most unremarkable and does the most work is &lt;code&gt;go run ./cmd/pgincident-loadgen&lt;/code&gt;. That's the part that artificially stokes the "real incident" you see on screen.&lt;/p&gt;

&lt;p&gt;The prep is &lt;code&gt;dev/loadgen_setup.sql&lt;/code&gt;. It's an unremarkable script that just creates two small tables for the load to hit.&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;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;balance&lt;/span&gt;    &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&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;touched_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 10k rows gives cache hit ratio room to move&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- pgincident_dev only has pg_monitor privileges. Grant DML so the simulator can run&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;pgincident_dev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's where it gets interesting. The load generator itself (Go) deliberately produces, one by one, the three categories that pgincident's Dashboard shows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long-running queries&lt;/strong&gt; come from analytical queries with a &lt;code&gt;pg_sleep&lt;/code&gt; baked in. It runs short ones that finish in a few seconds and long ones that squat for 5–8 minutes, with staggered start times.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;paused&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;id&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;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt;   &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lock waits&lt;/strong&gt; come from the classic wait queue: one session grabs a row with &lt;code&gt;FOR UPDATE&lt;/code&gt; and holds it, while another goes for the same row and gets stuck waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle in transaction&lt;/strong&gt; comes from a &lt;code&gt;BEGIN&lt;/code&gt;, one query, and then just sitting there without committing or rolling back. pgincident's threshold is 30 seconds, so it sleeps long enough to cross it.&lt;/li&gt;
&lt;li&gt;On top of that, a light OLTP load (a mix of SELECT / UPDATE) runs in the background so that TPS and cache hit ratio look "alive" on screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's the biggest trick for the sake of the recording: each worker is written to start on a staggered schedule. The idle ones so that "at least one is always visibly past the 30-second threshold," the long queries so that "two cycles half-overlap." The point is that the load itself is designed so that whenever &lt;code&gt;vhs demo.tape&lt;/code&gt; runs, the Dashboard's three categories are all filled in at once, just right.&lt;/p&gt;

&lt;p&gt;"Turning the whole demo environment into code," taken to its conclusion, includes this. Writing operations in a &lt;code&gt;.tape&lt;/code&gt; will never produce this "broken just right" state.&lt;/p&gt;

&lt;p&gt;The README just embeds this GIF as the top image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;pgincident demo&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;docs/demo.gif&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What changed after turning it into code
&lt;/h2&gt;

&lt;p&gt;Honestly, I didn't invent anything dramatic. Neither vhs nor a shell script is a novel technology. Even so, the day-to-day feel clearly changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anyone who runs it gets the same look and the same composition.&lt;/strong&gt; Font, size, theme, the way load is applied — it's all in the script. It won't match down to &lt;em&gt;which&lt;/em&gt; query lands in the top row on any given run, but the look and composition of the picture don't depend on my machine's mood. When a future contributor wants to update the GIF, they can read the &lt;code&gt;.tape&lt;/code&gt; and know what's on screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It reviews in a diff and lives in git.&lt;/strong&gt; A GIF is binary, so you can't read the diff of its contents, but &lt;code&gt;.tape&lt;/code&gt; and &lt;code&gt;record-demo.sh&lt;/code&gt; are just text. "Bumped a Sleep from 3s to 4s," "reordered the operations" — that shows up in the PR diff. Demo changes ride on the same rails as code changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change the UI, regenerate.&lt;/strong&gt; Added a line to the header. Changed a keybinding. Before, it was "re-recording is a pain, so leave it." Now it's just typing &lt;code&gt;./scripts/record-demo.sh&lt;/code&gt; again. I won't claim staleness is gone — forget to run the regen and it stays stale. But the weight of the "it's a pain" excuse dropped from 30 minutes of manual work to a single command.&lt;/p&gt;

&lt;p&gt;Let me be honest about one thing too. Right now &lt;code&gt;record-demo.sh&lt;/code&gt; is a local script I run by hand. I'm not auto-regenerating the GIF in CI. When I change the UI, I still have to notice "ah, gotta regenerate the GIF" myself and type it myself. Ideally there's room to get to where any UI-touching change makes CI re-bake the GIF and commit it. That's the next move. What I've got so far is that regeneration is one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas and tips
&lt;/h2&gt;

&lt;p&gt;A few spots that actually took some doing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tuning &lt;code&gt;Sleep&lt;/code&gt; takes the most work.&lt;/strong&gt; A TUI refreshes the screen periodically. Right after launch, the data isn't drawn yet. If &lt;code&gt;Sleep&lt;/code&gt; is too short, you record an empty or half-drawn screen. The reason &lt;code&gt;demo.tape&lt;/code&gt; waits a generous &lt;code&gt;Sleep 4s&lt;/code&gt; after launch is to let the Overview screen refresh once and fill in. Too long, though, and the GIF drags. I went back and forth a few times here — "shorten it, record, didn't make it, lengthen it a bit." It's an adjustment a single CLI command never needs; it's a TUI-specific chore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How well the load is crafted decides how convincing the demo is.&lt;/strong&gt; If the load the generator throws is too weak, not a single long query crosses the threshold and the screen stays sparse. Too strong and the screen fills up so much you can't tell what to look at. Creating the "broken just right" state is unglamorous but the single biggest thing for demo-ness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't mix in secrets.&lt;/strong&gt; The script is text and goes into git. And of course the GIF bakes in exactly the characters you typed and the screen you showed. Be careful not to write a production DSN or password into a &lt;code&gt;Type&lt;/code&gt;, or to record a screen connected to production. That pgincident's demo uses a dev config (&lt;code&gt;dev/pgincident-dev.toml&lt;/code&gt;) and connects to a local Docker Postgres is partly for this reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GIF size.&lt;/strong&gt; Crank up &lt;code&gt;Set Width&lt;/code&gt; / &lt;code&gt;Set Height&lt;/code&gt; and the file naturally gets heavier. This image sits at the top of the README, so slow loading defeats the purpose. Width 1200 × height 700 was the sweet spot where the text isn't crushed and the file isn't too heavy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demos go from something you "record" to something you "write and generate"
&lt;/h2&gt;

&lt;p&gt;Looking back, what changed is the kind of work. From the manual labor of "recording" a demo — moving your hands, failing, re-shooting — to "writing and generating" one: turning the script and the environment into code, and baking it with a command.&lt;/p&gt;

&lt;p&gt;And for a stateful TUI demo, writing the operations in a &lt;code&gt;.tape&lt;/code&gt; isn't enough. Only when you go all the way to setting up the "state" on screen — a real long query, a real lock — and turn the whole demo environment into code does it become reproducible. That, I think, is the key thing when you turn a TUI demo into code.&lt;/p&gt;

&lt;p&gt;If you're re-recording your own OSS README demo GIF by hand every time, start with a single &lt;code&gt;.tape&lt;/code&gt;. And if the screen you want to show needs "state," try folding the setup of that state into the script too.&lt;/p&gt;

&lt;p&gt;By the way, what pgincident actually is as a tool, I wrote up in &lt;a href="https://dev.to/_402ccbd6e5cb02871506/the-first-30-seconds-of-a-postgres-incident-why-they-take-30-minutes-5bnb"&gt;another article&lt;/a&gt;. It's the story of that first 30 seconds when Postgres slows down at 2 a.m.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>cli</category>
      <category>go</category>
      <category>bash</category>
    </item>
    <item>
      <title>"Couldn't you just use pg_activity? — staking out one corner of Postgres incident triage"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Wed, 01 Jul 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/couldnt-you-just-use-pgactivity-staking-out-one-corner-of-postgres-incident-triage-2m14</link>
      <guid>https://dev.to/shinagawa-web/couldnt-you-just-use-pgactivity-staking-out-one-corner-of-postgres-incident-triage-2m14</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/_402ccbd6e5cb02871506/the-first-30-seconds-of-a-postgres-incident-why-they-take-30-minutes-5bnb"&gt;Earlier, I wrote about a 2 a.m. incident&lt;/a&gt;. Production is slow, you open psql, the cursor blinks, and your hand freezes on the very first query. I introduced pgincident as a tool that breaks that "first-response paralysis" by giving you the whole picture on one screen.&lt;/p&gt;

&lt;p&gt;When I show that article to people, there's almost always one thing that comes back first.&lt;/p&gt;

&lt;p&gt;"Couldn't you just use pg_activity for that?"&lt;/p&gt;

&lt;p&gt;Or "isn't pgcenter enough?" Fair question. Terminal-based Postgres monitors have been around forever. Anyone can picture "that kind of screen" the moment you say &lt;code&gt;top&lt;/code&gt;, and I've personally been bailed out by both of those tools in the field more times than I can count. If I'm going to add one more TUI to the pile, I owe people a real explanation of what makes it different from the established standards.&lt;/p&gt;

&lt;p&gt;This article is that explanation. It's not a case that pgincident beats everything. After taking honest stock of where the existing tools are strong and where they have a harder time reaching, where pgincident stands turns out to be a fairly narrow corner: &lt;code&gt;managed Postgres × the Mac in front of you × first-response triage&lt;/code&gt;. That's where this lands. Read it as a stock-take, not a sales pitch.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, let me give credit — the existing tools are good
&lt;/h2&gt;

&lt;p&gt;I don't want to open by talking anything down, so let me say it up front: pg_activity and pgcenter are both well-made tools.&lt;/p&gt;

&lt;p&gt;(One caveat before we start: everything I say in this article about how the tools behave is as of pg_activity v3.6.2 / pgcenter v0.10.1 / pgincident v0.6.0. OS support, required privileges, and managed-environment behavior can all change between versions, so read every "works / doesn't work / gets stripped" here as a statement about those three specific versions.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/dalibo/pg_activity" rel="noopener noreferrer"&gt;pg_activity&lt;/a&gt; is a &lt;code&gt;top&lt;/code&gt;-style activity viewer written in Python. It's been around a long time, it's mature, and if you're SSH'd into a production host it's still first-rate. Instead of re-typing &lt;code&gt;pg_stat_activity&lt;/code&gt; by hand, the running queries line up sorted by duration and refresh on their own. Part of what I said I "wanted" in the last article has been sitting right here all along.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/lesovsky/pgcenter" rel="noopener noreferrer"&gt;pgcenter&lt;/a&gt; is written in Go and is also of the &lt;code&gt;top&lt;/code&gt; lineage. What's genuinely impressive about pgcenter is that it can correlate system stats — CPU, I/O — with Postgres stats on one screen. "Is the DB slow because of the queries, or because the host's I/O is saturated?" You can make that call by looking at the OS and the DB side by side on a single screen. If you want to do this on a self-managed Linux box, pgcenter is still a top pick.&lt;/p&gt;

&lt;p&gt;This isn't me propping it up in the abstract. There's a concrete time it saved me. On a self-managed Linux production box (Postgres I'd stood up myself on EC2), API latency one day ballooned to about three times its usual. Staring at the slow query log, I couldn't single out one bad query — everything was uniformly slow. That kind of slowness is the nasty kind, because you can't pin the blame on a single query. I SSH'd into the production host and launched pgcenter as the postgres user. On that familiar &lt;code&gt;top&lt;/code&gt;-lineage screen, the first thing that jumped out was the I/O wait row, with disk utilization basically pegged. The bottom half of the same screen had the Postgres-side stats, and I could see checkpoints firing far more often than usual. OS-side I/O saturation and DB-side checkpoint flurry, stacked one above the other, visible at the same time. "This isn't the queries — the host's I/O is jammed, and the trigger is checkpoints." I had that hunch within about ten seconds of opening the screen. If I'd been cross-referencing &lt;code&gt;pg_stat_bgwriter&lt;/code&gt; and &lt;code&gt;iostat&lt;/code&gt; in separate terminals, it would have taken a lot longer.&lt;/p&gt;

&lt;p&gt;That's what correlating OS and DB on one screen buys you. It's pgcenter's strongest suit, and where it works, it really works.&lt;/p&gt;

&lt;p&gt;So this isn't a piece about killing the old thing to sell the new one. The question is &lt;em&gt;where&lt;/em&gt; "where it works" actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the descendants of &lt;code&gt;top&lt;/code&gt; assume
&lt;/h2&gt;

&lt;p&gt;pg_activity and pgcenter are similar at the root of their design. Both are descendants of &lt;code&gt;top&lt;/code&gt;, and the basic shape is "show what's running right now as a single sorted list." And both deliver their best experience under one assumption.&lt;/p&gt;

&lt;p&gt;That assumption, roughly put, is that you're sitting on the host where the DB is running.&lt;/p&gt;

&lt;p&gt;Concretely, it comes down to this. pgcenter's signature feature — correlation with system stats — pulls CPU and I/O information from the OS. So the tool has to run on the same host as the DB. pg_activity, too, if you want it to show system information and temp-file information, assumes you run it on the same host as the OS user running postgres (or root).&lt;/p&gt;

&lt;p&gt;A word on privileges — this one's easy to get wrong, so let me be precise. Both tools deliver their best experience when the connecting role is a SUPERUSER. But it's not that they refuse to run without it. pg_activity drops into degraded mode if you're not a superuser and stops showing system and temp-file information — but you can still see the activity list itself. pgcenter is also happiest with a superuser, but it'll run with privileges sufficient to read the stats. It's not "SUPERUSER required," it's "without it, some of it gets stripped." That's a distinction worth keeping.&lt;/p&gt;

&lt;p&gt;Either way, the precondition for the best experience is "on the DB host, on Linux, with sufficient privileges." As long as that holds, both tools are strong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the assumption falls apart — RDS, Cloud SQL, Aurora
&lt;/h2&gt;

&lt;p&gt;The problem is that in a lot of recent incidents, that assumption falls apart wholesale.&lt;/p&gt;

&lt;p&gt;When your production Postgres runs on a managed service (RDS, Cloud SQL, Aurora), that precondition breaks in three ways at once. (1) you can't become the OS user, (2) you can't get SUPERUSER, (3) you can't get system stats. Let's take them in order.&lt;/p&gt;

&lt;p&gt;First, (1), you can't become the OS user of the DB host. You don't get to touch the internals of a managed service. The whole premise of SSH'ing in and launching the tool as the postgres user simply doesn't hold.&lt;/p&gt;

&lt;p&gt;Next, (2), you can't get SUPERUSER. Managed services generally won't hand you a SUPERUSER-equivalent role. So you're structurally locked out of that "SUPERUSER gives you the best experience" case from earlier.&lt;/p&gt;

&lt;p&gt;And (3), this is the one that stings most. The correlation with system stats — pgcenter's killer feature — simply doesn't work on managed services, because the mechanism for pulling CPU and I/O from the host can't reach inside the walls of a managed DB. pg_activity has an &lt;code&gt;--rds&lt;/code&gt; flag, but with it, system stats don't show up.&lt;/p&gt;

&lt;p&gt;Here, though, the damage lands differently on the two tools, so let me write it out fairly. pgcenter has its single biggest selling point — correlating OS and DB — vanish. The top half of that side-by-side screen, the one that let me catch the I/O saturation on EC2 in one shot, stops meaning anything at all. pg_activity, by contrast, loses the add-on information (system stats, temp-file info), but its core as an activity viewer survives. The main act — watching running queries sorted by duration, auto-refreshing — works just fine on a managed service too. pgcenter loses a pillar; pg_activity loses a branch. Both "get weaker on managed services," but that difference is not small.&lt;/p&gt;

&lt;p&gt;In fact, I once got burned by exactly this. During a morning incident with a slow Aurora (PostgreSQL-compatible), I typed out pgcenter on my Mac out of habit. It wouldn't even start — pgcenter is Linux-only and the Mac is unsupported (this isn't degradation, it just plain doesn't run). So I pointed pg_activity at the Aurora instance with the &lt;code&gt;--rds&lt;/code&gt; flag, and this time it started up fine. The activity list showed up too. But the system-information columns I always leaned on were completely blank. CPU, I/O, temp files, all empty. Which makes sense when you think about it — you can't reach the OS inside the managed wall — so this is what degraded looks like under &lt;code&gt;--rds&lt;/code&gt;. Not "doesn't run," but "comes back as half of itself."&lt;/p&gt;

&lt;p&gt;That swing-and-a-miss — it starts, but the half you most wanted to see is blank — wore on me more than I'd have expected. On EC2 I'd had a hunch within ten seconds of opening the screen, and now the same tool just wouldn't do the same work. In the end, that morning I couldn't use either of my usual standards in its proper form, and I had to form a hunch off the activity list plus guesswork in my head. The first thing you open during a late-night or early-morning incident is usually not the production host but the terminal on your own Mac. And in that "first thing you open," both tools fall short of their best at the same time.&lt;/p&gt;

&lt;p&gt;For the record, this isn't a defect in either tool. It's just that the world their design assumed (self-managed Linux hosts) and the world incidents now happen in (managed × the machine in front of you) have drifted apart. The world moved over these past few years; the tools didn't degrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  A difference in trade-offs — &lt;em&gt;deliberately&lt;/em&gt; dropping system stats
&lt;/h2&gt;

&lt;p&gt;Here's the design call pgincident makes. To be fair, this isn't "pgincident has more features." If anything, it's throwing features away.&lt;/p&gt;

&lt;p&gt;pgincident deliberately does not carry system stats (CPU / I/O / memory). The README even marks this as an explicit non-goal: "that's pgcenter's job; this one focuses on Postgres internals."&lt;/p&gt;

&lt;p&gt;This might look like a weakness. And it is — for someone who wants to correlate OS and DB on self-managed Linux, pgcenter is clearly the better choice. I'll grant that.&lt;/p&gt;

&lt;p&gt;But as a design decision it's coherent. On managed services you can't get system stats anyway. A feature built on top of something you can't get is dead on arrival if managed is your main case. So instead of depending on it, you pour everything into the range you &lt;em&gt;can&lt;/em&gt; get with the &lt;code&gt;pg_monitor&lt;/code&gt; role: activity, locks, idle in transaction, and the big-picture overview of overall health. Unlike SUPERUSER, &lt;code&gt;pg_monitor&lt;/code&gt; is a role that &lt;em&gt;can&lt;/em&gt; be granted on managed services, so you could say pgincident shifts the starting point of its design so that the best case "still holds on a managed service."&lt;/p&gt;

&lt;p&gt;Why can &lt;code&gt;pg_monitor&lt;/code&gt; be granted on managed services while SUPERUSER is withheld? This is worth one paragraph of context. SUPERUSER bypasses permission checks wholesale. It's a sweeping privilege that reaches the filesystem, server configuration, and other people's data. Hand that to a user and AWS or GCP can no longer hold the management boundary between the OS and the infrastructure. So managed services structurally don't issue SUPERUSER. &lt;code&gt;pg_monitor&lt;/code&gt;, on the other hand, is a narrowly scoped role that bundles up only the reads you need for monitoring — all columns of &lt;code&gt;pg_stat_activity&lt;/code&gt;, the various stats views. Because what it can see is limited to monitoring stats, the managed side can grant it without worry. By planting its starting point here, pgincident stands from the outset on the side of "the privilege a managed service can comfortably issue," rather than "the privilege a managed service structurally can't."&lt;/p&gt;

&lt;p&gt;One honest note: pgincident's managed-service support itself is, on the repo's SQL catalog, still largely unverified (most of the testing so far has been on local PG16). So I won't write "battle-tested on RDS." What I'm pointing at here isn't a verification track record but a choice of privilege model and architecture — the design's starting point sits on managed services and &lt;code&gt;pg_monitor&lt;/code&gt;. Same kind of TUI, but the best case it assumes faces a different direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other difference is the "shape" — one list vs. overview-then-dig
&lt;/h2&gt;

&lt;p&gt;Apart from the privilege and platform story, the shape of the screen itself is different. And personally I think this is the more important connection back to the last article.&lt;/p&gt;

&lt;p&gt;The basis of the &lt;code&gt;top&lt;/code&gt; family is "show what's running right now as a single sorted list." Sort by duration, sort by resource. This is good for seeing "what's heaviest." But it doesn't directly help with that paralysis I wrote about last time, the one where your hand stops at "what kind of incident is this even?" A list gives you a "heaviest-things ranking," but it doesn't give you the one-frame picture of "what's the overall state right now." Are connections exhausted? Are locks chaining? Is an idle in transaction session holding on? You need that hunch &lt;em&gt;before&lt;/em&gt; the list.&lt;/p&gt;

&lt;p&gt;pgincident reverses the order. The first thing you get is the Overview — a one-frame picture of overall health. Connection count, TPS, cache hit ratio, checkpoints, autovacuum. Each has a threshold, and the shaky metrics get highlighted in color. Here you get a read on it first — "this is the connection-exhaustion line," "no, this is a lock" — and only then drill down into that direction's category (activity / locks / idle in transaction). Overview first, then dig.&lt;/p&gt;

&lt;p&gt;Last time, I wrote this: "Because I was digging without an overview, I dug holes on instinct and filled them back in, and 30 minutes went by." A single &lt;code&gt;top&lt;/code&gt;-family list is an excellent tool for the "digging" side. What pgincident is trying to add is the one "overview" frame that comes before it, and that's the direct answer to the first-response paralysis I described in the first article. The comparison is only a means; the theme has been "the quality of the first response" the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Just one comparison table
&lt;/h2&gt;

&lt;p&gt;I'll pick the axes carefully and lay down a single table. Think of it not as a binary win/lose table but as one for reading "under which assumption does which tool work." (Restated: the behavior here is as of pg_activity v3.6.2 / pgcenter v0.10.1 / pgincident v0.6.0.)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;pg_activity&lt;/th&gt;
&lt;th&gt;pgcenter&lt;/th&gt;
&lt;th&gt;pgincident&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Runs on Mac (system stats need a Linux host)&lt;/td&gt;
&lt;td&gt;Linux-only (won't start on Mac)&lt;/td&gt;
&lt;td&gt;Linux + macOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privileges&lt;/td&gt;
&lt;td&gt;Best with SUPERUSER / degrades without it&lt;/td&gt;
&lt;td&gt;Ideal with SUPERUSER / works with stats-read privileges&lt;/td&gt;
&lt;td&gt;Designed around &lt;code&gt;pg_monitor&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System-stats correlation&lt;/td&gt;
&lt;td&gt;(needs same host + OS privileges)&lt;/td&gt;
&lt;td&gt;best-in-class (needs same host, Linux)&lt;/td&gt;
&lt;td&gt;deliberately absent (non-goal)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed DB as the main arena&lt;/td&gt;
&lt;td&gt;secondary&lt;/td&gt;
&lt;td&gt;secondary (system stats vanish)&lt;/td&gt;
&lt;td&gt;the primary design assumption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maturity / adoption&lt;/td&gt;
&lt;td&gt;high, proven track record&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screen shape&lt;/td&gt;
&lt;td&gt;one sorted list&lt;/td&gt;
&lt;td&gt;OS+DB correlated on one screen&lt;/td&gt;
&lt;td&gt;overview → drill into a category&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bold cells are where each tool clearly wins. For system-stats correlation, pgcenter; for maturity and track record, pg_activity; for putting managed services at the center from the start, pgincident. Read the table this way and you can see that what pgincident fills is the bottom-right corner — only the case of "managed is the main arena, &lt;code&gt;pg_monitor&lt;/code&gt; is the starting point, and I want to come in from an overview."&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick word on tools in other categories
&lt;/h2&gt;

&lt;p&gt;Let me also address the "but we already run Datadog for monitoring" question. This is less a comparison than a sorting-out of layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metrics platforms&lt;/strong&gt; (Datadog, Grafana + postgres_exporter, pganalyze) are for continuously accumulating metrics and watching trends and anomalies over time. Catching "it's been gradually getting worse since last week" is this layer's job, and that's where it earns its keep. But that's the "look back later / keep a standing watch" layer, and it lives on a different time axis from "the first 30 seconds right after the alert fires, what's happening in the terminal in front of me right now." I think of it as a different layer, not a competitor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GUIs&lt;/strong&gt; (pgAdmin, DBeaver) are tools for browsing schemas and writing queries. They're not what you open in that moment when the cursor is blinking at the start of an incident. Different category too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion — "the best tool" depends on the situation
&lt;/h2&gt;

&lt;p&gt;The conclusion of an honest stock-take comes out like this.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you're on self-managed Linux and want to look at OS and DB together to sort out "is it the host or the DB," then pgcenter. Its system-stats correlation is still first-rate.&lt;/li&gt;
&lt;li&gt;If you're SSH'd into the production host and want a mature, proven standard, then pg_activity. It's ahead on maturity and adoption.&lt;/li&gt;
&lt;li&gt;If you want long-term trends and standing monitoring, then Datadog / Grafana / pganalyze. That's a different layer.&lt;/li&gt;
&lt;li&gt;And if you want to get oriented right after the alert fires, on managed Postgres, from the Mac in front of you, then the tool trying to fill that corner is pgincident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a story about "the design's starting point sits on managed × &lt;code&gt;pg_monitor&lt;/code&gt;," not about "a track record of field verification piling up on RDS." Verification has mostly progressed on local PG16 so far, and that honestly remains a weak point. Even so, as far as I know there's no other first-response TUI whose assumed best case faces managed services from the start. What I'm asserting is the design's direction, not a track record.&lt;/p&gt;

&lt;p&gt;pgincident isn't a tool I built to beat everything. By dropping system stats, narrowing the feature set, and shifting its assumed best case to "managed × &lt;code&gt;pg_monitor&lt;/code&gt;," it's trying to take on just the one corner that the existing tools have a structurally harder time reaching.&lt;/p&gt;

&lt;p&gt;My answer to "couldn't you just use pg_activity?" is "in many cases, yes, exactly." If you can put it on the production host, on Linux, and want to see all the way down to system stats, the existing tools are the better call. But if it's 2 a.m. and you want to get into RDS from the Mac in front of you and form a hunch about which category of incident this is within 30 seconds — for that one narrow case, and that case alone, pgincident exists.&lt;/p&gt;

&lt;p&gt;So the conclusion of this stock-take isn't "use this," it's "choose by the situation." If you want to SSH into EC2 and look at the whole host, pgcenter; for a mature standard living on the production host, pg_activity; for tracking trends, Datadog; and for managed × the machine in front of you × first response, pgincident. Tools are stuck to their situations, and no single one covers all of them. Pick based on the situation you most often find yourself in. For me, that just happened to be "RDS from the Mac, late at night."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/pgincident" rel="noopener noreferrer"&gt;pgincident&lt;/a&gt; is on GitHub.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>The first 30 seconds of a Postgres incident: why they take 30 minutes</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 23 Jun 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/the-first-30-seconds-of-a-postgres-incident-why-they-take-30-minutes-5bnb</link>
      <guid>https://dev.to/shinagawa-web/the-first-30-seconds-of-a-postgres-incident-why-they-take-30-minutes-5bnb</guid>
      <description>&lt;p&gt;2 a.m. PagerDuty goes off. "Production is slow."&lt;/p&gt;

&lt;p&gt;You open your laptop, fire up psql with bleary eyes, and connect to production. The prompt comes up. The cursor blinks after &lt;code&gt;production=&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And your hands stop.&lt;/p&gt;

&lt;p&gt;Now, where was I supposed to look first? Do I get an overview of the whole DB, or do I start drilling into individual queries? It's been a while since the last incident, and the first move doesn't come to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  "What kind of incident is this even?" — and you freeze
&lt;/h2&gt;

&lt;p&gt;"Production is slow." If you could act on that much information, this would be easy. Is the slowness in the code or the DB? Is one runaway slow query thrashing, or is the overall load up? Are connections exhausted? Is a chain of lock waits piling up somewhere? Is an &lt;code&gt;idle in transaction&lt;/code&gt; session holding a transaction open?&lt;/p&gt;

&lt;p&gt;These are all different incidents. Different places to look, different moves to make. Do you check &lt;code&gt;pg_stat_activity&lt;/code&gt;? &lt;code&gt;pg_locks&lt;/code&gt;? Cache hit ratio in &lt;code&gt;pg_stat_database&lt;/code&gt;? Before you can decide which &lt;code&gt;pg_stat_*&lt;/code&gt; to hit, you need a hunch about which category of incident this is.&lt;/p&gt;

&lt;p&gt;But you don't have anything yet to form that hunch.&lt;/p&gt;

&lt;p&gt;What you need, really, is the big picture. The goal is to narrow it down. "Ah, this is connection exhaustion." "No, this is a lock." Only once you've placed the incident into a category does the first query become obvious. And for that, you first want to see — on one screen — what the entire database looks like at this exact moment. How are connections doing? Is TPS spiking or dropping? Is anything waiting? You don't want the overview for its own sake. It's the footing you need before you can make the call.&lt;/p&gt;

&lt;p&gt;But what psql gives you is one query, one snapshot. Fire off a &lt;code&gt;SELECT&lt;/code&gt; and you get back a single slice. No big picture. You want the whole picture, and you have to start by guessing the right query to get a view of it.&lt;/p&gt;

&lt;p&gt;So you pick the first query on instinct. Usually it's "&lt;code&gt;pg_stat_activity&lt;/code&gt; for now." Not wrong. But you're running it without knowing whether it's the right call. With no hunch yet, you're running the very query that was supposed to give you one.&lt;/p&gt;

&lt;p&gt;Guess wrong and you pay for it in extra time. Once, I saw the connection count was unusually high, decided "connection exhaustion," suspected the app's connection pool, and nearly started a conversation about adding servers. The actual culprit was a single long query thrown by a nightly batch. It had settled in and clogged everything behind it, and the connections were just stacking up as a result. I'd looked at the result (connection count) and mistaken it for the cause (the long query). If I'd been able to see the whole thing on one screen first, I'd probably have caught it in a couple of minutes.&lt;/p&gt;

&lt;p&gt;These few minutes — from the alert firing to the first keystroke — are the most nerve-wracking part.&lt;/p&gt;

&lt;h2&gt;
  
  
  You don't have the queries memorized
&lt;/h2&gt;

&lt;p&gt;Say you commit to "&lt;code&gt;pg_stat_activity&lt;/code&gt; first." The next problem arrives.&lt;/p&gt;

&lt;p&gt;You can't write that query, properly filtered, from memory.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SELECT * FROM pg_stat_activity&lt;/code&gt; you can write. You can — but run it and 143 rows scroll past, and you can't tell which one is the culprit. What you want is "only the active, long-running queries, ordered by duration descending, only the columns I need." Can you write that &lt;code&gt;SELECT&lt;/code&gt;, with the &lt;code&gt;state = 'active'&lt;/code&gt; filter and the &lt;code&gt;now() - query_start&lt;/code&gt; math, at 2 a.m. with nothing in front of you? I can't.&lt;/p&gt;

&lt;p&gt;So you open a browser. You search "postgres long running queries." You open Stack Overflow. You copy a query. You paste it into psql. &lt;code&gt;ERROR: column "waiting" does not exist&lt;/code&gt;. An old answer referencing a column that was removed in PG 9.6. You fix it.&lt;/p&gt;

&lt;p&gt;Locks are even worse. JOIN &lt;code&gt;pg_locks&lt;/code&gt; and &lt;code&gt;pg_stat_activity&lt;/code&gt; to produce the pairs of which session is blocking which — how many people have that query memorized? You search again. You paste again.&lt;/p&gt;

&lt;p&gt;Then you remember, "we put a query collection in the team wiki," and go looking for it. It does exist. But it was last updated two years ago, and half of it doesn't run as-is. Figuring out which ones still work and which are stale is, again, more searching. In the middle of an incident, you're bouncing between search results and a wiki instead of your own repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is that the cause, or the result?
&lt;/h2&gt;

&lt;p&gt;So you go through all that trouble and finally produce a list of long-running queries. At the top sits a query that's been running for 2 minutes 14 seconds.&lt;/p&gt;

&lt;p&gt;Is this the culprit? You don't know.&lt;/p&gt;

&lt;p&gt;Is the query itself heavy and slow, or is it being blocked somewhere else and just sitting there long as a &lt;em&gt;result&lt;/em&gt; of waiting? One snapshot can't separate the two. You think you've grabbed the cause, when you might be looking at the victim.&lt;/p&gt;

&lt;p&gt;To separate them, you also need to look at the lock side. Which means another query. And then you reconcile both in your head. At 2 a.m.&lt;/p&gt;

&lt;h2&gt;
  
  
  And you blink, and 30 minutes are gone
&lt;/h2&gt;

&lt;p&gt;Queries are snapshots, so if you want to follow how things change, you have to re-run them by hand. Hit &lt;code&gt;pg_stat_activity&lt;/code&gt; once. The situation shifts. Hit it again. Recall the history with &lt;code&gt;↑&lt;/code&gt;, hit enter. Run it again.&lt;/p&gt;

&lt;p&gt;Written out, the first response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Search "postgres running queries"
→ Copy a query from Stack Overflow
→ Fix it for a PG version mismatch
→ Sort by duration
→ Wonder: is this the cause or the result?
→ Search for a separate locks query
→ Paste and run
→ The situation changed, so re-run everything
→ …
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It feels like an instant. Your hands never stop. Searching, pasting, fixing, re-running. And yet you glance at the clock and 30 minutes have passed.&lt;/p&gt;

&lt;p&gt;For those 30 minutes, the incident channel in Slack keeps growing. When someone asks "what's the status?", all you can say is "still investigating." Your hands haven't stopped. You've been hitting something the whole time. And yet you haven't even taken the first step toward recovery. You don't even have a hunch about the cause.&lt;/p&gt;

&lt;p&gt;One tool has a tagline: "the first 30 seconds of a Postgres incident." But with nothing in hand, those "30 seconds" quietly balloon into 30 minutes of manual queries and searching. Where it should have taken 30 seconds, 30 minutes go by. That gap is what hits hardest in a late-night incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually wanted was "now, on one screen"
&lt;/h2&gt;

&lt;p&gt;Looking back, what I wanted in the first 30 seconds was always the same thing.&lt;/p&gt;

&lt;p&gt;"What does the database look like right now" — on one screen. Are connections filling up? Has cache hit ratio dropped? Are there long-running queries? Is a lock queue forming? Is a session sitting with a transaction open? I want to see this as one picture first, without memorizing queries and without searching.&lt;/p&gt;

&lt;p&gt;Then, having placed it — "this is connection exhaustion," "no, this is a lock" — I want to drill down only in that direction. Overview first, then dig. The order was always supposed to be this, and yet with psql I had no choice but to start from the one query for "digging."&lt;/p&gt;

&lt;p&gt;Because I was digging without an overview, I dug holes on instinct and filled them back in, and 30 minutes went by.&lt;/p&gt;

&lt;h2&gt;
  
  
  pgincident — putting that into one TUI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/pgincident" rel="noopener noreferrer"&gt;pgincident&lt;/a&gt; is a tool that packs that "overview first, then dig" into a single TUI in your terminal. Instead of the many queries you'd hit in psql, you take your guess from an overall-health overview screen, then drop into a category dashboard to dig.&lt;/p&gt;

&lt;p&gt;Setup is nothing dramatic. On macOS, you install it with Homebrew.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap shinagawa-web/tap
brew &lt;span class="nb"&gt;install &lt;/span&gt;pgincident
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Linux / macOS, a one-liner works too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/shinagawa-web/pgincident/main/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting started is three steps. First, generate a config file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgincident &lt;span class="nt"&gt;--init&lt;/span&gt;
&lt;span class="c"&gt;# Created /your/project/.pgincident.toml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write your connection details into the generated &lt;code&gt;.pgincident.toml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[connections.default]&lt;/span&gt;
&lt;span class="py"&gt;dsn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"postgres://user:password@localhost:5432/mydb"&lt;/span&gt;

&lt;span class="nn"&gt;[thresholds]&lt;/span&gt;
&lt;span class="py"&gt;long_running&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"5s"&lt;/span&gt;
&lt;span class="py"&gt;idle_in_transaction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"30s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then just launch it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgincident
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connecting role doesn't have to be a superuser. As long as it's a member of the &lt;code&gt;pg_monitor&lt;/code&gt; role, it works. This is a quiet detail, but it matters — on managed Postgres like RDS or Cloud SQL, you often can't get a superuser at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you see in the first 30 seconds
&lt;/h2&gt;

&lt;p&gt;When you launch it, the first thing you get is the Overview screen. That "now, on one screen" is right here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;primary  10.0.1.42:5432  PG 16.1                              interval: 5.0s
──────────────────────────────────────────────────────────────────────────
  DB Health Overview
──────────────────────────────────────────────────────────────────────────

  Metric                Value                 Status
  ──────────────────────────────────────────────────
  Connections           142 / 200 (71%)       OK
  TPS                   2340                  OK
  Cache hit             99.2%                 OK
  Checkpoints           req: 0                OK
  Autovacuum            0 workers             OK

──────────────────────────────────────────────────────────────────────────
[o]dashboard  [q]uit  [+/-]interval  [?]help
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connections, TPS, Cache hit, Checkpoints, Autovacuum. Each has a threshold set, and the badge reads &lt;code&gt;OK&lt;/code&gt; when healthy, &lt;code&gt;WARN&lt;/code&gt; when shaky, &lt;code&gt;CRIT&lt;/code&gt; when bad. If your setup has a replication standby, a Replication lag row joins them.&lt;/p&gt;

&lt;p&gt;This is exactly what I wanted at 2 a.m. If Connections is red at &lt;code&gt;90%&lt;/code&gt;, this looks like connection exhaustion. If Cache hit has dropped, another lead opens up. Without memorizing queries, without searching, the hunch about "which category of incident" forms right here. And this screen refreshes itself on the interval you set, so there's no re-running it by hand.&lt;/p&gt;

&lt;p&gt;Once you've placed it, press &lt;code&gt;o&lt;/code&gt; to drop into the Dashboard screen. Here, the things you'd hit with several queries in psql line up in three categories.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long-running queries&lt;/strong&gt; — active queries still running past the threshold (default 5s)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locks&lt;/strong&gt; — pairs of the blocking and the blocked sessions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle in transaction&lt;/strong&gt; — sessions left holding a transaction open past the threshold (default 30s)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That "cause or result?" separation you were stuck on moves forward here. The list of long-running queries and the blocking relationships of locks are on the same screen at the same time. If the top long-running query shows up on the blocked side, it's not the culprit — it's the victim. No need to re-run a separate query and reconcile it in your head.&lt;/p&gt;

&lt;p&gt;Press &lt;code&gt;Enter&lt;/code&gt; on a long-running query row and its full text opens in an overlay. Formatted with line breaks, keywords highlighted.&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="err"&gt;┌─&lt;/span&gt; &lt;span class="n"&gt;Query&lt;/span&gt; &lt;span class="n"&gt;Detail&lt;/span&gt; &lt;span class="err"&gt;──────────────────────────────────────────────────────────┐&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="n"&gt;PID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12345&lt;/span&gt;   &lt;span class="k"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;   &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&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="mi"&gt;32&lt;/span&gt;   &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;     &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="err"&gt;───────────────────────────────────────────────────────────────────────&lt;/span&gt; &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;                                                                   &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                                &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_amount&lt;/span&gt;                            &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;                                                             &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;                                       &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;                                               &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;                         &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;                                              &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;                                                               &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;└─────────────────────────────────────────────────────────────────────────┘&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;any&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;close&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've configured multiple DBs, &lt;code&gt;c&lt;/code&gt; switches the connection (the use case being to move between a primary and a replica). You adjust the polling interval with &lt;code&gt;+&lt;/code&gt; / &lt;code&gt;-&lt;/code&gt;, and &lt;code&gt;Tab&lt;/code&gt; moves between sections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the "first 30 seconds" back
&lt;/h2&gt;

&lt;p&gt;What you actually needed in the first response to an incident wasn't a new query collection, or &lt;code&gt;pg_stat_*&lt;/code&gt; syntax to re-learn. It was being able to take in "what's happening right now" as an overview on one screen, place your guess, and then dig — and to walk that order without searching or re-pasting. Because I had no way to get the overview, I kept digging on instinct — holes I'd just fill back in. What pgincident gives back is that first overview.&lt;/p&gt;

&lt;p&gt;This article covers only the first 30 seconds, from launch — the core experience. Deeper dives into lock chains, and the investigation further down the line, are for another post.&lt;/p&gt;

&lt;p&gt;2 a.m., those few minutes where your hands stopped after &lt;code&gt;production=&amp;gt;&lt;/code&gt;. So that they don't turn into 30 minutes, have the one-screen &lt;em&gt;now&lt;/em&gt; on hand first — that alone changes the first response quite a bit.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to safely remove a Rails column: finding every real reference before you delete</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 16 Jun 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/how-to-safely-remove-a-rails-column-finding-every-real-reference-before-you-delete-3b08</link>
      <guid>https://dev.to/shinagawa-web/how-to-safely-remove-a-rails-column-finding-every-real-reference-before-you-delete-3b08</guid>
      <description>&lt;p&gt;Every Rails project has at least one of these. A model with an old column that's probably not used anymore. "Probably" is the scary part. If something in production is still referencing it, deleting the column breaks the app. &lt;code&gt;NoMethodError&lt;/code&gt;, in production.&lt;/p&gt;

&lt;p&gt;You know what that looks like. It's 11:30 PM the night before sprint planning. You're tidying up the &lt;code&gt;Article&lt;/code&gt; model — the kind of low-stakes cleanup you save for when nothing urgent is on fire. You spot &lt;code&gt;summary&lt;/code&gt; in &lt;code&gt;db/schema.rb&lt;/code&gt;. It doesn't appear in any recent ticket. The last commit touching it was fourteen months ago. You look at the column definition: &lt;code&gt;t.text :summary&lt;/code&gt;. Probably a description field from some old feature. You open the controller. You don't see it used. You check the views quickly. Nothing obvious.&lt;/p&gt;

&lt;p&gt;You convince yourself it's probably safe to delete. You run the migration, deploy, go to sleep.&lt;/p&gt;

&lt;p&gt;At 7:15 AM your on-call pager fires. Five hundred errors per minute. &lt;code&gt;NoMethodError: undefined method 'summary' for an instance of Article&lt;/code&gt;. Users are getting blank pages. Logs are flooding. Slack has twelve messages: "site down?" "was it that deploy last night?" Your stomach drops. You push a rollback. The errors stop. Now you spend the morning in a postmortem figuring out that an admin reporting feature — a rarely-used export endpoint buried in &lt;code&gt;app/reports/&lt;/code&gt; — was still calling &lt;code&gt;article.summary&lt;/code&gt; to build a CSV. Nobody thought to check it. You didn't even know that file existed. The column wasn't unused; it just looked unused.&lt;/p&gt;

&lt;p&gt;That's why nobody deletes anything: you can't be sure, so you don't. It's a reasonable call — except there was never a way to get sure.&lt;/p&gt;

&lt;p&gt;The obvious thing to try: search for the column name in VS Code. In one Rails project, searching for &lt;code&gt;summary&lt;/code&gt; returned 3,847 results. I started going through them and quickly noticed: almost none were the real thing. &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; tags in ERB templates — the HTML accordion element. Translation keys in locale files. Description strings in RSpec examples. Actual code accessing &lt;code&gt;article.summary&lt;/code&gt;: 9 results.&lt;/p&gt;

&lt;p&gt;I gave up somewhere around result 50.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why full-text search isn't enough
&lt;/h2&gt;

&lt;p&gt;VS Code search and grep answer "does this string appear anywhere in this file?" That's useful for a lot of things. But when you want to know "is this column actually referenced in code?", text search picks up way too much. The column name in a string literal, in a comment, as an HTML tag name: it all counts as a hit. Sorting through them is manual work.&lt;/p&gt;

&lt;p&gt;Those 3,847 VS Code results were full-text matches. Narrowing with &lt;code&gt;grep -rn "\bsummary\b" --include="*.rb"&lt;/code&gt; to Ruby files left 847. Here's what those broke down to:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; tags in ERB templates (HTML element)&lt;/td&gt;
&lt;td&gt;312&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Translation keys in locale files (&lt;code&gt;summary:&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;td&gt;218&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description strings in RSpec &lt;code&gt;describe&lt;/code&gt; / &lt;code&gt;it&lt;/code&gt; blocks&lt;/td&gt;
&lt;td&gt;157&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comments, variable names, unrelated strings&lt;/td&gt;
&lt;td&gt;151&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actual column accesses&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;summary&lt;/code&gt; is particularly tricky because HTML5 has a &lt;code&gt;&amp;lt;details&amp;gt;/&amp;lt;summary&amp;gt;&lt;/code&gt; accordion element. If your project uses that tag in templates, every view file is a potential hit. To text search, &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; and &lt;code&gt;article.summary&lt;/code&gt; are the same thing: a string match.&lt;/p&gt;

&lt;p&gt;Even filtering to Ruby files, any &lt;code&gt;'summary'&lt;/code&gt; string in a serializer field list or a comment still hits. "Ruby files only" and "actual column access" are completely different things.&lt;/p&gt;

&lt;p&gt;The more you refine the regex, the more you start wondering whether the regex itself is missing something. You end up needing to verify the verification.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;summary&lt;/code&gt; is not even the worst case. Consider &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, or &lt;code&gt;type&lt;/code&gt; — column names that appear in dozens of unrelated contexts throughout a typical Rails project. Variable names, hash keys, RSpec subject descriptions, i18n keys, FactoryBot attributes. Hundreds of hits. Same problem, worse noise.&lt;/p&gt;

&lt;p&gt;Try it right now if you're curious. Open a Rails project you've been on for a year. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Count the results. Most of them have nothing to do with the &lt;code&gt;status&lt;/code&gt; column you're thinking about. They're local variables, hash keys in unrelated parts of the app, &lt;code&gt;describe "updates the status"&lt;/code&gt; in test files. The signal you need — "is &lt;code&gt;article.status&lt;/code&gt; actually accessed somewhere?" — is buried in hundreds of lines of noise.&lt;/p&gt;

&lt;p&gt;There's also the psychological cost. You open VS Code, run the search, see 847 results for &lt;code&gt;status&lt;/code&gt;, and your shoulders drop. You close the tab. You tell yourself you'll check it later. "Later" never comes. Nobody should have to hand-verify 847 results to answer a yes/no question.&lt;/p&gt;

&lt;p&gt;"I searched, couldn't check everything, left it alone." Most Rails developers have been here. You want to delete the column but can't. Checking properly is possible, but it costs more time than the cleanup is worth. So the column stays, and they pile up.&lt;/p&gt;

&lt;p&gt;So the columns accumulate. Here's what that does to a codebase.&lt;/p&gt;

&lt;p&gt;The schema bloats. You open &lt;code&gt;db/schema.rb&lt;/code&gt; and it's 600 lines. You scroll past columns you half-recognize — &lt;code&gt;legacy_body&lt;/code&gt;, &lt;code&gt;old_slug&lt;/code&gt;, &lt;code&gt;deprecated_export_format&lt;/code&gt;, &lt;code&gt;summary&lt;/code&gt; — added by developers who've since moved on, tied to tickets that closed eighteen months ago. Nobody knows what they did, so nobody touches them. Every &lt;code&gt;SELECT *&lt;/code&gt; drags them along. Every &lt;code&gt;Article.new&lt;/code&gt; builds an object with two dozen attributes, most of them nil because they've been unused for a year.&lt;/p&gt;

&lt;p&gt;A new developer joins and runs &lt;code&gt;Article.column_names&lt;/code&gt; to understand the schema, then stares at the output. "What's &lt;code&gt;legacy_body&lt;/code&gt;? What does &lt;code&gt;deprecated_export_format&lt;/code&gt; mean?" They ask in Slack. Nobody knows for certain, and the answer is "don't touch those." Reasonable in isolation. But repeat that across five models and it hardens into an unspoken rule: don't touch anything you didn't write.&lt;/p&gt;

&lt;p&gt;Design options narrow with it. You want to add a &lt;code&gt;content_format&lt;/code&gt; column, but you can't tell whether &lt;code&gt;legacy_body&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt; are two competing implementations of the same concept or two genuinely different things. So the new feature gets bolted onto the side of the model instead of replacing the old thing cleanly. Every migration feels slightly riskier because the schema is full of things nobody understands, and the unease compounds until nobody touches anything at all. Six months later, another developer hits the same dead end.&lt;/p&gt;

&lt;p&gt;This is the same kind of debt as missing tests: it accumulates quietly and you can never point to the moment it started. The real cost is cognitive load. Every unused column is a small tax on everyone who reads the model. Thirty columns, two years of new developers, one noisy on-call incident caused by a column that was supposed to be gone: that's how "we never clean up old columns" becomes a drag that's hard to measure and impossible to ignore. And none of it is a skills problem — the tool to check properly just didn't exist yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  How colref reads code structure instead of text
&lt;/h2&gt;

&lt;p&gt;What you actually wanted to know was: where is &lt;code&gt;article.summary&lt;/code&gt; referenced in Ruby code? &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;colref&lt;/a&gt; answers that, ignoring ERB &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; tags and locale-file &lt;code&gt;summary:&lt;/code&gt; keys.&lt;/p&gt;

&lt;p&gt;How does it tell the difference? Instead of treating code as a sequence of characters, it reads the code structure.&lt;/p&gt;

&lt;p&gt;When you write &lt;code&gt;article.summary&lt;/code&gt;, Ruby sees "call the &lt;code&gt;summary&lt;/code&gt; method on the &lt;code&gt;article&lt;/code&gt; object" — a specific structure. &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; is written as an HTML tag name — structurally, it's not a method call. &lt;code&gt;:summary&lt;/code&gt; is written as a symbol. Reading code structure makes those differences detectable. Only places written as &lt;code&gt;object.column_name&lt;/code&gt; get picked up. HTML tags, symbols, and strings that happen to contain &lt;code&gt;summary&lt;/code&gt; are ignored.&lt;/p&gt;

&lt;p&gt;Text search is Ctrl+F. Reading code structure is closer to a human reading through every line — except it handles thousands of lines in a second.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Hits (for &lt;code&gt;summary&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;What it sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VS Code full-text search&lt;/td&gt;
&lt;td&gt;3,847&lt;/td&gt;
&lt;td&gt;All string matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grep &lt;code&gt;\bsummary\b&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;847&lt;/td&gt;
&lt;td&gt;Word-boundary matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;colref&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Actual column accesses only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;3,847 or 847 becomes 9. Whether you can act on the results depends entirely on how many there are.&lt;/p&gt;

&lt;p&gt;When you get 9 results: open each one. &lt;code&gt;app/controllers/articles_controller.rb:42&lt;/code&gt; means go to that line and check whether &lt;code&gt;article.summary&lt;/code&gt; is actually being accessed there. Nine results takes maybe 15 minutes.&lt;/p&gt;

&lt;p&gt;A few things you'll encounter while reviewing results: the column appearing in a migration file (colref skips migrations, but if it surfaced it, the migration is just recording the column's history, not actively using it). You might also see test factories or fixtures that assign the column's value. If you delete the column and forget to clean up the factory, your test suite will fail. That's not a reason not to delete — it's just something to handle as part of the deletion.&lt;/p&gt;

&lt;p&gt;When you get zero: you have a fact. "Not found in Ruby code" is different from "I think it's probably fine." It's the signal to move on: dynamic access patterns, templates, Strong Parameters, serializers. Treat a zero from colref as the first check, with several more still to run.&lt;/p&gt;

&lt;p&gt;The shift is from "check 847 things" to "check 9 things, then a handful of specific files." That's the difference between a task you'll defer indefinitely and one you'll do today.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;

&lt;p&gt;Add to your Gemfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle add colref &lt;span class="nt"&gt;--group&lt;/span&gt; development
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or install globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gem &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Specify the model name, field name, and your project directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; rails &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; summary ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results come back as &lt;code&gt;filename:line_number&lt;/code&gt;. Each one is something you can open directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What zero results doesn't cover
&lt;/h2&gt;

&lt;p&gt;Zero results doesn't mean "safe to delete." It means "not found in Ruby code," and the gap between those two is where columns come back to bite you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic Access Pattern:&lt;/strong&gt; colref detects literal-symbol forms like &lt;code&gt;article.send(:summary)&lt;/code&gt; and &lt;code&gt;article.read_attribute(:summary)&lt;/code&gt; — these appear in results with a &lt;code&gt;[symbol]&lt;/code&gt; confidence label, meaning they need manual verification. What colref cannot catch is when the method name is stored in a variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# colref cannot catch this — field name is in a variable, not on the same call&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:body&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="kp"&gt;attr&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The variable form can't be reliably caught with a single grep either — &lt;code&gt;send.*summary&lt;/code&gt; only matches lines where &lt;code&gt;send&lt;/code&gt; and &lt;code&gt;summary&lt;/code&gt; appear together, which misses the loop above entirely. To find this pattern, read every &lt;code&gt;send&lt;/code&gt;, &lt;code&gt;public_send&lt;/code&gt;, and &lt;code&gt;read_attribute&lt;/code&gt; call site directly. As a starting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"send.*summary&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;read_attribute.*summary"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches the literal form (&lt;code&gt;article.send(:summary)&lt;/code&gt;) that colref already surfaces, and it's a useful double-check. But don't treat zero results as proof there's no variable-form usage — scan the call sites by eye for the loop pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symbol Permit Pattern:&lt;/strong&gt; The column name appears as a symbol in &lt;code&gt;permit&lt;/code&gt; inside controllers, which colref does not detect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# controller&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;article_params&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:article&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;colref won't detect this &lt;code&gt;:summary&lt;/code&gt; symbol. Opening the controller file directly is the reliable check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serializer Field Pattern:&lt;/strong&gt; Blueprinter, JSONAPI::Serializer, ActiveModelSerializers — all list fields as strings or symbols:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Blueprinter&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleBlueprint&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Blueprinter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
  &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# JSONAPI::Serializer&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSerializer&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;JSONAPI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Serializer&lt;/span&gt;
  &lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# ActiveModelSerializers&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSerializer&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveModel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Serializer&lt;/span&gt;
  &lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Determining which model the &lt;code&gt;:summary&lt;/code&gt; symbol in a list refers to requires tracing class inheritance, which colref doesn't handle yet. Serializer files tend to be few in number — opening them directly is the reliable check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ERB templates:&lt;/strong&gt; &lt;code&gt;&amp;lt;%= @article.summary %&amp;gt;&lt;/code&gt; lives in &lt;code&gt;.html.erb&lt;/code&gt; files. colref only scans &lt;code&gt;.rb&lt;/code&gt; files. Check templates separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"summary"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.erb"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;&amp;lt;summary&amp;gt;&lt;/code&gt; tags will also hit here, so results will be noisy. Narrowing to &lt;code&gt;@article.summary&lt;/code&gt; or &lt;code&gt;article.summary&lt;/code&gt; is more practical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ActiveAdmin / RailsAdmin:&lt;/strong&gt; If you're displaying or editing the column in an admin interface, the reference is likely a string or symbol there too. I've seen &lt;code&gt;column :summary&lt;/code&gt; sitting in an ActiveAdmin &lt;code&gt;show&lt;/code&gt; block for a column that had been "confirmed removed" twice already — nobody checked the admin file because it only gets opened once a month. If your project uses either, check those files as well.&lt;/p&gt;

&lt;p&gt;Checking serializers and admin files by eye sounds tedious, but in practice it takes a few minutes. These files tend to be organized by model. Open &lt;code&gt;app/serializers/article_serializer.rb&lt;/code&gt;, find the relevant serializer, check the &lt;code&gt;attributes&lt;/code&gt; list. Open &lt;code&gt;app/admin/article.rb&lt;/code&gt; if you use ActiveAdmin. This isn't a grep problem. You open two files and look, and you're done — no tooling required.&lt;/p&gt;

&lt;p&gt;colref (Ruby attribute accesses) + grep (variable dynamic patterns and templates) + manual check (Strong Parameters, serializers, admin) covers the vast majority of real-world Rails codebases. There are edge cases colref doesn't handle yet; the &lt;a href="https://shinagawa-web.github.io/colref/docs/detection-patterns/" rel="noopener noreferrer"&gt;Detection Patterns&lt;/a&gt; docs list them. For most projects, this three-part check is enough to move from "I think it's probably unused" to "I have confirmed it's unused."&lt;/p&gt;

&lt;p&gt;Once you've gone through all of that and colref returns zero, that's a grounded deletion rather than a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The procedure
&lt;/h2&gt;

&lt;p&gt;Here's the full sequence I run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Check for column accesses in Ruby code&lt;/span&gt;
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; rails &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; summary ./

&lt;span class="c"&gt;# 2. Check for dynamic access (catches literal form; scan call sites for variable form)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"send.*summary&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;read_attribute.*summary"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; ./

&lt;span class="c"&gt;# 3. Check ERB templates (narrow to .summary access)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;summary"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.erb"&lt;/span&gt; ./

&lt;span class="c"&gt;# 4. Check Strong Parameters (controllers)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;":summary&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;'summary'"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; app/controllers/

&lt;span class="c"&gt;# 5. Check serializers and admin&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;":summary&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;'summary'"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.rb"&lt;/span&gt; app/serializers/ app/admin/

&lt;span class="c"&gt;# 6. Generate the removal migration&lt;/span&gt;
rails generate migration RemoveSummaryFromArticles summary:string

&lt;span class="c"&gt;# 7. Apply to the schema&lt;/span&gt;
rails db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 2–5 are still grep — colref doesn't solve everything. But step 1 cuts 847 results down to 9. The "too many results to check, left it alone" situation: this is the one place that changes.&lt;/p&gt;

&lt;p&gt;One more thing about steps 6 and 7: give the migration a descriptive name like &lt;code&gt;RemoveSummaryFromArticles&lt;/code&gt;. Six months from now, someone scanning migration filenames can see what changed and when without opening every file. Run the migration locally and make sure your test suite passes before deploying. When you're confident about a deletion it's tempting to skip verification. Don't. If a factory is still setting the deleted column, tests will catch it before production does.&lt;/p&gt;

&lt;p&gt;The whole process — run colref, run the checklist, generate the migration, run tests locally, deploy — takes maybe 30 minutes for a column that's actually unused. Compare that to leaving it in &lt;code&gt;db/schema.rb&lt;/code&gt; for another year because you couldn't confirm it was safe.&lt;/p&gt;

&lt;p&gt;The difference between "I think it's probably unused" and "zero results in Ruby code, no dynamic &lt;code&gt;send&lt;/code&gt; call sites, nothing in templates" matters when something goes wrong. Knowing what you checked tells you exactly where the cause wasn't — which narrows down where it was. A grounded deletion makes the debugging faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: try a column you know is used
&lt;/h2&gt;

&lt;p&gt;If you don't have a deletion candidate in mind, start with a column you know is in use — something like &lt;code&gt;title&lt;/code&gt; on &lt;code&gt;Article&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; rails &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; title ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;title&lt;/code&gt; is actively used, you'll get multiple results with file and line number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/controllers/articles_controller.rb:42
app/helpers/articles_helper.rb:11
app/serializers/article_serializer.rb:5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing what a real result looks like makes it easier to judge zero results later. Then try a column you've been wondering about. Close to zero? Move to steps 2–5.&lt;/p&gt;

&lt;p&gt;From installation to first run: under five minutes. Running it is faster than reading the README.&lt;/p&gt;

&lt;p&gt;What do you do when you get 3 results? Open all three. For each one: is this code still running in production? If a reference is inside a clearly dead code path — wrapped in a feature flag that was turned off, or a method that's never called — it doesn't count as a real reference. If it's live code, the column is still in use. But 3 results is a manageable number. You can make that judgment call.&lt;/p&gt;

&lt;p&gt;What if you get 0 results? Don't stop there. Run steps 2–5. Zero from colref, nothing from the dynamic access grep, clean templates, nothing in the controllers or serializers: that's multiple independent checks pointing the same direction. At that point you have something solid to stand on.&lt;/p&gt;

&lt;p&gt;Even without a deletion candidate right now, colref fits into routine schema review. Scan &lt;code&gt;db/schema.rb&lt;/code&gt;, spot something that looks unused, run colref. Zero results — it goes on the list. "Probably unused" becomes "not referenced in Ruby code" in 30 seconds. Do this periodically on projects you maintain. Every few months scan the migration history for columns you don't recognize, run colref on them, and build a short list. Some end up staying because they're used in ways colref doesn't detect. But a few always turn out to be genuinely gone: references removed over time, nobody noticed, nobody cleaned it up. Those get deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can colref be added to CI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The use case is catching references to a deleted column that sneak back in through someone's PR. &lt;code&gt;colref check&lt;/code&gt; exits with code 0 when there are zero results, so it fits as a step in GitHub Actions or CircleCI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check removed column references&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;colref check --orm rails --model Article --field summary ./&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When something other than zero results comes back, the CI step fails. That prevents deleted column references from ever making it to main.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should the team know before using this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The important thing to communicate is what colref doesn't detect — Strong Parameters, serializers, ERB templates, and variable-symbol dynamic access. If "colref returned zero so it's safe" becomes the assumption without those additional checks, things get missed. Documenting a checklist alongside colref — "colref covers direct access in Ruby code; run these greps and open these files for the rest" — means new team members get the full picture from day one.&lt;/p&gt;

&lt;p&gt;colref is still in development. If something doesn't work or you get unexpected results, open an issue at &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;github.com/shinagawa-web/colref&lt;/a&gt;. Real usage feedback is what shapes the priorities.&lt;/p&gt;

&lt;p&gt;colref currently supports Django and Rails. For the roadmap, see &lt;a href="https://github.com/shinagawa-web/colref/issues/74" rel="noopener noreferrer"&gt;issue #74&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How many columns are you sitting on that you haven't been able to delete?&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>"Renaming `user` when grep can't tell which model you mean"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 09 Jun 2026 13:05:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/renaming-user-when-grep-cant-tell-which-model-you-mean-22fc</link>
      <guid>https://dev.to/shinagawa-web/renaming-user-when-grep-cant-tell-which-model-you-mean-22fc</guid>
      <description>&lt;p&gt;I stopped mid-task trying to add an &lt;code&gt;editor&lt;/code&gt; field to the &lt;code&gt;Article&lt;/code&gt; model.&lt;/p&gt;

&lt;p&gt;The problem was the existing &lt;code&gt;user&lt;/code&gt; field. Adding &lt;code&gt;editor&lt;/code&gt; would leave &lt;code&gt;Article&lt;/code&gt; with two foreign keys to &lt;code&gt;User&lt;/code&gt; sitting side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&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;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&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;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_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;edited_articles&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;I could already see the code review comment. "What is &lt;code&gt;user&lt;/code&gt; here? The author? The last person who touched it?"&lt;/p&gt;

&lt;p&gt;The right move was to rename &lt;code&gt;user&lt;/code&gt; to &lt;code&gt;author&lt;/code&gt; first, then add &lt;code&gt;editor&lt;/code&gt;. Leave it as-is and that ambiguity is baked into the codebase permanently. Every new developer who opens the model will have the same question. The field name is wrong and everyone who encounters it will eventually notice.&lt;/p&gt;

&lt;p&gt;The problem was how much that rename scared me. &lt;code&gt;user&lt;/code&gt; doesn't live only inside the &lt;code&gt;Article&lt;/code&gt; model definition. It gets accessed as &lt;code&gt;article.user&lt;/code&gt; in views, output as &lt;code&gt;user_id&lt;/code&gt; in serializers, listed in admin's &lt;code&gt;list_display&lt;/code&gt;, returned as a response key the frontend depends on. Every one of those places needs to change to &lt;code&gt;author&lt;/code&gt; or &lt;code&gt;author_id&lt;/code&gt;. The serializer's &lt;code&gt;fields&lt;/code&gt; list, the frontend's expected key names — all of it.&lt;/p&gt;

&lt;p&gt;Miss one and here's what happens: you renamed &lt;code&gt;article.user&lt;/code&gt; to &lt;code&gt;article.author&lt;/code&gt; in &lt;code&gt;views.py&lt;/code&gt; but missed &lt;code&gt;fields = ['user_id']&lt;/code&gt; in the serializer. The API can no longer return &lt;code&gt;author_id&lt;/code&gt;. The frontend's expecting &lt;code&gt;user_id&lt;/code&gt; and that screen breaks. Or you run the migration and there's a reference still sitting somewhere — &lt;code&gt;AttributeError: 'Article' object has no attribute 'user'&lt;/code&gt; starts appearing in production logs. The kind of bug you find after the migration is already live. The logs fill up, Slack lights up, someone asks if we should roll back.&lt;/p&gt;

&lt;p&gt;So the right order is: find every reference first, then rename. That means knowing every file and every line before touching the model definition or running any migration. The obvious tool is grep.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;user"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returned 340 hits.&lt;/p&gt;

&lt;p&gt;I ran &lt;a href="https://shinagawa-web.github.io/colref/" rel="noopener noreferrer"&gt;colref&lt;/a&gt; on the same codebase. It returned 4 hits for &lt;code&gt;Article --field user&lt;/code&gt; and 3 hits for &lt;code&gt;Article --field user_id&lt;/code&gt;. Seven results total — every actual &lt;code&gt;Article.user&lt;/code&gt; reference in the codebase.&lt;/p&gt;

&lt;p&gt;340 became 7. The gap is what this article is about.&lt;/p&gt;

&lt;p&gt;The same problem comes up with any field name that multiple models share. &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;created_by&lt;/code&gt;, &lt;code&gt;assigned_to&lt;/code&gt; — grep on a common field name returns results from every model that uses it, and there is no way to filter by which model you care about. The rename that looks straightforward from the model definition turns into a search problem the moment you try to verify what will break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Disambiguation Problem: Ten Models, One Field Name
&lt;/h2&gt;

&lt;p&gt;grep returned 340 results because &lt;code&gt;user&lt;/code&gt; is not specific to &lt;code&gt;Article&lt;/code&gt;. Every model in the project that relates to a user has a &lt;code&gt;user&lt;/code&gt; field. Here's where those 340 hits actually came from:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Comment.user&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order.user&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile.user&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment.user&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;other models&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Article.user / Article.user_id&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven out of 340. The other 333 are legitimate field accesses — not noise, not comments, not file extensions. Real code that correctly uses &lt;code&gt;.user&lt;/code&gt; on those other models. Text search has no way to distinguish them from what you're looking for.&lt;/p&gt;

&lt;p&gt;This is a different problem from the grep-noise issue that comes up when checking field deletions, where string literals, comments, and file paths inflate the count. &lt;a href="https://dev.to/shinagawa_web/grep-said-1202-the-real-answer-was-10-introducing-colref-3b22"&gt;That problem is covered in an earlier article in this series.&lt;/a&gt; Here the results are all real code. The issue is that &lt;code&gt;user&lt;/code&gt; appears on ten models, and text search cannot tell which model any given &lt;code&gt;obj.user&lt;/code&gt; belongs to.&lt;/p&gt;

&lt;p&gt;You could try narrowing the search. Filter to files that import &lt;code&gt;Article&lt;/code&gt;, or grep for &lt;code&gt;article.user&lt;/code&gt; with a lowercase &lt;code&gt;a&lt;/code&gt; expecting the variable name to match. Every refinement introduces a new failure mode. What if a view stores the article in a variable named &lt;code&gt;obj&lt;/code&gt; or &lt;code&gt;instance&lt;/code&gt;? What if it comes from &lt;code&gt;get_object_or_404&lt;/code&gt; and the variable name is different from what you searched for? A tighter grep pattern gives you more confidence in the hits it returns, but zero information about what it missed. You end up needing to verify your verification.&lt;/p&gt;

&lt;p&gt;Checking all 340 results by hand isn't impossible but it's exactly the kind of task that gets postponed indefinitely. You open the first file, see that it's &lt;code&gt;Comment.user&lt;/code&gt;, close it, open the next one — &lt;code&gt;Order.user&lt;/code&gt; — and somewhere around result 30 you realize this is going to take the rest of the afternoon. You close the tab and tell yourself you'll do it tomorrow. Tomorrow you have other things. The rename doesn't happen and the ambiguity stays.&lt;/p&gt;

&lt;p&gt;The field that should have been renamed six months ago is still called &lt;code&gt;user&lt;/code&gt;. And the next time someone needs to add another user relationship to the model, the situation is even more tangled.&lt;/p&gt;

&lt;p&gt;What you need is a search that understands which model you're asking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scope Filter: Telling colref Which Model You Mean
&lt;/h2&gt;

&lt;p&gt;colref reads code as structure rather than text. It parses source files into an AST and targets attribute-access nodes — the nodes that represent &lt;code&gt;obj.field&lt;/code&gt; in running code. String literals, comments, and template paths are invisible to it. Crucially, it accepts a &lt;code&gt;--model&lt;/code&gt; flag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;span class="c"&gt;# or&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a ForeignKey field, two access patterns appear in real 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="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;      &lt;span class="c1"&gt;# fetches the User object (triggers a JOIN)
&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;   &lt;span class="c1"&gt;# fetches the FK integer directly (no JOIN)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Performance-conscious code often uses &lt;code&gt;article.user_id&lt;/code&gt; to avoid the join when only the ID is needed. Both need to be found and updated, so run both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# find obj.user accesses on Article instances&lt;/span&gt;
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; user ./

&lt;span class="c"&gt;# find obj.user_id accesses on Article instances&lt;/span&gt;
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; user_id ./
&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;# --field user
app/views.py:58
app/api.py:23
app/admin.py:11
app/tests/test_views.py:34

# --field user_id
app/serializers.py:18
app/api.py:29
app/tests/test_api.py:41
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven results. Every one is an &lt;code&gt;Article.user&lt;/code&gt; or &lt;code&gt;Article.user_id&lt;/code&gt; access. &lt;code&gt;Comment.user&lt;/code&gt;, &lt;code&gt;Order.user&lt;/code&gt;, every other model's &lt;code&gt;user&lt;/code&gt; field — excluded.&lt;/p&gt;

&lt;p&gt;How does it tell the difference? colref reads the project's &lt;code&gt;models.py&lt;/code&gt; files first and builds a map of which fields belong to which model. It confirms that &lt;code&gt;Article&lt;/code&gt; has a field named &lt;code&gt;user&lt;/code&gt;. Then it walks the AST looking for attribute-access nodes and applies the model context: &lt;code&gt;comment.user&lt;/code&gt; is classified as a &lt;code&gt;Comment&lt;/code&gt; instance access and filtered out. It's not searching for the string &lt;code&gt;user&lt;/code&gt; — it's reading the syntax tree and reasoning about which model each access belongs to.&lt;/p&gt;

&lt;p&gt;Seven results is a number you can act on. Open each file, verify the context, update the reference. At a reasonable pace that's fifteen to twenty minutes of work. For each result, the question is simple: is this code still active in production? If the access is inside a function you know is live, update it. If it's inside commented-out code or a block that clearly never runs, note it and move on. Seven results means you can make that judgment for each one without losing track of where you are.&lt;/p&gt;

&lt;p&gt;Once you've gone through colref's output and updated every location, there are two categories colref doesn't cover: serializer &lt;code&gt;fields&lt;/code&gt; lists and admin class attributes. Neither is detected because determining which model a string like &lt;code&gt;'user_id'&lt;/code&gt; refers to inside a &lt;code&gt;fields = [...]&lt;/code&gt; list requires tracing class inheritance, which colref doesn't handle yet. Check those directly before running the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"user_id"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./app/serializers.py ./app/admin.py ./app/forms.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These files are limited in number. In most projects you have one serializers file, one admin file, maybe one forms file. Opening each and searching for &lt;code&gt;user_id&lt;/code&gt; takes a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap: One Wrong Keystroke Drops Your Data
&lt;/h2&gt;

&lt;p&gt;After updating every reference colref found and checking the serializers manually, the next step is generating the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python manage.py makemigrations &lt;span class="nt"&gt;--name&lt;/span&gt; rename_article_user_to_author
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Django detects that the old field is gone and a new one appeared, and asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Did you rename article.user to article.author (a ForeignKey)? [y/N]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default is &lt;code&gt;N&lt;/code&gt;. If you're not paying attention and hit Enter, or answer &lt;code&gt;n&lt;/code&gt; because you're not sure what Django is asking, the resulting migration looks like this:&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;# What you get if you answer n — do not use this
&lt;/span&gt;&lt;span class="n"&gt;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RemoveField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model_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;article&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user&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;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AddField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model_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;article&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(...),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RemoveField&lt;/code&gt; drops the &lt;code&gt;user_id&lt;/code&gt; column. &lt;code&gt;AddField&lt;/code&gt; creates a new empty &lt;code&gt;author_id&lt;/code&gt; column. All the data that was in &lt;code&gt;user_id&lt;/code&gt; — every article's author relationship — is gone. On a production database with existing rows, this means every &lt;code&gt;Article.author&lt;/code&gt; is now null or missing, depending on whether the field allows null. If it doesn't allow null, the migration fails partway through. Either way, not a situation you want to be in.&lt;/p&gt;

&lt;p&gt;Answer &lt;code&gt;y&lt;/code&gt;. That generates a &lt;code&gt;RenameField&lt;/code&gt; migration:&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;# What you get if you answer y — this is what you want
&lt;/span&gt;&lt;span class="n"&gt;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RenameField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model_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;article&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;old_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;user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;new_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;author&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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RenameField&lt;/code&gt; renames the column in place. The data stays exactly where it is. Every row that had a &lt;code&gt;user_id&lt;/code&gt; value now has that same value under &lt;code&gt;author_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather skip the interactive prompt and write the migration directly, use &lt;code&gt;RenameField&lt;/code&gt; explicitly:&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;migrations&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dependencies&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&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;0042_previous_migration&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;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RenameField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model_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;article&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;old_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;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;new_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;author&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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the safest approach: write the migration by hand, review it, apply it. No interactive prompt to accidentally misread.&lt;/p&gt;

&lt;p&gt;One more check before applying: run the migration against your local database and confirm your test suite still passes. If a test factory is still setting &lt;code&gt;user=...&lt;/code&gt; on &lt;code&gt;Article&lt;/code&gt; instead of &lt;code&gt;author=...&lt;/code&gt;, the tests will catch it here rather than in production. Factory and fixture files are another category colref doesn't cover — they often set fields using keyword arguments that look like function calls, not attribute accesses. A passing test suite after the migration is the confirmation that nothing was missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API Surface: Renaming Propagates Past Python
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;python manage.py migrate&lt;/code&gt; isn't the end of the rename. The model field changed from &lt;code&gt;user&lt;/code&gt; to &lt;code&gt;author&lt;/code&gt;, which means the database column changed from &lt;code&gt;user_id&lt;/code&gt; to &lt;code&gt;author_id&lt;/code&gt;. Anything that reads data from the API and expected &lt;code&gt;user_id&lt;/code&gt; in the response now receives &lt;code&gt;author_id&lt;/code&gt; instead. That's a breaking change.&lt;/p&gt;

&lt;p&gt;The places that need to change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TypeScript type definitions that declare an &lt;code&gt;Article&lt;/code&gt; interface with a &lt;code&gt;userId&lt;/code&gt; field&lt;/li&gt;
&lt;li&gt;API client code that reads &lt;code&gt;response.data.user_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;OpenAPI schemas, and any client SDKs generated from them&lt;/li&gt;
&lt;li&gt;Tests that check response payloads against &lt;code&gt;user_id&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A team that generates TypeScript types from the OpenAPI schema will have &lt;code&gt;userId&lt;/code&gt; in dozens of components, not just one. A mobile app consuming the same API has its own model types. An analytics pipeline reading the JSON might extract &lt;code&gt;user_id&lt;/code&gt; by name. None of these are visible from the Django codebase. The Python rename is only half the work — the other half is knowing what else consumed that response key and how quickly those consumers can be updated.&lt;/p&gt;

&lt;p&gt;If the backend and frontend deploy together — same release, same CI run — update both in one change and deploy together. The rename on the Python side, the type definition update on the TypeScript side, deployed atomically. That's the cleanest path when it's available.&lt;/p&gt;

&lt;p&gt;If backend and frontend deploy independently, there's a window between the backend deploy (which changes the response key from &lt;code&gt;user_id&lt;/code&gt; to &lt;code&gt;author_id&lt;/code&gt;) and the frontend deploy (which updates the code to read &lt;code&gt;author_id&lt;/code&gt;). During that window the frontend is broken.&lt;/p&gt;

&lt;p&gt;Django REST Framework's &lt;code&gt;source&lt;/code&gt; parameter lets you keep the old response key temporarily:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelSerializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# temporary bridge: API still returns user_id while the frontend catches up
&lt;/span&gt;    &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntegerField&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;author_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;read_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Article&lt;/span&gt;
        &lt;span class="n"&gt;fields&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;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;title&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;user_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this in place, the API continues returning &lt;code&gt;user_id&lt;/code&gt; after the migration while the frontend deploys its update. Once the frontend is deployed and no longer reads &lt;code&gt;user_id&lt;/code&gt;, remove the explicit field declaration and update &lt;code&gt;fields&lt;/code&gt; to include &lt;code&gt;author_id&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;More steps, but no breaking window. Teams doing continuous deployment find this easier to manage than trying to coordinate a simultaneous Python-and-frontend release: Python rename + migration in one deploy, frontend update in the next, serializer bridge removed in a third cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Naming Debt: Why &lt;code&gt;user&lt;/code&gt; Became a Liability
&lt;/h2&gt;

&lt;p&gt;Having made it through the rename, it's worth asking how the situation came up in the first place.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;user&lt;/code&gt; describes an implementation fact — "this is a ForeignKey to User" — without saying anything about what that relationship means. Is it the author? The assignee? The last editor? The name doesn't tell you. That gap becomes a problem the moment you need a second user relationship on the same model, and it becomes a grep problem the moment you need to check where the field is used.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;user&lt;/code&gt; is also the most common ForeignKey name in Django codebases by a wide margin. &lt;code&gt;Comment&lt;/code&gt;, &lt;code&gt;Order&lt;/code&gt;, &lt;code&gt;Payment&lt;/code&gt;, &lt;code&gt;Profile&lt;/code&gt; — every model that touches users tends to call the field &lt;code&gt;user&lt;/code&gt;. That's why grep returned 334 irrelevant results. The name isn't specific to any model or any relationship. It's the field name equivalent of naming a variable &lt;code&gt;data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What would have been different with &lt;code&gt;author&lt;/code&gt; from the start?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;author"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Far fewer models have an &lt;code&gt;author&lt;/code&gt; ForeignKey. Results would stay in the dozens and remain checkable by hand. You might not have needed colref at all.&lt;/p&gt;

&lt;p&gt;The principle for naming ForeignKey fields: use the name of the relationship, not the name of the target model. Instead of &lt;code&gt;user&lt;/code&gt;, use &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;assignee&lt;/code&gt;, &lt;code&gt;reviewer&lt;/code&gt;, &lt;code&gt;approver&lt;/code&gt; — whatever the relationship actually means in your domain. The field name should describe &lt;em&gt;why&lt;/em&gt; the relationship exists, not just where it points.&lt;/p&gt;

&lt;p&gt;The test is simple: can a developer reading the model definition understand the field's purpose without looking at any other file? &lt;code&gt;author&lt;/code&gt; passes that test. &lt;code&gt;user&lt;/code&gt; doesn't. A field named &lt;code&gt;user&lt;/code&gt; on an &lt;code&gt;Article&lt;/code&gt; model tells you the type of the related object but nothing about why the relationship exists. That gap grows more costly every time a new developer joins the team or a new relationship gets added to the model.&lt;/p&gt;

&lt;p&gt;That's straightforward to apply on a new project, when no one has committed to a name yet. On an inherited codebase, a product that's been running for years, "it should have been &lt;code&gt;author&lt;/code&gt; from the start" is not actionable. &lt;code&gt;user&lt;/code&gt; is already in views, serializers, tests, and migrations. The rename has to happen, and the question is only how to do it without breaking anything in production.&lt;/p&gt;

&lt;p&gt;That's where colref is useful. Seven results instead of 340. A fifteen-minute update instead of an indefinitely postponed task.&lt;/p&gt;

&lt;p&gt;The rename that's been sitting on the backlog is usually not blocked by difficulty. It's blocked by the upfront cost of a check that feels too expensive to run. Reducing that cost from hours to minutes is what makes the work actually happen.&lt;/p&gt;

&lt;p&gt;colref supports Django and Rails. For the full list of what it detects and the current roadmap, see &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;github.com/shinagawa-web/colref&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix: The rename procedure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Find every reference.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; user ./
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; user_id ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update each location colref returns. Then check serializers, admin, and forms for string references colref does not detect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"user_id"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./app/serializers.py ./app/admin.py ./app/forms.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the full list of what colref detects and does not, see the &lt;a href="https://shinagawa-web.github.io/colref/docs/detection-patterns/" rel="noopener noreferrer"&gt;Detection Patterns&lt;/a&gt; docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Rename the field in the model.&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;class&lt;/span&gt; &lt;span class="nc"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&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;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_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;articles&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;strong&gt;Step 3: Generate the migration. Answer &lt;code&gt;y&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python manage.py makemigrations &lt;span class="nt"&gt;--name&lt;/span&gt; rename_article_user_to_author
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Django asks "Did you rename article.user to article.author?", answer &lt;code&gt;y&lt;/code&gt;. &lt;code&gt;n&lt;/code&gt; generates &lt;code&gt;RemoveField + AddField&lt;/code&gt; and drops your data. &lt;code&gt;y&lt;/code&gt; generates &lt;code&gt;RenameField&lt;/code&gt; and keeps it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Run your tests locally, then apply.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python manage.py migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 5: Handle the API surface.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Update TypeScript types, API clients, and OpenAPI schemas. If the frontend cannot deploy at the same time, add &lt;code&gt;user_id = serializers.IntegerField(source='author_id')&lt;/code&gt; to the serializer as a temporary bridge, then remove it once the frontend has deployed.&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to safely remove a Django model field: finding every real reference before you delete</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 02 Jun 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/how-to-safely-remove-a-django-model-field-finding-every-real-reference-before-you-delete-1b9o</link>
      <guid>https://dev.to/shinagawa-web/how-to-safely-remove-a-django-model-field-finding-every-real-reference-before-you-delete-1b9o</guid>
      <description>&lt;p&gt;Every Django project has at least one of these. A model with an old field that's probably not used anymore. "Probably" is the scary part. If something in production is still referencing it, deleting the column breaks the app. &lt;code&gt;AttributeError&lt;/code&gt;, in production.&lt;/p&gt;

&lt;p&gt;So you don't delete it. You want to, but you can't figure out how to check safely, so it just sits there. The column takes up space in every query. The field clutters the model definition. And it keeps sitting there, month after month, because the cost of confirming it's unused feels higher than the cost of leaving it.&lt;/p&gt;

&lt;p&gt;Think about what happens when &lt;code&gt;AttributeError: 'Article' object has no attribute 'summary'&lt;/code&gt; hits production. Users get 500 errors every time they open the page. Logs flood. Slack lights up. "Was it that deploy we just pushed?" Already considering a rollback. And the cause was deleting a field you thought was unused.&lt;/p&gt;

&lt;p&gt;That's why nobody deletes anything. You can't be sure, so you don't. That's the right call. The problem is there was no way to get sure.&lt;/p&gt;

&lt;p&gt;I tried the obvious thing: searching for the field name in VS Code. Hundreds of hits. I started opening them one by one and immediately noticed most aren't real references. File paths, comments, unrelated variable names. I searched for the &lt;code&gt;.html&lt;/code&gt; field in one Django project and got 1,202 results. The actual code accessing that field: 10 results.&lt;/p&gt;

&lt;p&gt;1,192 were noise. But you couldn't know that upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why search returns 1,202 hits
&lt;/h2&gt;

&lt;p&gt;When you give up because there are too many results, that's not a failure on your part. VS Code search and grep just weren't built for this.&lt;/p&gt;

&lt;p&gt;These tools answer "does this string appear anywhere in this file?" That's useful for a lot of things. But when you want to know "is this field actually referenced in code?", text search picks up way too much. The field name in a string literal, in a comment, as part of a filename: it all counts as a hit. Sorting through them is manual work.&lt;/p&gt;

&lt;p&gt;Here's what those 1,202 results for &lt;code&gt;.html&lt;/code&gt; broke down to:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File extensions (&lt;code&gt;layout.html&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;td&gt;1,087&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unrelated strings&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comments&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other noise&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actual field accesses&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can't check 1,200 results. Giving up was the right call. The issue wasn't how you used the tool. You were using the wrong tool.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;.html&lt;/code&gt; isn't a special case. Say you want to delete a &lt;code&gt;title&lt;/code&gt; field on an &lt;code&gt;Article&lt;/code&gt; model. "title" shows up everywhere in a codebase: variable names, dictionary keys, comments, strings. Hundreds of results. Same problem.&lt;/p&gt;

&lt;p&gt;Common field names are the worst. &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt; — these appear in dozens of unrelated contexts throughout a typical Django project. The search that was supposed to answer a simple question becomes 40 minutes of opening files, closing files, and losing track of what you've already checked.&lt;/p&gt;

&lt;p&gt;"I searched, couldn't check everything, left it alone." Most Django developers have been here. You want to delete it but can't. The check isn't impossible, it's just too expensive. So the field stays. They accumulate.&lt;/p&gt;

&lt;p&gt;This compounds over time. A project that's two years old might have thirty fields that nobody's confident about. The developers who added them have moved on. The tickets that motivated them are closed. The tests, if they exist, pass regardless of whether the field is used. There's no mechanism forcing a cleanup, just the gradual intuition that the schema is getting harder to understand.&lt;/p&gt;

&lt;p&gt;If you know regex, you might try narrowing it: &lt;code&gt;grep -rn "\bhtml\b" --include="*.py"&lt;/code&gt; to limit to Python files. Still the same problem. &lt;code&gt;"html"&lt;/code&gt; as a dictionary key, in a comment — it all still hits. "Python files only" and "actual field access" are completely different things.&lt;/p&gt;

&lt;p&gt;The more you refine the regex, the more you start wondering whether the regex itself is missing something. You end up needing to verify the verification. The tool that was supposed to save you time has become another source of doubt.&lt;/p&gt;

&lt;p&gt;There's also the psychological cost. You open VS Code, run the search, see 847 results for &lt;code&gt;status&lt;/code&gt;, and your shoulders drop. You close the tab. You tell yourself you'll check it later. "Later" never comes. Nobody should have to hand-verify 847 results to answer a yes/no question.&lt;/p&gt;

&lt;p&gt;When undeletable fields pile up, here's what actually happens.&lt;/p&gt;

&lt;p&gt;The schema bloats. Ten, twenty unused columns accumulate. A new developer opens the model, scans the fields. "What's this one for?" They try to find out, can't, and decide not to touch it. Reasonable. But when that pattern repeats, you end up with an unspoken rule: don't touch this model.&lt;/p&gt;

&lt;p&gt;Every migration feels slightly more risky. The unease builds until nobody touches it at all. Six months later, another developer thinks the same thing. The cycle repeats.&lt;/p&gt;

&lt;p&gt;This is technical debt in the same way missing tests are. An unresolvable cost that accumulates. Development slows. Onboarding takes longer. Nobody intended this, but the project gets heavier over time.&lt;/p&gt;

&lt;p&gt;This isn't a skills problem. The tool didn't exist yet.&lt;/p&gt;

&lt;p&gt;Here's the situation I kept ending up in: I'd look at a field, feel like it was probably unused, open VS Code to check, get overwhelmed by results, close it, and go do something else. The field would still be there six months later. The next developer would go through the same loop. The field would still be there a year later.&lt;/p&gt;

&lt;p&gt;At some point the schema becomes archaeology. Fields with names like &lt;code&gt;legacy_content&lt;/code&gt;, &lt;code&gt;old_slug&lt;/code&gt;, &lt;code&gt;deprecated_flag&lt;/code&gt;: nobody knows what they do, nobody wants to touch them, and the project carries them forever. Every &lt;code&gt;SELECT *&lt;/code&gt; is slightly slower. Every new developer's mental model of the data is slightly more confused.&lt;/p&gt;

&lt;p&gt;The real cost is cognitive load. Every unused field is a small tax on everyone who reads the model. Multiply that by thirty fields and two years of new developers and you start to see why "we never clean up old fields" becomes an invisible drag on velocity.&lt;/p&gt;

&lt;h2&gt;
  
  
  How colref reads code structure instead of text
&lt;/h2&gt;

&lt;p&gt;What you actually wanted to know was: where is &lt;code&gt;obj.html&lt;/code&gt; referenced in code? &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;colref&lt;/a&gt; returns exactly that. File paths and string contents ignored.&lt;/p&gt;

&lt;p&gt;How does it tell the difference? Instead of treating code as a sequence of characters, it reads the code structure.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;embed.html&lt;/code&gt; in Python means "read the &lt;code&gt;html&lt;/code&gt; attribute of the &lt;code&gt;embed&lt;/code&gt; object": a specific structure. &lt;code&gt;"pages/publish.html"&lt;/code&gt; is string data, not an attribute access. Reading code structure makes that difference detectable. Only places written as &lt;code&gt;object.field_name&lt;/code&gt; get picked up. The &lt;code&gt;.html&lt;/code&gt; that appears inside a string is ignored.&lt;/p&gt;

&lt;p&gt;If text search is like pressing Ctrl+F on a page, reading code structure is closer to a human reading through every line. Except it handles thousands of lines in an instant.&lt;/p&gt;

&lt;p&gt;It scans Python code and returns only &lt;code&gt;.field_name&lt;/code&gt; accesses, with file and line number.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Hits (for &lt;code&gt;.html&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;What it sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VS Code full-text search&lt;/td&gt;
&lt;td&gt;3,534&lt;/td&gt;
&lt;td&gt;All string matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grep &lt;code&gt;\.html\b&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,202&lt;/td&gt;
&lt;td&gt;Word-boundary matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;colref&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Actual field accesses only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;3,534 or 1,202 becomes 10. Whether you can act on the results depends entirely on how many there are.&lt;/p&gt;

&lt;p&gt;When you get 10 results: open each one. &lt;code&gt;views.py:42&lt;/code&gt; means go to that line and check whether &lt;code&gt;obj.html&lt;/code&gt; is actually being accessed. Real reference — can't delete. Not a real reference — skip. Ten results takes 10–15 minutes.&lt;/p&gt;

&lt;p&gt;A few common things you'll see when reviewing results: the field name appearing in a migration file (colref skips migrations, but if it didn't, this would be a false positive; the migration is just recording the history of the field's existence, not actively using it). You might also see test factories or fixtures that set the field value. Worth noting: if you delete the field and forget to update the factory, your test suite will break. That's not a reason not to delete, it's just something to clean up as part of the deletion.&lt;/p&gt;

&lt;p&gt;When you get zero: you have a fact. "No references found in Python code." That's different from "I think it's probably unused." Move to the next step: checking getattr, templates, Admin, Forms, and Serializers. Zero from colref is the starting point, not the finish line.&lt;/p&gt;

&lt;p&gt;The shift is from "check 1,200 things" to "check 10 things, then a handful of specific files." That's the difference between a task you'll defer indefinitely and one you'll do today.&lt;/p&gt;

&lt;p&gt;For the technical details of how code structure is read, see &lt;a href="https://github.com/shinagawa-web/colref/blob/main/ARCHITECTURE.md" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Install via pipx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with pip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;colref
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Specify the model name, field name, and your project directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Embed &lt;span class="nt"&gt;--field&lt;/span&gt; html ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results come back as &lt;code&gt;filename:line_number&lt;/code&gt;. Each one is something you can open directly. Ten results takes maybe ten minutes to verify. Nothing compared to scrolling through 1,202 results, losing your place, and giving up halfway through.&lt;/p&gt;

&lt;p&gt;A note on model names: use the class name exactly as it appears in your models file, including capitalization. &lt;code&gt;Embed&lt;/code&gt;, not &lt;code&gt;embed&lt;/code&gt; or &lt;code&gt;EMBED&lt;/code&gt;. Field names are case-sensitive too: &lt;code&gt;html&lt;/code&gt;, not &lt;code&gt;HTML&lt;/code&gt;. If you get zero results for a field you know is used, double-check the casing first.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;./&lt;/code&gt; at the end is the path to scan. You can point it at a specific app directory if you want to narrow it down, but pointing at the project root works fine and makes sure nothing gets missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What zero results doesn't cover
&lt;/h2&gt;

&lt;p&gt;Zero results doesn't mean "safe to delete." It means "not found in Python code."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic access:&lt;/strong&gt; &lt;code&gt;getattr(obj, field_name)&lt;/code&gt; with the field name in a variable won't be detected. Check separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"getattr"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./ | &lt;span class="nb"&gt;grep &lt;/span&gt;your_field
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Django templates:&lt;/strong&gt; &lt;code&gt;{{ page.html }}&lt;/code&gt; lives in &lt;code&gt;.html&lt;/code&gt; files. colref only scans &lt;code&gt;.py&lt;/code&gt;. Check templates separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"your_field"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.html"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Django Admin, Forms, and DRF Serializers:&lt;/strong&gt; This is the easiest one to miss. None of these are detected:&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;# Django Admin
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleAdmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelAdmin&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;list_display&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;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;list_filter&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;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Django Forms
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelForm&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fields&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;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# DRF Serializer
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArticleSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelSerializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fields&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;title&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;Determining which model the string &lt;code&gt;'title'&lt;/code&gt; in a list refers to requires tracing class inheritance, which colref doesn't handle yet. Check these separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"your_field"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This grep has the same noise problem as full-text search. Opening the Admin, Forms, and Serializer files directly is more reliable. In most projects there aren't many of them.&lt;/p&gt;

&lt;p&gt;These are exactly the places a Django beginner might not think to check. You verify the views, the serializers feel obvious after you remember them, but Django Admin is easy to forget, especially if the admin configuration lives in a file you rarely open. I've seen &lt;code&gt;list_display&lt;/code&gt; hold a reference to a field that had been "confirmed deleted" twice already. The admin file just wasn't in anyone's mental checklist.&lt;/p&gt;

&lt;p&gt;Once you've checked all of the above and colref returns zero, that's a grounded deletion: confirmed in Python code, checked getattr, templates, Admin/Forms/Serializers. Not "I think it's probably fine."&lt;/p&gt;

&lt;p&gt;Checking Admin/Forms/Serializers by eye sounds tedious, but in practice it takes a few minutes. These files tend to be organized by model. Open &lt;code&gt;admin.py&lt;/code&gt;, search for the model name, check &lt;code&gt;list_display&lt;/code&gt; and related attributes. Open &lt;code&gt;serializers.py&lt;/code&gt;, find the relevant serializer, check &lt;code&gt;fields&lt;/code&gt;. Open &lt;code&gt;forms.py&lt;/code&gt; if you have one. It's not a grep problem, it's an "open three files and look" problem. That's manageable even without a tool.&lt;/p&gt;

&lt;p&gt;colref (Python attribute accesses) + grep (dynamic patterns and templates) + manual check (Admin/Forms/Serializers) covers the vast majority of real-world Django codebases. There are edge cases colref doesn't handle yet; the &lt;a href="https://shinagawa-web.github.io/colref/docs/detection-patterns/" rel="noopener noreferrer"&gt;Detection Patterns&lt;/a&gt; docs list them. For most projects, this three-part check is enough to move from "I think it's probably unused" to "I have confirmed it's unused."&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-step procedure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Check for field accesses in Python code&lt;/span&gt;
colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; YourModel &lt;span class="nt"&gt;--field&lt;/span&gt; your_field ./

&lt;span class="c"&gt;# 2. Check for dynamic access&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"getattr"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; ./ | &lt;span class="nb"&gt;grep &lt;/span&gt;your_field

&lt;span class="c"&gt;# 3. Check templates&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"your_field"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.html"&lt;/span&gt; ./

&lt;span class="c"&gt;# 4. Delete the field and generate the migration&lt;/span&gt;
python manage.py makemigrations &lt;span class="nt"&gt;--name&lt;/span&gt; remove_your_field

&lt;span class="c"&gt;# 5. Apply to the schema&lt;/span&gt;
python manage.py migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 2 and 3 are still grep — colref doesn't solve everything. But step 1 cuts 1,202 results down to 10. The "too many results to check, left it alone" situation: this is the one place that changes.&lt;/p&gt;

&lt;p&gt;The difference between "probably unused, I think" and "zero results in Python code, no getattr, nothing in templates" is real. If something breaks in production, knowing what you checked tells you exactly where to look. You know the cause came from outside your checked scope: a dynamic reference, a template, a pattern colref doesn't handle yet. The cause is narrowed. Grounded deletion makes debugging faster when things go wrong.&lt;/p&gt;

&lt;p&gt;One more thing about step 4 and 5: don't skip &lt;code&gt;makemigrations --name&lt;/code&gt;. Giving the migration a descriptive name like &lt;code&gt;remove_summary_field&lt;/code&gt; makes the history readable. Six months from now, someone scanning migration filenames can see what changed and when without opening every file.&lt;/p&gt;

&lt;p&gt;Before deploying, run the migration locally and make sure your test suite passes. When you're confident about a deletion it's tempting to skip the verification. Don't. If a test factory is still setting the deleted field, the tests will catch it before production does.&lt;/p&gt;

&lt;p&gt;The whole process — run colref, check the checklist, generate the migration, run tests locally, deploy — takes maybe 30 minutes for a field that's actually unused. Compare that to leaving the field there indefinitely because you couldn't confirm it was safe to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: try a field you know is used
&lt;/h2&gt;

&lt;p&gt;If you don't have a candidate field in mind, try a field you know is used, something like &lt;code&gt;title&lt;/code&gt; on &lt;code&gt;Article&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;colref check &lt;span class="nt"&gt;--orm&lt;/span&gt; django &lt;span class="nt"&gt;--model&lt;/span&gt; Article &lt;span class="nt"&gt;--field&lt;/span&gt; title ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;title&lt;/code&gt; is in use, you'll get multiple results with file and line number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/views.py:42
app/serializers.py:18
app/templates/article_detail.py:11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing what a real result looks like makes it easier to judge zero results later. Then try a field you've been wondering about. Close to zero? Move to steps 2 and 3.&lt;/p&gt;

&lt;p&gt;From installation to first run: under five minutes. No need to read the README first. Running it is faster than reading about it.&lt;/p&gt;

&lt;p&gt;What do you do when you get 3 results? Open all three. For each one: is this code still running in production? If a reference is inside a function that's clearly dead code, something wrapped in &lt;code&gt;if False&lt;/code&gt; or commented out, it doesn't count. If it's live code, the field is still in use. But 3 results is a manageable number. You can make that judgment call.&lt;/p&gt;

&lt;p&gt;What if you get 0 results? Don't stop there. Run steps 2 and 3. Zero from colref, zero from getattr grep, zero from template grep: that's three independent checks. At that point, also check your Admin, Forms, and Serializer files by eye. If all of that is clear, you have something solid to stand on.&lt;/p&gt;

&lt;p&gt;Even without a deletion candidate right now, colref is useful for routine schema review. Scan migration history, spot something that looks unused, run colref. Zero results? It goes on the deletion candidate list. "Probably unused" becomes "not referenced in Python code" in 30 seconds.&lt;/p&gt;

&lt;p&gt;I do this periodically on projects I maintain. Every few months I scan the migration history for fields I don't recognize, run colref on them, and build a short list. It takes maybe 20 minutes and usually turns up one or two candidates worth investigating further. Some end up staying because they're used in ways colref doesn't detect yet. But a few always turn out to be genuinely gone: references removed over time, nobody noticed, nobody cleaned it up. Those get deleted.&lt;/p&gt;

&lt;p&gt;colref is still in development. If something doesn't work or you get unexpected results, open an issue at &lt;a href="https://github.com/shinagawa-web/colref" rel="noopener noreferrer"&gt;github.com/shinagawa-web/colref&lt;/a&gt;. Real usage feedback is what shapes the priorities. A bug report with a concrete example is more useful than ten feature requests.&lt;/p&gt;

&lt;p&gt;colref currently supports Django and Rails. For the roadmap, see &lt;a href="https://github.com/shinagawa-web/colref/issues/74" rel="noopener noreferrer"&gt;issue #74&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you find a field that colref misses, something it should have flagged but didn't, that's especially useful to report. The detection gap around Admin, Forms, and Serializers is a known limitation, but there may be patterns in your codebase that nobody's encountered yet. The tool gets better with more real-world cases.&lt;/p&gt;

&lt;p&gt;How many fields are you sitting on that you haven't been able to delete?&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>"How I Cut My Go Markdown Linter's Benchmark by 81%"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 26 May 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/how-i-cut-my-go-markdown-linters-benchmark-by-81-4ain</link>
      <guid>https://dev.to/shinagawa-web/how-i-cut-my-go-markdown-linters-benchmark-by-81-4ain</guid>
      <description>&lt;p&gt;When I started optimizing &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt;, I had no benchmarks. I had unit tests. I had coverage. But I had no idea what the linter actually cost to run on a real document.&lt;/p&gt;

&lt;p&gt;Here's what I found, what I changed, and what I'd do differently next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is gomarklint?
&lt;/h2&gt;

&lt;p&gt;gomarklint is a Go-based CLI Markdown linter I've been building as an open-source project. The pitch: catch broken links before your readers do, keep your Markdown clean, single binary, no Node.js required.&lt;/p&gt;

&lt;p&gt;The main alternative most teams reach for is &lt;a href="https://github.com/DavidAnson/markdownlint" rel="noopener noreferrer"&gt;markdownlint&lt;/a&gt;, which works well but requires a Node.js runtime. For Go projects running in a lean CI environment, pulling in Node.js just to lint Markdown felt like the wrong tradeoff. gomarklint ships as a standalone binary installable via Homebrew, npm, or &lt;code&gt;go install&lt;/code&gt;, and integrates with GitHub Actions and pre-commit out of the box.&lt;/p&gt;

&lt;p&gt;The rule set covers around 25 checks: structural rules like &lt;code&gt;heading-level&lt;/code&gt; (no H4 appearing under H2 without an H3 in between), content rules like &lt;code&gt;no-bare-urls&lt;/code&gt; and &lt;code&gt;fenced-code-language&lt;/code&gt;, and link validation including internal anchor checking. Each rule emits diagnostics with file path, line number, and severity — &lt;code&gt;error&lt;/code&gt; causes a non-zero exit, making it safe as a CI gate.&lt;/p&gt;

&lt;p&gt;Internally, each rule is a function that receives the full file content as a slice of lines and returns a slice of violations. No shared parse tree, no AST, just functions over strings. That made it easy to add rules quickly.&lt;/p&gt;

&lt;p&gt;The current release processes 100,000+ lines in under 170ms. A typical 200-line README lints in under 0.2 ms across all rules, and a large documentation site with hundreds of files fits in a CI step nobody notices. Getting there required 20 PRs over three weeks, each measured before it merged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Starting Point: No Visibility
&lt;/h2&gt;

&lt;p&gt;gomarklint's rules like &lt;code&gt;heading-level&lt;/code&gt;, &lt;code&gt;no-bare-urls&lt;/code&gt;, &lt;code&gt;fenced-code-language&lt;/code&gt;, and around 25 others each receive the full file content split into lines and return a slice of violations. Rules run independently, no shared parse tree, no AST, just functions over strings.&lt;/p&gt;

&lt;p&gt;That architecture is easy to extend. But every rule doing anything non-trivial had to solve the same problem on its own: "is this line inside a code block?" Headings inside fenced blocks aren't real headings. URLs inside fenced blocks aren't real URLs. Every rule that cared had to figure it out independently.&lt;/p&gt;

&lt;p&gt;The solution that grew organically was a shared utility called &lt;code&gt;GetCodeBlockLineRanges&lt;/code&gt;. It scanned the entire document, built a list of &lt;code&gt;[start, end]&lt;/code&gt; line ranges for every fenced block, and returned them. Any rule could then call &lt;code&gt;isInCodeBlock(lineNumber, ranges)&lt;/code&gt; to check membership via a linear search.&lt;/p&gt;

&lt;p&gt;I didn't notice this was a problem because I never measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Build a Benchmark You Can Trust
&lt;/h2&gt;

&lt;p&gt;Before optimizing anything, I needed a benchmark I could actually rely on. The existing per-rule &lt;code&gt;_bench_test.go&lt;/code&gt; files were isolated and not included in CI comparisons, so they gave no signal about end-to-end cost.&lt;/p&gt;

&lt;p&gt;I rewrote the benchmark around a single &lt;code&gt;generateComplexMarkdown(n int)&lt;/code&gt; function that produces a realistic &lt;code&gt;n&lt;/code&gt;-section document (headings, paragraphs, lists, fenced code blocks, images, links) exercising every rule's scan path without producing any violations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeIntro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"# Main Title&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This is the introduction to the document.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&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;func&lt;/span&gt; &lt;span class="n"&gt;writeHeading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"## Section %d&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeParagraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This section contains *important* information. "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Here are some **key** details that you should know about.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&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;func&lt;/span&gt; &lt;span class="n"&gt;writeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Key points:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"- First important point&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"- Second critical detail&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"- Third consideration&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&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;func&lt;/span&gt; &lt;span class="n"&gt;writeCodeBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"```

go&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"func example() error {&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"    return nil&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"

```&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&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;func&lt;/span&gt; &lt;span class="n"&gt;writeLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Useful resources:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"- [Documentation](https://example.com/docs/%d)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"- [GitHub](https://github.com/project/%d)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&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;func&lt;/span&gt; &lt;span class="n"&gt;writeImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"![Diagram %d](diagram%d.png)&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeSubsection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"### Subsection %d.1&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"More detailed information goes here.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&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;func&lt;/span&gt; &lt;span class="n"&gt;generateComplexMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sections&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;
    &lt;span class="n"&gt;writeIntro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;writeHeading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;writeParagraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;writeList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&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;i&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeCodeBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&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;i&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;writeSubsection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&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;That last constraint — zero violations — matters. A benchmark that triggers violations measures error-reporting cost, not scanning cost. I added a guard test to enforce it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestBenchmarkContentIsViolationFree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;generateComplexMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;lint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;benchmarkConfig&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;assert&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;results&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;This test runs in CI on every PR in the series. If anyone accidentally adds a violation to the benchmark content, the build breaks before the numbers become meaningless.&lt;/p&gt;

&lt;p&gt;The benchmark also pays forward. Now that it runs on every PR, any new rule added to gomarklint gets automatically checked for performance regression before it merges. If a new &lt;code&gt;no-trailing-spaces&lt;/code&gt; rule adds 15% to the geomean, &lt;code&gt;benchstat&lt;/code&gt; surfaces that number in the PR diff — before it ships, not after. Without a benchmark in CI, performance regressions from new features are invisible until someone notices the linter "feels slower" on a large repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Profile Before Touching Anything
&lt;/h2&gt;

&lt;p&gt;With the benchmark in place, I ran a CPU and allocation profile against the 1000-section document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-bench&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;BenchmarkFullLinting &lt;span class="nt"&gt;-benchtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5s &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-cpuprofile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cpu.prof &lt;span class="nt"&gt;-memprofile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mem.prof ./cmd/
go tool pprof &lt;span class="nt"&gt;-top&lt;/span&gt; cpu.prof
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Showing top 5 nodes out of 42
      flat  flat%   sum%        cum   cum%
   6.32s  63.72% 63.72%      6.32s 63.72%  isInCodeBlock
   0.89s   8.97% 72.69%      0.89s  8.97%  strings.TrimSpace
   0.54s   5.44% 78.14%      0.54s  5.44%  regexp.(*Regexp).FindStringSubmatch
   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isInCodeBlock&lt;/code&gt; was a shared helper called from multiple rules. Each call invoked &lt;code&gt;GetCodeBlockLineRanges&lt;/code&gt;, which allocated a &lt;code&gt;[][2]int&lt;/code&gt; slice by scanning the entire document to find fence boundaries, then performed a linear search through that slice to answer one question: "is line N inside a code block?"&lt;/p&gt;

&lt;p&gt;That's O(n × k) per rule per line, where &lt;code&gt;n&lt;/code&gt; is the number of lines and &lt;code&gt;k&lt;/code&gt; is the number of code blocks. In a document with 6 rules calling it and 50 code blocks, every line triggered 6 linear searches over 50 ranges. The cost multiplied silently across every rule that wanted to skip code block content.&lt;/p&gt;

&lt;p&gt;The allocation profile added a second finding: &lt;code&gt;CheckHeadingLevels&lt;/code&gt; was calling &lt;code&gt;regexp.MustCompile(atxHeadingPattern)&lt;/code&gt; inside the function body on every invocation — not once at package init. That single oversight was allocating ~16 MB of heap per benchmark run and showed up clearly in &lt;code&gt;-memprofile&lt;/code&gt; output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Fix the Biggest Problem First
&lt;/h2&gt;

&lt;p&gt;Both issues were in the &lt;code&gt;heading-level&lt;/code&gt; rule. I fixed them together in &lt;a href="https://github.com/shinagawa-web/gomarklint/pull/182" rel="noopener noreferrer"&gt;PR #182&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CheckHeadingLevels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minLevel&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;
    &lt;span class="n"&gt;prevLevel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;headingRegex&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`^(#{1,6})\s+`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// compiled on every call&lt;/span&gt;
    &lt;span class="n"&gt;codeBlockRanges&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="n"&gt;GetCodeBlockLineRanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// O(n) alloc&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;lines&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;isInCodeBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;codeBlockRanges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// O(k) linear search per line&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;headingRegex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FindStringSubmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&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;matches&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;currentLevel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="c"&gt;// ... violation checks&lt;/span&gt;
            &lt;span class="n"&gt;prevLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentLevel&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;errs&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;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// atxHeadingLevel returns the heading level (1–6) or 0.&lt;/span&gt;
&lt;span class="c"&gt;// Pure byte scan — no regex, no allocation.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;atxHeadingLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'#'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&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="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;'\t'&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;level&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CheckHeadingLevels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minLevel&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;
    &lt;span class="n"&gt;prevLevel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;inCodeBlock&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fenceMarker&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;firstNonSpaceByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c"&gt;// Inline fence tracking — no allocation, no O(n×k) lookup.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inCodeBlock&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;first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;fenceMarker&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&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;IsClosingFence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fenceMarker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;inCodeBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
                    &lt;span class="n"&gt;fenceMarker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&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;continue&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;first&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'#'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'`'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'~'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&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;marker&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;openingFenceMarker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;inCodeBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
            &lt;span class="n"&gt;fenceMarker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;
            &lt;span class="k"&gt;continue&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;first&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'#'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;currentLevel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;atxHeadingLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&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;currentLevel&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c"&gt;// ... violation checks&lt;/span&gt;
        &lt;span class="n"&gt;prevLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentLevel&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;errs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result on CI (AMD EPYC, 1000-section document):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;th&gt;delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;time/op (geomean)&lt;/td&gt;
&lt;td&gt;52.55 ms&lt;/td&gt;
&lt;td&gt;14.78 ms&lt;/td&gt;
&lt;td&gt;−72%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory/op&lt;/td&gt;
&lt;td&gt;2.108 Mi&lt;/td&gt;
&lt;td&gt;1.912 Mi&lt;/td&gt;
&lt;td&gt;−9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;allocs/op&lt;/td&gt;
&lt;td&gt;9,225&lt;/td&gt;
&lt;td&gt;4,677&lt;/td&gt;
&lt;td&gt;−49%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One PR. 72% of the time gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Turn the Pattern Into a System
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;heading-level&lt;/code&gt; fix revealed a reusable pattern: the &lt;code&gt;firstNonSpaceByte&lt;/code&gt; prefilter. Most rules only care about lines starting with a specific byte. A heading starts with &lt;code&gt;#&lt;/code&gt;. A fenced code block starts with &lt;code&gt;`&lt;/code&gt; or &lt;code&gt;~&lt;/code&gt;. A hard tab starts with &lt;code&gt;\t&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reading the first non-whitespace byte costs almost nothing — one loop over leading spaces, then a byte read. Calling &lt;code&gt;strings.TrimSpace&lt;/code&gt; on every line is not free, especially when 95% of lines would be skipped anyway.&lt;/p&gt;

&lt;p&gt;I applied this prefilter across 14 more rules over 14 subsequent PRs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;firstNonSpaceByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\t'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\r'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\n'&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;c&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="m"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each individual PR produced a modest gain — 3% to 15% per rule. But they compounded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PR&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;time delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/193" rel="noopener noreferrer"&gt;#193&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;no-bare-urls&lt;/td&gt;
&lt;td&gt;−15.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/195" rel="noopener noreferrer"&gt;#195&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;no-emphasis-as-heading&lt;/td&gt;
&lt;td&gt;−8.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/190" rel="noopener noreferrer"&gt;#190&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;empty-alt-text&lt;/td&gt;
&lt;td&gt;−6.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/194" rel="noopener noreferrer"&gt;#194&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;duplicate-heading&lt;/td&gt;
&lt;td&gt;−3.0% (+ −12.6% memory)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint/pull/192" rel="noopener noreferrer"&gt;#192&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;no-empty-links&lt;/td&gt;
&lt;td&gt;−2.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Final Numbers
&lt;/h2&gt;

&lt;p&gt;Starting from the benchmark baseline after the scaffolding work, to the last PR in the series:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;start&lt;/th&gt;
&lt;th&gt;end&lt;/th&gt;
&lt;th&gt;improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;time/op (geomean)&lt;/td&gt;
&lt;td&gt;74.37 ms&lt;/td&gt;
&lt;td&gt;13.88 ms&lt;/td&gt;
&lt;td&gt;−81%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory/op&lt;/td&gt;
&lt;td&gt;2.221 Mi&lt;/td&gt;
&lt;td&gt;1.654 Mi&lt;/td&gt;
&lt;td&gt;−25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;allocs/op&lt;/td&gt;
&lt;td&gt;9,533&lt;/td&gt;
&lt;td&gt;4,510&lt;/td&gt;
&lt;td&gt;−53%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twenty PRs over three weeks. Each one measured, each one merged only after the CI benchmark comparison confirmed no regression.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;Profile before writing a single optimization. I got lucky that the biggest problem (&lt;code&gt;isInCodeBlock&lt;/code&gt; at 63%) was immediately obvious. In a more complex codebase, guessing the hotspot first would have sent me optimizing rules that contributed 0.5% of total CPU while the real bottleneck sat untouched. The 20-minute setup cost of a proper benchmark pays back on the first targeted fix.&lt;/p&gt;

&lt;p&gt;Treat the benchmark content as a first-class artifact. A benchmark that produces violations is measuring error-reporting cost, not scanning cost. A benchmark whose content drifts rule-by-rule becomes impossible to interpret. The &lt;code&gt;TestBenchmarkContentIsViolationFree&lt;/code&gt; guard test caught three cases mid-series where a content addition accidentally triggered a violation. Without it, I would have been optimizing against corrupted numbers and wondering why the geomean kept moving.&lt;/p&gt;

&lt;p&gt;Two PRs in this series landed within measurement noise. One was closed without merging. Having numbers in each PR body made that obvious: the CI &lt;code&gt;benchstat&lt;/code&gt; output showed &lt;code&gt;~ (p=0.485)&lt;/code&gt; and there was nothing to argue about. A single "performance" PR with twenty changes mixed together would have buried those non-results.&lt;/p&gt;

&lt;p&gt;Writing before/after benchmark numbers in every PR description did something I didn't expect: it made the series reviewable after the fact. Looking back at 20 PRs, I can tell exactly which optimization accounted for which fraction of the total gain. The &lt;code&gt;firstNonSpaceByte&lt;/code&gt; prefilter was worth applying to 14 rules because I could see, rule by rule, that it kept working. That's how I found the pattern in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;gomarklint is open source: &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;github.com/shinagawa-web/gomarklint&lt;/a&gt;. The entire optimization series is tracked under &lt;a href="https://github.com/shinagawa-web/gomarklint/issues/146" rel="noopener noreferrer"&gt;issue #146&lt;/a&gt;, with each PR linking back to it and carrying its own before/after benchmark numbers.&lt;/p&gt;

&lt;p&gt;If you're running a Go linter or any line-by-line text processor, these two patterns are worth trying before anything fancier: inline fence tracking instead of pre-computed ranges, and a first-byte prefilter before any string work. Zero dependencies, easy to test, and between them they accounted for almost all of the 81%.&lt;/p&gt;

</description>
      <category>go</category>
      <category>performance</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
