<?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: Alex Georgiev</title>
    <description>The latest articles on DEV Community by Alex Georgiev (@alexgeorgiev17).</description>
    <link>https://dev.to/alexgeorgiev17</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%2F374570%2Ffd187417-a674-43c9-951a-81c6fb965471.png</url>
      <title>DEV Community: Alex Georgiev</title>
      <link>https://dev.to/alexgeorgiev17</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexgeorgiev17"/>
    <language>en</language>
    <item>
      <title>Python 3.14's concurrent.interpreters module turns a 3.6-second GIL bottleneck into 0.9 seconds</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Mon, 26 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/python-314s-concurrentinterpreters-module-turns-a-36-second-gil-bottleneck-into-09-seconds-219</link>
      <guid>https://dev.to/alexgeorgiev17/python-314s-concurrentinterpreters-module-turns-a-36-second-gil-bottleneck-into-09-seconds-219</guid>
      <description>&lt;p&gt;A CPU-bound Python job split across four threads took 3.6 seconds on my machine. The same job split across four of Python 3.14's new subinterpreters took 0.9 seconds, roughly what one thread alone takes to do a quarter of the work. That part matched what I expected from the PEP. What I did not expect was that asking one of those subinterpreters to run two things at once does not slow it down. It refuses outright.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;concurrent.interpreters&lt;/code&gt; is new in the standard library as of Python 3.14, released on 7 October 2026. Subinterpreters themselves are not new - they have existed in the C API since Python 1.5 and CPython has used them internally for years - but PEP 734 is what makes them usable from ordinary Python code, with no C extension required. Each subinterpreter gets its own Global Interpreter Lock, which is the part worth testing: it is the thing that is supposed to let CPU-bound Python code actually use more than one core without reaching for &lt;code&gt;multiprocessing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I tested it against a CPU-bound workload (counting primes below 400,000, a plain Python double loop with no C extensions to hide behind), compared against threads, processes, and a single thread, three runs each, inside a &lt;code&gt;python:3.14-slim&lt;/code&gt; container with four cores available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline number
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;approach&lt;/th&gt;
&lt;th&gt;time for 4x the work&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;single thread (1x unit, for reference)&lt;/td&gt;
&lt;td&gt;0.77 - 0.80s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 OS threads, classic GIL&lt;/td&gt;
&lt;td&gt;3.51 - 3.70s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 subinterpreters (new in 3.14)&lt;/td&gt;
&lt;td&gt;0.89 - 0.98s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 processes (&lt;code&gt;multiprocessing&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0.95 - 1.30s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four threads doing four units of CPU-bound work took more than four times as long as one thread doing one unit - 3.6s against roughly 0.8s, which is actually a bit worse than the 3.2s you'd get from perfect serialisation, because the GIL has to keep stopping one thread to let another run. Four subinterpreters doing the same four units took about 0.9s: close enough to the single-unit baseline that the extra three units of work were, for practical purposes, free. That is real parallelism, not a scheduling trick, and it lands in the same place &lt;code&gt;multiprocessing&lt;/code&gt; already does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it refuses: one thread per interpreter, not shared
&lt;/h2&gt;

&lt;p&gt;The PEP's selling point is that each interpreter has its own GIL. I wanted to see what happens when two threads try to use one interpreter's GIL at the same time, expecting it to behave like classic Python: both threads run, the shared GIL makes them take turns.&lt;/p&gt;

&lt;p&gt;That is not what happened. A single &lt;code&gt;Interpreter&lt;/code&gt; object only tolerates one caller. A second concurrent call doesn't queue, and it doesn't share time with the first - it is rejected immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exception in thread Thread-2 (_call):
Traceback (most recent call last):
  File "/usr/local/lib/python3.14/concurrent/interpreters/__init__.py", line 226, in _call
    res, excinfo = _interpreters.call(self._id, callable, args, kwargs, restrict=True)
concurrent.interpreters.InterpreterError: interpreter already running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I confirmed the shape of this with three variants of the same interpreter, run in sequence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;scenario&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;th&gt;outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one call&lt;/td&gt;
&lt;td&gt;0.84s&lt;/td&gt;
&lt;td&gt;fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two sequential calls, same interpreter&lt;/td&gt;
&lt;td&gt;1.63s&lt;/td&gt;
&lt;td&gt;fine, roughly 2x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two concurrent calls, same interpreter&lt;/td&gt;
&lt;td&gt;0.84s wall-clock&lt;/td&gt;
&lt;td&gt;second call raises &lt;code&gt;InterpreterError&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;interpreters.create()&lt;/code&gt; is documented as returning "a new (idle) Python interpreter", and that word is accurate: idle or running, nothing in between. If you want N things running at once, you need N interpreters, one each. A thread pool aimed at a single shared interpreter - the pattern every &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; tutorial uses with regular threads - does not translate. You get one interpreter's worth of work and an exception, not graceful serialisation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opposite number: interpreters are not obviously cheaper to start than processes
&lt;/h2&gt;

&lt;p&gt;The other assumption I walked in with was that subinterpreters, being lighter than OS processes, would be cheap to spin up. I measured the cost of creating four workers and giving each one some real state (a 200,000-element list), steady state after the first warm-up run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;approach&lt;/th&gt;
&lt;th&gt;create + populate, 4 workers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fork&lt;/code&gt; (Python 3.13's Linux default)&lt;/td&gt;
&lt;td&gt;0.037 - 0.038s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;forkserver&lt;/code&gt; (Python 3.14's Linux default)&lt;/td&gt;
&lt;td&gt;0.057 - 0.065s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 subinterpreters&lt;/td&gt;
&lt;td&gt;0.076 - 0.078s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Creating four subinterpreters and running a small amount of code in each took about twice as long as a classic &lt;code&gt;fork&lt;/code&gt;-based process pool, and somewhat longer than the new &lt;code&gt;forkserver&lt;/code&gt; default too. None of this matters once the work inside each worker takes more than a few milliseconds - the 3.6s-versus-0.9s number above swallows an 80-millisecond setup cost without noticing. But it means "subinterpreters are lighter than processes" is not something I could verify at creation time; the saving, where it exists, is in not forking a second copy of the process's address space, not in any faster start.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;forkserver&lt;/code&gt; row was an accident. I wrote a timing script that called &lt;code&gt;multiprocessing.Process(...).start()&lt;/code&gt; directly from the module's top level, the way plenty of tutorial code still does, and it crashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RuntimeError:
        An attempt has been made to start a new process before the
        current process has finished its bootstrapping phase.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking &lt;code&gt;multiprocessing.get_start_method()&lt;/code&gt; explained it: Python 3.14 changed the default start method on Linux from &lt;code&gt;fork&lt;/code&gt; to &lt;code&gt;forkserver&lt;/code&gt;. Python 3.13 reports &lt;code&gt;fork&lt;/code&gt;; 3.14 reports &lt;code&gt;forkserver&lt;/code&gt;. &lt;code&gt;forkserver&lt;/code&gt; re-imports your module in the child, which is exactly the thing &lt;code&gt;if __name__ == "__main__":&lt;/code&gt; exists to guard against, and which &lt;code&gt;fork&lt;/code&gt; never required you to bother with. Every piece of multiprocessing code I'd written without that guard over the years happened to work by accident, on Linux, until this release. It's a one-line fix, but it is a real break, and it landed in the same release that gives you an alternative that doesn't have this problem at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs: closing an interpreter doesn't give the memory back
&lt;/h2&gt;

&lt;p&gt;I ran four rounds of "create four interpreters, give each a 200,000-element list, close them", watching my own process's RSS the whole time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;round 1: 16920 -&amp;gt; 55648 KB
round 2: 55648 -&amp;gt; 57140 KB
round 3: 57140 -&amp;gt; 59476 KB
round 4: 59476 -&amp;gt; 61096 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.close()&lt;/code&gt; does finalise the interpreter - a destroyed interpreter can't be called again, and I checked that it raises if you try - but the process's resident memory never drops back to where it started. It flattens out (each later round adds less than the first) rather than growing without bound, which looks like CPython's allocator holding onto freed arenas rather than a genuine leak, but I didn't chase that further. If you're planning to cycle through many short-lived interpreters inside one long-running process, budget for that memory to stay resident, not to be returned to the OS the way a finished process's memory is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually shareable between interpreters
&lt;/h2&gt;

&lt;p&gt;Subinterpreters don't share objects by default - that is the entire point, it's what lets them skip the GIL - so most interesting values need to cross a boundary. &lt;code&gt;is_shareable()&lt;/code&gt; gives a yes/no answer per type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;int              -&amp;gt; True
str              -&amp;gt; True
bytes            -&amp;gt; True
None             -&amp;gt; True
bool             -&amp;gt; True
tuple of ints    -&amp;gt; True
float            -&amp;gt; True
list             -&amp;gt; False
dict             -&amp;gt; False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing a &lt;code&gt;threading.Lock&lt;/code&gt; as an argument to &lt;code&gt;Interpreter.call()&lt;/code&gt; fails immediately, with the repr of the offending object right there in the message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NotShareableError: (&amp;lt;unlocked _thread.lock object at 0x7f61b8b5add0&amp;gt;,) does not support cross-interpreter data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An ordinary custom class instance fails the same way, with a flatter message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NotShareableError: object does not support cross-interpreter data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the cross-interpreter &lt;code&gt;Queue&lt;/code&gt; that the same module provides does not enforce this. I put a plain custom-class instance onto a queue and it went through without complaint, and came back out as a distinct, working object with the same attribute values - the queue silently pickles and unpickles it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;interpreters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_queue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Thing&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;          &lt;span class="c1"&gt;# no error
&lt;/span&gt;&lt;span class="n"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&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="c1"&gt;# received is not the original object, but received.value is unchanged
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "not shareable" is enforced differently depending on how the object crosses over. A direct argument to &lt;code&gt;call()&lt;/code&gt; is checked against the strict shareable-types list and rejected on the spot. The same object dropped into a &lt;code&gt;Queue&lt;/code&gt; gets the softer treatment the docs describe for &lt;code&gt;put()&lt;/code&gt;: pickled going in, unpickled coming out as a new, unrelated object. If your code passes data between interpreters by argument, moving it to a queue instead buys you a much larger set of types - at the cost of identity and, for anything mutable, of staying in sync with the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;My first attempt at the shareability tests put several checks in one script with no &lt;code&gt;if __name__ == "__main__":&lt;/code&gt; guard, calling &lt;code&gt;interp.call()&lt;/code&gt; with a mix of shareable and non-shareable arguments one after another. Running it printed every result twice, and the second copy sometimes disagreed with the first about which error got raised. My best explanation, which I didn't fully verify, is that &lt;code&gt;call()&lt;/code&gt;'s documented pickle fallback needs to resolve names against the target interpreter's own &lt;code&gt;__main__&lt;/code&gt;, and since that interpreter had never run my module, resolving anything defined at module level meant running the whole module there first - silently re-executing my test file a second time, inside the subinterpreter, with its own fresh state. Splitting each check into its own small script with a proper &lt;code&gt;__main__&lt;/code&gt; guard made every result reproducible, which is what's reported above. I'd treat any subinterpreter test that imports a lot of top-level code as suspect until it survives being run twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This needs Python 3.14 and four-ish cores to see the gap clearly. I ran it in Docker because that's the fastest way to get an exact version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull python:3.14-slim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core benchmark:&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;# bench.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cpu_work&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;is_prime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;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;range&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="nf"&gt;int&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="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;is_prime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_prime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_threads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cpu_work&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;threads&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="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;threads&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="nf"&gt;join&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;threads=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_interpreters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;concurrent.interpreters&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;interpreters&lt;/span&gt;
    &lt;span class="n"&gt;interps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;interpreters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;threads&lt;/span&gt; &lt;span class="o"&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="nf"&gt;call_in_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cpu_work&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;interps&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;threads&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="nf"&gt;join&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;interpreters=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;interps&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="nf"&gt;close&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;__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;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&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;threads&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run_threads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;interpreters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run_interpreters&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&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;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/w &lt;span class="nt"&gt;-w&lt;/span&gt; /w python:3.14-slim python3 bench.py threads 400000 4
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/w &lt;span class="nt"&gt;-w&lt;/span&gt; /w python:3.14-slim python3 bench.py interpreters 400000 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the refusal, which needs no setup at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; python:3.14-slim python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import concurrent.interpreters as interpreters
i = interpreters.create()
t1 = i.call_in_thread(lambda: __import__('time').sleep(1))
t2 = i.call_in_thread(lambda: __import__('time').sleep(1))
t1.join(); t2.join()
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;p&gt;If you have a CPU-bound Python workload that &lt;code&gt;multiprocessing&lt;/code&gt; already parallelises well, subinterpreters aren't an obvious upgrade yet: on this release they start up slower than a plain &lt;code&gt;fork&lt;/code&gt; and about the same as &lt;code&gt;forkserver&lt;/code&gt;, and you still need one interpreter per concurrent task, with no pooling against a single interpreter. Where they're worth trying now is the code that currently reaches for a thread, because threading is simpler to wire up than multiprocessing, and then quietly gets nothing from the GIL: swap the &lt;code&gt;Thread&lt;/code&gt; for an &lt;code&gt;Interpreter.call_in_thread&lt;/code&gt;, and check your data either fits the shareable-types list or is willing to cross as a pickled copy through a &lt;code&gt;Queue&lt;/code&gt;. And if you have multiprocessing code anywhere that starts a &lt;code&gt;Process&lt;/code&gt; without an &lt;code&gt;if __name__ == "__main__":&lt;/code&gt; guard, fix that first - 3.14 will find it on Linux even if nothing else in this post applies to you.&lt;/p&gt;

</description>
      <category>python</category>
      <category>performance</category>
      <category>programming</category>
      <category>devops</category>
    </item>
    <item>
      <title>Valkey 9.2's new rdbcompression lz4 setting cuts a 1M-row RDB from 178MB to 35MB</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/valkey-92s-new-rdbcompression-lz4-setting-cuts-a-1m-row-rdb-from-178mb-to-35mb-1cje</link>
      <guid>https://dev.to/alexgeorgiev17/valkey-92s-new-rdbcompression-lz4-setting-cuts-a-1m-row-rdb-from-178mb-to-35mb-1cje</guid>
      <description>&lt;p&gt;I set &lt;code&gt;rdbcompression lz4&lt;/code&gt; on a Valkey 9.2.0-rc1 primary, watched a replica do a full sync, and measured exactly the same number of bytes going over the wire as with compression off. For a few minutes I believed the feature didn't touch replication at all. It does. I just had the wrong setting.&lt;/p&gt;

&lt;p&gt;Valkey 9.2.0-rc1, released as a release candidate on 16 September with GA planned for mid-November, adds &lt;code&gt;rdbcompression lz4&lt;/code&gt;: whole-stream LZ4 compression for RDB files. Until now, RDB persistence has compressed each string value separately with LZF, a scheme the project's own issue tracker describes as "quite inefficient" because it can only find repetition inside one value, never across the file. I ran both schemes against three kinds of data and then went looking for where the new setting falls over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline number
&lt;/h2&gt;

&lt;p&gt;I loaded a million keys shaped like a typical user-profile hash, each a small JSON document with a handful of repeated field names and a handful of unique values, and ran &lt;code&gt;SAVE&lt;/code&gt; three times under each compression setting.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rdbcompression&lt;/th&gt;
&lt;th&gt;file size&lt;/th&gt;
&lt;th&gt;SAVE time (best of 3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;180.3 MB&lt;/td&gt;
&lt;td&gt;0.80s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yes (default, per-string LZF)&lt;/td&gt;
&lt;td&gt;177.6 MB&lt;/td&gt;
&lt;td&gt;1.10s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lz4&lt;/td&gt;
&lt;td&gt;35.4 MB&lt;/td&gt;
&lt;td&gt;1.16s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default LZF setting barely moves the needle: 180.3MB down to 177.6MB, a 1.5% saving, for the CPU cost of running compression at all. &lt;code&gt;lz4&lt;/code&gt; takes the same file down to 35.4MB, an 80.1% cut against the default and 80.4% against no compression whatsoever, for about 6% more CPU time than LZF already costs. I reran it on a second, more repetitive dataset — 200,000 synthetic log lines with a shared template — and the gap was the same shape: 31.47MB uncompressed and under LZF, 5.91MB with &lt;code&gt;lz4&lt;/code&gt;, an 81.2% cut.&lt;/p&gt;

&lt;p&gt;The reason LZF does almost nothing here is exactly the limitation the project describes: each JSON document is 150-200 bytes, with no exploitable repetition inside itself, and LZF never looks past one value's boundary. Whole-stream LZ4 compresses the serialized file as one byte stream, so the repeated &lt;code&gt;"active":true&lt;/code&gt;, &lt;code&gt;"roles":["user","member"]&lt;/code&gt; and field names across a million records are exactly the kind of redundancy it picks up and LZF structurally cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it doesn't help
&lt;/h2&gt;

&lt;p&gt;I tried the same experiment on 200,000 keys holding 32-byte random hex tokens, the kind of value you'd get from session IDs or API keys. There is no cross-record redundancy to find, and it showed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rdbcompression&lt;/th&gt;
&lt;th&gt;file size&lt;/th&gt;
&lt;th&gt;SAVE time (best of 3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;15.49 MB&lt;/td&gt;
&lt;td&gt;0.186s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;15.49 MB&lt;/td&gt;
&lt;td&gt;0.211s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lz4&lt;/td&gt;
&lt;td&gt;14.76 MB&lt;/td&gt;
&lt;td&gt;0.283s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 4.7% size reduction for roughly 35% more wall-clock time than LZF, and 52% more than no compression at all. On a database that's mostly tokens, counters or anything that already looks random, whole-stream LZ4 is close to pure overhead. The win is entirely a property of the data, not the algorithm being generically better than LZF — it just has a wider window to search in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;My first attempt at measuring the replication effect gave a flat, suspicious result: identical replica-side byte counts under &lt;code&gt;rdbcompression no&lt;/code&gt;, &lt;code&gt;yes&lt;/code&gt; and &lt;code&gt;lz4&lt;/code&gt;, down to a few kilobytes of jitter. I assumed the flag simply wasn't wired into the sync path yet, since this is a release candidate.&lt;/p&gt;

&lt;p&gt;Before concluding that, I checked whether my measurement itself was broken, by re-running a plain &lt;code&gt;SAVE&lt;/code&gt; on disk with the same dataset and the same setting. That still showed the expected 80% drop. So the instrumentation was fine; the behavior genuinely differed between a local save and a replication sync. Looking at the primary's log line during the sync was the actual answer: it read &lt;code&gt;Starting BGSAVE for SYNC with target: disk using: normal sync&lt;/code&gt;, with no mention of compression at all. Setting &lt;code&gt;rdbcompression lz4&lt;/code&gt; on the primary had done nothing for the replication path.&lt;/p&gt;

&lt;p&gt;The fix is a second setting, &lt;code&gt;repl-compression&lt;/code&gt;, which controls the replication stream independently of the on-disk RDB format. Once I set &lt;code&gt;repl-compression lz4&lt;/code&gt; on the primary, the exact same sync produced this log line instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;7:M 25 Oct 2026 05:12:15.412 * Disk-based full sync with compression: lz4
7:M 25 Oct 2026 05:12:15.587 * Replication compression enabled for replica 172.17.0.4:6379 (algo=lz4)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the replica's actual received bytes dropped from 17,627,802 to 3,435,304 — an 80.5% cut, in line with the file-based number. &lt;code&gt;rdbcompression lz4&lt;/code&gt; and &lt;code&gt;repl-compression lz4&lt;/code&gt; are two separate flags controlling two separate code paths, and the release note text I found for this change talks only about the first one. Setting only &lt;code&gt;rdbcompression lz4&lt;/code&gt; on a primary that replicates will leave every full sync exactly as large as before, silently, with no warning anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mixed fleets cost more than one BGSAVE
&lt;/h2&gt;

&lt;p&gt;I wanted to know what happens when an old replica and a compression-aware one both attach around the same time. I started a Valkey 8.1 replica and a 9.2.0-rc1 replica configured with &lt;code&gt;repl-compression lz4&lt;/code&gt; against the same primary within the same second. The legacy replica synced cleanly — all million keys landed, &lt;code&gt;master_link_status:up&lt;/code&gt;, no errors — so the fallback to plaintext for a replica that can't speak the new protocol works as I'd want. But the primary's log showed two separate &lt;code&gt;BGSAVE&lt;/code&gt; passes, not one shared fork:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;7:M * Current BGSAVE has socket target. Waiting for next BGSAVE for SYNC
7:M * Diskless rdb transfer, done reading from pipe, 1 replicas still up.
7:M * Streamed RDB transfer with replica 172.17.0.5:6379 succeeded (socket)...
7:M * Starting BGSAVE for SYNC with target: disk using: normal sync
7:M * Disk-based full sync with compression: lz4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plain diskless, uncompressed save served the 8.1 replica, then a second, disk-based, LZ4-compressed save ran immediately after to serve the 9.2 one. A fleet with a mix of old and new replicas pays for a full extra fork-and-save cycle per sync generation rather than sharing one, which matters on a large dataset where a single &lt;code&gt;BGSAVE&lt;/code&gt; already costs real CPU and I/O.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it from outside
&lt;/h2&gt;

&lt;p&gt;I expected &lt;code&gt;INFO persistence&lt;/code&gt; or &lt;code&gt;INFO replication&lt;/code&gt; to expose which algorithm was actually used, the way &lt;code&gt;rdb_last_bgsave_status&lt;/code&gt; or &lt;code&gt;master_link_status&lt;/code&gt; do. Neither field exists. I checked both sections after every save and sync in this post and found nothing: no &lt;code&gt;rdb_compression&lt;/code&gt;, no &lt;code&gt;repl_compression_algo&lt;/code&gt;, nothing under &lt;code&gt;INFO ALL&lt;/code&gt; either. The only record of which compression ran is the primary's own log line, and that line only appears for a replica that negotiated compression — a plaintext fallback sync produces a log entry identical to one with no compression configured at all. If you want to confirm in production whether a given replica actually got a compressed sync, you grep the primary's log for &lt;code&gt;algo=&lt;/code&gt; at the moment that replica attached. There's no way to ask the running server after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This needs Docker and roughly 400MB of free disk for the images and test data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull valkey/valkey:9.2.0-rc1
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vk &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/vk-data:/data"&lt;/span&gt; valkey/valkey:9.2.0-rc1 &lt;span class="nt"&gt;--save&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;

&lt;span class="c"&gt;# load ~1M small JSON-shaped values&lt;/span&gt;
python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
import subprocess, random
def feed(cmds):
    subprocess.run(['docker', 'exec', '-i', 'vk', 'valkey-cli', '--pipe'],
                    input=cmds.encode(), capture_output=True)
batch = []
for i in range(1_000_000):
    val = '{"id":%d,"name":"user%d","active":true,"roles":["user","member"]}' % (i, i)
    batch.append(f"SET user:{i} '{val}'")
    if len(batch) &amp;gt;= 200_000:
        feed('&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'.join(batch) + '&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'); batch = []
if batch:
    feed('&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'.join(batch) + '&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;')
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;c &lt;span class="k"&gt;in &lt;/span&gt;no &lt;span class="nb"&gt;yes &lt;/span&gt;lz4&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;vk valkey-cli config &lt;span class="nb"&gt;set &lt;/span&gt;rdbcompression &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
  docker &lt;span class="nb"&gt;exec &lt;/span&gt;vk &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /data/dump.rdb
  &lt;span class="nb"&gt;time &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;vk valkey-cli save &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;vk &lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;%s /data/dump.rdb&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; bytes"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# replication needs a second setting, not just rdbcompression:&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;vk valkey-cli config &lt;span class="nb"&gt;set &lt;/span&gt;repl-compression lz4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run this on a dataset you actually care about, check your own ratio before assuming it'll be 80%. The size I got is a property of how repetitive your values are across records, not a fixed number this version of Valkey guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;p&gt;If you're on Valkey and most of your values are small, structured and repeat a lot of their shape across keys — session hashes, user profiles, event records — &lt;code&gt;rdbcompression lz4&lt;/code&gt; is worth turning on for the RDB file alone; the CPU cost over the existing default is small next to an 80% size cut on disk and in backups. If you also replicate, set &lt;code&gt;repl-compression lz4&lt;/code&gt; on every primary explicitly and don't assume the RDB setting covers it — check the primary's log for &lt;code&gt;algo=lz4&lt;/code&gt; on the next full sync rather than trusting the config to have taken effect. And if your values are already high-entropy, measure your own data before turning anything on, because the win there was close to nothing in my tests.&lt;/p&gt;

</description>
      <category>valkey</category>
      <category>database</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Caddy 2.11.6 turns a silent config failure on /load into an HTTP 400</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Sat, 24 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/caddy-2116-turns-a-silent-config-failure-on-load-into-an-http-400-4656</link>
      <guid>https://dev.to/alexgeorgiev17/caddy-2116-turns-a-silent-config-failure-on-load-into-an-http-400-4656</guid>
      <description>&lt;p&gt;I posted a broken load-balancer config to Caddy's admin API and it told me the reload had worked. The server behind it stopped answering requests entirely. The API still said 200.&lt;/p&gt;

&lt;p&gt;That was on Caddy 2.11.4. I wanted to know whether the two patch releases that followed it, 2.11.6 (1 October) and 2.11.7 (3 October), actually closed that gap, because their changelogs claim "stricter config validation" and a fix for a Unix socket that used to hang on reload. Both claims turned out to be true, and more precise than I expected on the first one and more dramatic than I expected on the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config that lied about succeeding
&lt;/h2&gt;

&lt;p&gt;Caddy's admin API has a &lt;code&gt;/load&lt;/code&gt; endpoint: POST a JSON config, it replaces the running one. I sent this, which gives a &lt;code&gt;weighted_round_robin&lt;/code&gt; policy a negative weight:&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="nl"&gt;"load_balancing"&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;"selection_policy"&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;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"weighted_round_robin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"weights"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Caddy 2.11.4:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/out.json &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'HTTP %{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;--data&lt;/span&gt; @bad_weighted.json &lt;span class="se"&gt;\&lt;/span&gt;
    http://localhost:2019/load
HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I curled the server's actual listener on port 8080:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'HTTP %{http_code}\n'&lt;/span&gt; http://localhost:8080/
curl: &lt;span class="o"&gt;(&lt;/span&gt;52&lt;span class="o"&gt;)&lt;/span&gt; Empty reply from server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The admin API said the new config was live. The thing it was supposedly serving had stopped responding. On Caddy 2.11.6 and 2.11.7, the same POST gets this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP 400
{"error":"loading config: loading new config: loading http app module: provision http:
server srv0: ... http.reverse_proxy.selection_policies.weighted_round_robin:
invalid configuration: weighted_round_robin requires at least one upstream with a
positive weight"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the server that was already running kept running. I reloaded a known-good config on 2.11.7, confirmed it was serving, sent the broken one, got the 400, and checked again:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;2.11.4&lt;/th&gt;
&lt;th&gt;2.11.7&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;POST bad config to &lt;code&gt;/load&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;400&lt;/code&gt; with error body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live traffic afterwards&lt;/td&gt;
&lt;td&gt;empty reply, server down&lt;/td&gt;
&lt;td&gt;unaffected, still serving old config&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you have anything automated that pushes Caddy config over this endpoint and only checks for a 2xx, on 2.11.4 and earlier that check was lying to you.&lt;/p&gt;

&lt;p&gt;Caddy logs every admin API request, which is the other way to catch this if you're watching logs rather than status codes. On 2.11.4, the log line for the bad load reads:&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="nl"&gt;"logger"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"admin.api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"load complete"&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;On 2.11.7, the same POST produces:&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="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"logger"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"admin.api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"request error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"... weighted_round_robin:
invalid configuration: weighted_round_robin requires at least one upstream with a positive
weight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"status_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Load complete" is exactly what it sounds like: as far as 2.11.4 is concerned, nothing went wrong. A log-based alert watching for &lt;code&gt;admin.api&lt;/code&gt; errors would have caught this on the newer version and found nothing at all to catch on the older one, same as checking the status code would.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's a specific list, not a general re-check
&lt;/h2&gt;

&lt;p&gt;The changelog names several newly-rejected categories, and I tested two more of them directly, because I wanted to know whether "stricter" meant "all of it" or "this list, no more."&lt;/p&gt;

&lt;p&gt;A Caddyfile with a duplicate &lt;code&gt;uri&lt;/code&gt; inside &lt;code&gt;forward_auth&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;forward_auth&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;9099&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;uri&lt;/span&gt; &lt;span class="n"&gt;/auth1&lt;/span&gt;
    &lt;span class="s"&gt;uri&lt;/span&gt; &lt;span class="n"&gt;/auth2&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 2.11.4, &lt;code&gt;caddy adapt&lt;/code&gt; accepts this with no warning and silently keeps the second value. On 2.11.7:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ caddy adapt --config Caddyfile.forwardauth
{"level":"error","msg":"parsing caddyfile tokens for 'forward_auth': cannot re-declare uri: /auth2, at /Caddyfile.forwardauth:4"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1. A &lt;code&gt;map&lt;/code&gt; handler with a destination placeholder missing its closing brace behaved the same way: 200 on 2.11.4, and on 2.11.7:&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"... http.handlers.map: destination 0 must be a placeholder and only a
placeholder, but got '{http.vars.unterminated'"&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;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;I assumed "stricter validation" meant any previously-accepted mistake would now be caught, so I tried two more cases expecting the same result and got surprised twice.&lt;/p&gt;

&lt;p&gt;First, duplicate &lt;code&gt;@id&lt;/code&gt; values on two routes. I expected this to be a new catch. It wasn't — Caddy 2.11.4 already rejects it:&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"loading config: indexing config: duplicate ID 'r1' found at
/config/apps/http/servers/srv0/routes/0 and /config/apps/http/servers/srv0/routes/1"&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;Same error, same HTTP 400, on both versions. Second, a &lt;code&gt;file_limit&lt;/code&gt; set to &lt;code&gt;3.5&lt;/code&gt; where the schema wants an integer. Also already rejected on 2.11.4, by Go's own JSON decoder rather than anything Caddy added:&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="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"... json: cannot unmarshal number 3.5 into Go struct field
Browse.browse.file_limit of type int"&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;So the fix is a named list of specific gaps — duplicate &lt;code&gt;named_routes&lt;/code&gt;, duplicate &lt;code&gt;forward_auth&lt;/code&gt; subdirectives, bad &lt;code&gt;weighted_round_robin&lt;/code&gt; weights, ambiguous &lt;code&gt;map&lt;/code&gt; inputs, and a handful of others — not a blanket pass over every handler. Worth knowing before you assume a config mistake you haven't tested is now covered.&lt;/p&gt;

&lt;p&gt;I also checked a boundary case on the weight fix, because "stricter" sometimes means "stricter than it should be." A weight of &lt;code&gt;0&lt;/code&gt;, as opposed to negative, is accepted on 2.11.7:&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="nl"&gt;"weights"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&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;returns &lt;code&gt;200&lt;/code&gt;. The rule is "at least one positive weight," not "no zeroes," so taking an upstream fully out of rotation with a zero weight still works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Unix socket that took two minutes to let go
&lt;/h2&gt;

&lt;p&gt;This is the one I didn't expect to be able to measure cleanly, and it was the most striking result of the day.&lt;/p&gt;

&lt;p&gt;The 2.11.7 changelog says a reload that moves a server's listener off a Unix socket used to leave the old socket file in place and leave clients connecting to it hanging for "about two minutes," fixed by closing it immediately. I set up Caddy 2.11.6 listening on &lt;code&gt;unix//tmp/sock_a&lt;/code&gt;, confirmed it answered, then POSTed a new config that moves the same server to &lt;code&gt;unix//tmp/sock_b&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="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'HTTP %{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;--data&lt;/span&gt; @b2.json &lt;span class="se"&gt;\&lt;/span&gt;
    http://localhost:2019/load
HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I polled the old socket path every half second or so. For roughly the first 100 seconds, every attempt timed out rather than failing fast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around the 110-second mark it flipped to a connection reset, and by about 120 seconds it was a clean, instant refusal. The socket file itself (&lt;code&gt;/tmp/sock_a&lt;/code&gt;) was still sitting on disk the entire time — &lt;code&gt;ls&lt;/code&gt; kept finding it right up until the connections started failing fast. That is close enough to the changelog's "about two minutes" that I don't think I got lucky with the timing; it's a real, repeatable window.&lt;/p&gt;

&lt;p&gt;On Caddy 2.11.7, running the identical reload:&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;&lt;span class="nb"&gt;ls&lt;/span&gt; /tmp/sock_a
&lt;span class="go"&gt;ls: cannot access '/tmp/sock_a': No such file or directory
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--unix-socket&lt;/span&gt; /tmp/sock_a http://localhost/
&lt;span class="go"&gt;curl: (7) Failed to connect to localhost port 80 after 0 ms: Couldn't connect to server
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file was gone and the refusal was instant, checked immediately after the &lt;code&gt;/load&lt;/code&gt; call returned and ten more times over the next three seconds. No timeout anywhere in that window.&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;2.11.6&lt;/th&gt;
&lt;th&gt;2.11.7&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Old socket file after reload&lt;/td&gt;
&lt;td&gt;still present&lt;/td&gt;
&lt;td&gt;removed immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connecting to old socket&lt;/td&gt;
&lt;td&gt;hangs ~100-120s, then reset/refused&lt;/td&gt;
&lt;td&gt;refused instantly, 0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you reload Caddy in a loop as part of a deploy pipeline and something downstream reconnects on a fixed interval, the old version could leave that something stuck for two minutes against a socket nobody is serving on any more. That is a real production cost, not a cosmetic log message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This needs Docker and about ten minutes once the images are pulled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull caddy:2.11.4 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker pull caddy:2.11.7

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; good.json &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[
  {"handle":[{"handler":"static_response","body":"hello-good"}]}
]}}}}}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Deliberately invalid: a negative weight&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; bad_weighted.json &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{
  "handler":"reverse_proxy",
  "load_balancing":{"selection_policy":{"policy":"weighted_round_robin","weights":[1,-3,2]}},
  "upstreams":[{"dial":"127.0.0.1:9001"},{"dial":"127.0.0.1:9002"},{"dial":"127.0.0.1:9003"}]
}]}]}}}}}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;v &lt;span class="k"&gt;in &lt;/span&gt;2.11.4 2.11.7&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; caddy-&lt;span class="nv"&gt;$v&lt;/span&gt; caddy:&lt;span class="nv"&gt;$v&lt;/span&gt; caddy run &lt;span class="nt"&gt;--resume&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
  &lt;span class="nb"&gt;sleep &lt;/span&gt;1
  docker &lt;span class="nb"&gt;cp &lt;/span&gt;good.json caddy-&lt;span class="nv"&gt;$v&lt;/span&gt;:/good.json
  docker &lt;span class="nb"&gt;cp &lt;/span&gt;bad_weighted.json caddy-&lt;span class="nv"&gt;$v&lt;/span&gt;:/bad_weighted.json
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== &lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt; =="&lt;/span&gt;
  docker &lt;span class="nb"&gt;exec &lt;/span&gt;caddy-&lt;span class="nv"&gt;$v&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'load good: HTTP %{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;--data&lt;/span&gt; @/good.json http://localhost:2019/load
  docker &lt;span class="nb"&gt;exec &lt;/span&gt;caddy-&lt;span class="nv"&gt;$v&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'traffic after good: HTTP %{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    http://localhost:8080/
  docker &lt;span class="nb"&gt;exec &lt;/span&gt;caddy-&lt;span class="nv"&gt;$v&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'load bad: HTTP %{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;--data&lt;/span&gt; @/bad_weighted.json http://localhost:2019/load
  docker &lt;span class="nb"&gt;exec &lt;/span&gt;caddy-&lt;span class="nv"&gt;$v&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'traffic after bad: HTTP %{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    http://localhost:8080/
  docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; caddy-&lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
  &lt;span class="nb"&gt;echo
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the actual output from running it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== 2.11.4 ==
load good: HTTP 200
traffic after good: HTTP 200
load bad: HTTP 200
traffic after bad: HTTP 000

== 2.11.7 ==
load good: HTTP 200
traffic after good: HTTP 200
load bad: HTTP 400
traffic after bad: HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 2.11.4, the bad config reports &lt;code&gt;200&lt;/code&gt; and the server stops answering (&lt;code&gt;000&lt;/code&gt;, connection failure). On 2.11.7, the bad config is refused with &lt;code&gt;400&lt;/code&gt; and the server carries on serving &lt;code&gt;hello-good&lt;/code&gt; exactly as before. The &lt;code&gt;/load&lt;/code&gt; response alone tells you which version you're running, without needing a second request against the data path at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;p&gt;If anything you run pushes configuration to Caddy's admin API — a CI job, a control plane, a sidecar — and it treats a 2xx from &lt;code&gt;/load&lt;/code&gt; as "the new config is live and correct," go check which version you're running. Before 2.11.6, that check could pass while the server underneath it was already broken. After 2.11.6, it still can't tell you about every possible mistake, only the specific ones listed in the changelog, so don't drop your own post-reload health check just because the API got stricter.&lt;/p&gt;

&lt;p&gt;And if you reload Caddy on a schedule and something else in your infrastructure dials a Unix socket Caddy used to own, check your Caddy version before assuming a two-minute stall is your code's fault.&lt;/p&gt;

</description>
      <category>caddy</category>
      <category>devops</category>
      <category>networking</category>
      <category>performance</category>
    </item>
    <item>
      <title>nginx 1.31.5's control API turns a failed reload into an HTTP 422 instead of a log line</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/nginx-1315s-control-api-turns-a-failed-reload-into-an-http-422-instead-of-a-log-line-48bc</link>
      <guid>https://dev.to/alexgeorgiev17/nginx-1315s-control-api-turns-a-failed-reload-into-an-http-422-instead-of-a-log-line-48bc</guid>
      <description>&lt;p&gt;I bound nginx's new control socket to a plain TCP port, sent it a bare &lt;code&gt;curl&lt;/code&gt; with no headers and no credentials from outside the container, and it handed back the live PIDs of both worker processes. A second &lt;code&gt;curl&lt;/code&gt;, still with nothing attached, swapped the running configuration for a different one.&lt;/p&gt;

&lt;p&gt;That's the control API, a feature nginx 1.31.5 added to the open-source build on 2 September 2026. It lets you reload nginx over HTTP instead of a &lt;code&gt;kill -HUP&lt;/code&gt; or &lt;code&gt;nginx -s reload&lt;/code&gt;, and get a structured answer back instead of a line in the error log. I tested it on nginx 1.31.8, the current mainline, which carries the same feature forward unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding it
&lt;/h2&gt;

&lt;p&gt;You turn it on with a new command-line flag, not a config directive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nginx &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'daemon off;'&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; unix:/tmp/nginx_control.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official &lt;code&gt;nginx:1.31.8&lt;/code&gt; Docker image already ships the binary with &lt;code&gt;--with-control-api&lt;/code&gt; compiled in, so no custom build was needed. Querying it from inside the container found the endpoint tree by walking it one GET at a time:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--unix-socket&lt;/span&gt; /tmp/nginx_control.sock http://localhost/
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--unix-socket&lt;/span&gt; /tmp/nginx_control.sock http://localhost/1/
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"control"&lt;/span&gt;,&lt;span class="s2"&gt;"nginx"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--unix-socket&lt;/span&gt; /tmp/nginx_control.sock http://localhost/1/control
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"processes"&lt;/span&gt;,&lt;span class="s2"&gt;"config"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GET /1/control/config&lt;/code&gt; returns the live config file as JSON, name and full content. &lt;code&gt;PATCH /1/control/config&lt;/code&gt; with no body re-reads the file from disk and reloads, which is the one I cared about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline: a reload failure you can parse
&lt;/h2&gt;

&lt;p&gt;I broke the config on purpose — a typo, &lt;code&gt;retrun&lt;/code&gt; instead of &lt;code&gt;return&lt;/code&gt; — and reloaded it both ways.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -i --unix-socket /tmp/nginx_control.sock -X PATCH http://localhost/1/control/config
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{"logs":["2026/10/23 05:11:41 [emerg] 1#1: unknown directive \"retrun\" in /etc/nginx/nginx.conf:6\n"]}
&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;$ nginx -s reload
2026/10/23 05:11:41 [emerg] 131#131: unknown directive "retrun" in /etc/nginx/nginx.conf:6
nginx: [emerg] unknown directive "retrun" in /etc/nginx/nginx.conf:6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both refuse the bad config and keep the old one running — nginx has always done that part safely, and I confirmed the previous config was still being served after each attempt. The difference is what a calling script gets back. The signal route gives you a process exit code and a line that only exists in the error log, which means a deploy script either parses log text or just trusts the exit code and hopes. The control API gives you a 422 and the exact compiler message as a JSON array, in the response body, with nothing to tail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opposite reading: it isn't faster
&lt;/h2&gt;

&lt;p&gt;Before I found the error-reporting difference I assumed the API route would at least be quicker, since it skips sending a signal and waiting for the master to notice it. Five reloads each, same container, alternating between two valid configs so every run was a genuine reload rather than a no-op:&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;Trial 1&lt;/th&gt;
&lt;th&gt;Trial 2&lt;/th&gt;
&lt;th&gt;Trial 3&lt;/th&gt;
&lt;th&gt;Trial 4&lt;/th&gt;
&lt;th&gt;Trial 5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PATCH /1/control/config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;102ms&lt;/td&gt;
&lt;td&gt;91ms&lt;/td&gt;
&lt;td&gt;90ms&lt;/td&gt;
&lt;td&gt;101ms&lt;/td&gt;
&lt;td&gt;102ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nginx -s reload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;83ms&lt;/td&gt;
&lt;td&gt;78ms&lt;/td&gt;
&lt;td&gt;85ms&lt;/td&gt;
&lt;td&gt;96ms&lt;/td&gt;
&lt;td&gt;100ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those numbers include &lt;code&gt;docker exec&lt;/code&gt;'s own overhead, so treat them as a comparison between the two methods rather than nginx's true reload latency. By that comparison, the signal route was if anything a touch quicker, never slower. The control API's benefit is entirely in what it tells you, not in how fast it gets there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under concurrency: in-flight requests didn't care either way
&lt;/h2&gt;

&lt;p&gt;I proxied a request to a backend that sleeps three seconds before answering, started the request, and reloaded nginx one second in — once with the API, once with the signal, three trials each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Trial 1&lt;/th&gt;
&lt;th&gt;Trial 2&lt;/th&gt;
&lt;th&gt;Trial 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API reload at 1s&lt;/td&gt;
&lt;td&gt;3.005s, HTTP 200&lt;/td&gt;
&lt;td&gt;3.004s, HTTP 200&lt;/td&gt;
&lt;td&gt;3.004s, HTTP 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signal reload at 1s&lt;/td&gt;
&lt;td&gt;3.003s, HTTP 200&lt;/td&gt;
&lt;td&gt;3.004s, HTTP 200&lt;/td&gt;
&lt;td&gt;3.003s, HTTP 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No dropped connections, no difference from a reload-free baseline of the same request (3.012s). I was suspicious of this result at first, because "no difference" is the answer a broken test gives you by default. So I checked the obvious failure: did the reload actually happen during the request, not before or after it? The worker PIDs confirmed it — the old workers showed &lt;code&gt;"exiting":true&lt;/code&gt; in &lt;code&gt;/1/control/processes&lt;/code&gt; while the slow request was still in flight, and new PIDs appeared, so the drain was genuinely overlapping the request. nginx's graceful worker shutdown applies the same way regardless of which door you reload through, which is reassuring rather than interesting, but it's the kind of thing worth checking rather than assuming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually exposes
&lt;/h2&gt;

&lt;p&gt;nginx's own release notes call binding this to a network port "strongly discouraged" and say the API carries no authentication of its own. I wanted the exact shape of that risk, not just the warning.&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;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 19000:9000 nginx:1.31.8 nginx &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'daemon off;'&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; 0.0.0.0:9000
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:19000/1/control/processes
&lt;span class="go"&gt;[{"name":"worker process","pid":30,"exiting":false},{"name":"worker process","pid":31,"exiting":false}]
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH http://127.0.0.1:19000/1/control/config
&lt;span class="go"&gt;{"logs":[]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both requests ran from the host, outside the container, with no &lt;code&gt;Authorization&lt;/code&gt; header, no API key, nothing but a bare URL. The second one actually replaced the running configuration — I'd already copied a different config file into the container first, and the content being served changed immediately after. There's no login, no token, no IP allowlist built into the feature itself. Anyone who can reach that port can read live process state and push a complete config swap.&lt;/p&gt;

&lt;p&gt;The documented mitigation is to bind the control socket to the filesystem instead and rely on normal file permissions. I checked that it actually works rather than taking it on faith. The default unix socket came up as &lt;code&gt;srw-------&lt;/code&gt;, owner and group both root, world nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ls -la /tmp/nginx_control.sock
srw------- 1 root root 0 Oct 23 05:11 /tmp/nginx_control.sock
$ docker exec --user nobody mycontainer curl -s --unix-socket /tmp/nginx_control.sock http://localhost/1/control/processes
(exit code 7 — couldn't connect)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As &lt;code&gt;nobody&lt;/code&gt;, the connection was refused before it ever reached nginx. So the workaround is real: a unix socket with tight permissions genuinely blocks an unprivileged user on the same host, which is more than the API does for itself over a network interface, where it blocks nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to leave open
&lt;/h2&gt;

&lt;p&gt;I compared the master process's resident memory with and without the &lt;code&gt;-l&lt;/code&gt; flag, same image, same otherwise-empty config:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Master process VmRSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No control socket&lt;/td&gt;
&lt;td&gt;9,164 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control socket open&lt;/td&gt;
&lt;td&gt;9,468 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;About 304KB, roughly 3.3%, just for keeping the listener alive with nothing connected to it. Small, and nobody's capacity planning is going to notice it, but it's a real, non-zero cost for a feature you might enable once and forget about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;My first attempt at testing the reload used a host-side bind mount for the config file, editing it with &lt;code&gt;sed&lt;/code&gt; on the host and expecting the running container to see the change immediately, the way bind mounts normally work. It didn't: the container kept serving the old content no matter what I wrote to the host file, and I spent a while assuming the control API itself was silently failing to apply changes. It wasn't. The sandboxed Docker setup I was running in didn't propagate host-side edits to an already-mounted file the way a normal Docker host does. Switching to &lt;code&gt;docker cp&lt;/code&gt; to push the new config file directly into the container, then triggering the reload, fixed it immediately and every measurement above uses that approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull nginx:1.31.8
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; ngxtest &lt;span class="nt"&gt;-p&lt;/span&gt; 18080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
  nginx:1.31.8 nginx &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'daemon off;'&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; unix:/tmp/nginx_control.sock

&lt;span class="c"&gt;# confirm the socket and walk the endpoint tree&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;ngxtest curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--unix-socket&lt;/span&gt; /tmp/nginx_control.sock http://localhost/1/control

&lt;span class="c"&gt;# push a new config and reload through the API&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; new.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
events {}
http {
    server {
        listen 8080;
        location / { return 200 "v2&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"; }
    }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;docker &lt;span class="nb"&gt;cp &lt;/span&gt;new.conf ngxtest:/etc/nginx/nginx.conf
docker &lt;span class="nb"&gt;exec &lt;/span&gt;ngxtest curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--unix-socket&lt;/span&gt; /tmp/nginx_control.sock &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; PATCH http://localhost/1/control/config

curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:18080/   &lt;span class="c"&gt;# should print v2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran that exact sequence again right before publishing this, on a fresh container, and it produced &lt;code&gt;v2&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;p&gt;If you already run &lt;code&gt;nginx -s reload&lt;/code&gt; from a deploy script and have ever had it fail silently in a way you only noticed later, the control API is worth wiring in for the structured error alone — a 422 with the compiler's own message beats grepping an error log you may not even be shipping anywhere. Don't bind it to anything but a unix socket, and don't treat the socket's default permissions as optional — they're the only access control this feature has. If you're auditing a host that already runs nginx 1.31.5 or newer, check now whether anyone turned this on with &lt;code&gt;-l&lt;/code&gt; pointed at a network address, because nothing in the feature itself will stop a request from working.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>security</category>
      <category>networking</category>
    </item>
    <item>
      <title>Git 2.56's last-modified command is fast on one directory and slow on scattered files</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/git-256s-last-modified-command-is-fast-on-one-directory-and-slow-on-scattered-files-2ggm</link>
      <guid>https://dev.to/alexgeorgiev17/git-256s-last-modified-command-is-fast-on-one-directory-and-slow-on-scattered-files-2ggm</guid>
      <description>&lt;p&gt;I ran the same command on the same 40 files twice. The only thing I changed was which 40 files. The first run took 861 milliseconds. The second took 6.4 seconds, and lost to the thing it's supposed to replace.&lt;/p&gt;

&lt;p&gt;Git 2.52.0 added &lt;code&gt;git last-modified&lt;/code&gt;, a command that walks history once and reports, for every path you give it, the closest commit that touched it. It exists because the obvious alternative — running &lt;code&gt;git log -1 -- &amp;lt;path&amp;gt;&lt;/code&gt; once per file — is what every repo browser's file listing has always done, and it's wasteful: each call re-walks history from scratch for one path. &lt;code&gt;git last-modified&lt;/code&gt; is meant to do that walk once and answer for all the paths at the same time. It's still marked &lt;code&gt;EXPERIMENTAL&lt;/code&gt; in its own manual page, and it picked up real bug fixes as late as 2.54, so I wanted to know what the current stable release, 2.56.0 (released 27 September 2026), actually delivers.&lt;/p&gt;

&lt;p&gt;I tested against the git/git repository itself, which is also where the original performance discussion happened. I cloned it with &lt;code&gt;--filter=blob:none&lt;/code&gt; (full commit and tree history, no file contents, since &lt;code&gt;last-modified&lt;/code&gt; never needs blob bytes) and built Git 2.56.0 from source, because no package repository I could reach ships it yet. 82,542 commits, 4,857 tracked files, on a 4-core VM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline number
&lt;/h2&gt;

&lt;p&gt;The realistic case for this command is a repo browser listing one directory: here are these 40 files, show me who touched each one last. I ran it against git.git's top-level listing, three times each, fastest run reported:&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;Time (40 files, one directory)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git last-modified -- &amp;lt;40 files&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;861ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;per-file &lt;code&gt;git log -1&lt;/code&gt; loop&lt;/td&gt;
&lt;td&gt;2,981ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 3.5x faster, and I checked the actual output against &lt;code&gt;git log -1&lt;/code&gt; for five of the files by hand — every commit hash matched. It isn't fast because it's wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opposite reading: same file count, different shape, different winner
&lt;/h2&gt;

&lt;p&gt;The 40 files above all live in one directory, which means a lot of their history overlaps — the same release-prep commits, the same large refactors, touch several of them together. That overlap is exactly what lets a single history walk answer for all of them at once. So I built a second set of 40 files, picked at random from anywhere in the 4,857 tracked files, with no shared directory and mostly unrelated histories, and ran the identical comparison:&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;Time (40 files, scattered across the tree)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git last-modified -- &amp;lt;40 scattered files&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6,402ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;per-file &lt;code&gt;git log -1&lt;/code&gt; loop&lt;/td&gt;
&lt;td&gt;5,793ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same number of files, same repository, same machine. &lt;code&gt;last-modified&lt;/code&gt; is now the slower option. I pushed the scattered case further to see whether that gap was a fluke:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scattered file count&lt;/th&gt;
&lt;th&gt;&lt;code&gt;last-modified&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;log loop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;6.40s&lt;/td&gt;
&lt;td&gt;5.79s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;7.48s&lt;/td&gt;
&lt;td&gt;6.59s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;25.7s&lt;/td&gt;
&lt;td&gt;12.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Going from 50 to 100 scattered files should roughly double the loop's time, and it does (6.59s to 12.2s). &lt;code&gt;last-modified&lt;/code&gt; more than tripled over the same step (7.48s to 25.7s). I killed a 500-file run of the same shape after it had burned over four CPU-minutes without finishing — more evidence this isn't linear once the paths stop sharing history, not a timing fluke. Whatever bookkeeping the command does per tracked path seems to compound badly when the paths don't overlap, and the maintainer's own "about 6x faster" claim, which I could reproduce for a real directory listing, doesn't hold at all for a scattered set of the same size.&lt;/p&gt;

&lt;p&gt;The practical rule: this command is for "show me the directory," not "show me these files I picked from across the repo." A GitHub- or Forgejo-style file browser is exactly the first case. A dashboard that pulls together files from unrelated parts of a codebase is the second, and the per-file loop will beat it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  It also loses outright on a single file
&lt;/h2&gt;

&lt;p&gt;The whole appeal of &lt;code&gt;last-modified&lt;/code&gt; is spreading one history walk across many paths. Hand it exactly one path and there's nothing to spread, so the comparison just measures whatever setup the command does before it starts answering at all.&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;Time (1 file, &lt;code&gt;COPYING&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git last-modified -- COPYING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;684ms (min of 5), up to 1,582ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git log -1 -- COPYING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;431ms (min of 5), up to 744ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five runs each, and &lt;code&gt;last-modified&lt;/code&gt; never once beat &lt;code&gt;git log -1&lt;/code&gt; for this single file, even on its fastest attempt. Its spread was wider too, up to 1.58 seconds on a slow run against &lt;code&gt;git log&lt;/code&gt;'s worst of 744 milliseconds, which looks like the setup cost itself is a little unpredictable rather than just slower on average. If you're fetching the last-touch commit for one file, plain &lt;code&gt;git log -1&lt;/code&gt; is still the right tool, because there's no second path for the new command to amortize anything against.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pathspec order bug that isn't in any changelog
&lt;/h2&gt;

&lt;p&gt;The manual says the default (&lt;code&gt;--max-depth=0&lt;/code&gt;) "shows all paths matching the pathspec without descending into subtrees." I gave it a directory and one of its own children as two separate pathspecs, in both orders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git last-modified -- contrib
a9bd346d8b6570a950724d4196224a493b038243    contrib

$ git last-modified -- contrib/completion
1c1256682a54d6738880e11ed2edf4b36757ef6c    contrib/completion

$ git last-modified -- contrib contrib/completion
1c1256682a54d6738880e11ed2edf4b36757ef6c    contrib/completion
30cc6c91b618a26a355558d6ee6aadbe7a0d5d85    contrib/buildsystems
b837e5f4bc1577f267edfec5459babafc156d7ed    contrib/contacts
[... 15 more rows, one per immediate child of contrib ...]

$ git last-modified -- contrib/completion contrib
a9bd346d8b6570a950724d4196224a493b038243    contrib
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Asked alone, each path gives exactly one row, matching &lt;code&gt;git log -1&lt;/code&gt; for the same path. Asked together with the parent listed first, &lt;code&gt;contrib&lt;/code&gt; silently expands into all 18 of its immediate children and its own aggregate row disappears entirely — nothing told me it was recursing, and &lt;code&gt;--max-depth=0&lt;/code&gt; didn't stop it, because I'd already passed that explicitly. Reverse the order, child first, and it collapses correctly back to the single &lt;code&gt;contrib&lt;/code&gt; row. Nothing in the 2.52 through 2.56 release notes mentions this, and I couldn't find it described anywhere. For a tool meant to back a file browser, where the pathspec list is built from whatever the user clicked on, this is the kind of bug that would show up as an intermittent, hard-to-reproduce wrong listing, because it depends on argument order.&lt;/p&gt;

&lt;h2&gt;
  
  
  One bug that is actually fixed
&lt;/h2&gt;

&lt;p&gt;The 2.54 release notes claimed "giving git last-modified a tree (not a commit-ish) died an uncontrolled death, which has been corrected." I checked:&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;git last-modified &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse HEAD^&lt;span class="o"&gt;{&lt;/span&gt;tree&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; COPYING
error: revision argument &lt;span class="s1"&gt;'006b28c5d4599d10965d9e5bbe22450db8c24eca'&lt;/span&gt; is a tree, not a commit-ish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clean, named error, not a crash. That one holds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, and how it behaves under load
&lt;/h2&gt;

&lt;p&gt;Peak memory for both approaches landed close together — about 190MB for &lt;code&gt;last-modified&lt;/code&gt; against 50 paths, and roughly the same for the single heaviest &lt;code&gt;git log&lt;/code&gt; process in the loop. Both numbers are dominated by loading this repository's commit-graph and pack index, not by anything &lt;code&gt;last-modified&lt;/code&gt; itself allocates, so there's no memory story here worth claiming either way.&lt;/p&gt;

&lt;p&gt;Running four copies of the 40-file directory listing at once, simulating four people loading the same repo page simultaneously, finished in 1.3 seconds wall-clock on my 4-core box, against 3.64 seconds run one after another. It's a read-only command with nothing to lock, so it parallelises about as well as the core count allows, with no contention I could detect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;My first build failed outright: &lt;code&gt;make&lt;/code&gt; died on &lt;code&gt;http.c&lt;/code&gt; unable to find &lt;code&gt;curl/curl.h&lt;/code&gt;, because I'd skipped &lt;code&gt;libcurl4-openssl-dev&lt;/code&gt; on the assumption that a locally-run, locally-cloned binary wouldn't need network transport code at all. Git's Makefile doesn't separate "can clone over https" from "can do anything," so leaving &lt;code&gt;NO_CURL&lt;/code&gt; unset means it always tries to compile the curl-backed transport. Adding &lt;code&gt;NO_CURL=1 NO_EXPAT=1&lt;/code&gt; to the build fixed it in under two minutes, and the resulting binary works fine for everything in this post, since none of it touches a remote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This needs Git built from source, since 2.56 isn't in most distributions' repositories yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;blob:none https://github.com/git/git.git git-source
&lt;span class="nb"&gt;cd &lt;/span&gt;git-source &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git checkout v2.56.0
make &lt;span class="nt"&gt;-j&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;nproc&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;NO_GETTEXT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;NO_TCLTK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;NO_PERL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;NO_CURL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;NO_EXPAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
./git &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# git version 2.56.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the comparison itself, against any repo with real history (git/git works well because it's large and public):&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;subprocess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;GITBIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;REPO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./git&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;./git-source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;times&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reps&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;REPO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&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;times&lt;/span&gt;

&lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last-modified:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;GITBIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last-modified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;log loop:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sh&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;-c&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;for p in &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;; do git log -1 --format=%H -- &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$p&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;; done&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;Run it with a real directory's file list first (&lt;code&gt;git ls-tree HEAD --name-only&lt;/code&gt; inside some subdirectory), then with the same number of paths picked from across the tree, and compare. The gap flips.&lt;/p&gt;

&lt;p&gt;If you're building a file browser or a dashboard on top of this command, test it against your own directory shapes before trusting the "faster" claim, and don't pass a parent directory alongside one of its own children until the ordering bug above is fixed — list each directory's contents as its own call instead.&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Kubernetes 1.35's kubelet refuses to start on a cgroup v1 host by default</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Wed, 21 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/kubernetes-135s-kubelet-refuses-to-start-on-a-cgroup-v1-host-by-default-4o3h</link>
      <guid>https://dev.to/alexgeorgiev17/kubernetes-135s-kubelet-refuses-to-start-on-a-cgroup-v1-host-by-default-4o3h</guid>
      <description>&lt;p&gt;I tried to bring up a kind cluster on this machine using the current stable Kubernetes, 1.37.0, and the control plane never came up. &lt;code&gt;kubectl get nodes&lt;/code&gt; never returned anything, because the kubelet never got past its own startup checks. &lt;code&gt;systemctl status kubelet&lt;/code&gt; inside the node container showed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Active: activating (auto-restart) (Result: exit-code) since Wed 2026-10-21 05:20:55 UTC; 959ms ago
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restarting every second, forever. The actual error, from the journal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;E1021 05:20:25.129684 1003 run.go:72] "command failed" err="failed to validate kubelet
configuration, error: kubelet is configured to not run on a host using cgroup v1.
cgroup v1 support is unsupported and will be removed in a future release,
path: &amp;amp;TypeMeta{Kind:,APIVersion:,}"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This machine still runs cgroup v1. Starting with Kubernetes 1.35, that alone is now fatal, by default, with no grace period.&lt;/p&gt;

&lt;p&gt;Cgroup v1 is not some exotic setup. It's what you get on older kernels, older base images, and any host nobody has gone back to reconfigure since before cgroup v2 was the default. The failure mode here isn't "feature doesn't work as well as hoped." It's "kubelet will not run at all, and it will not tell you that until you're already mid-upgrade."&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming the boundary myself
&lt;/h2&gt;

&lt;p&gt;The behaviour is in a kubelet field called &lt;code&gt;FailCgroupV1&lt;/code&gt;, and Kubernetes' own blog, published two weeks before I ran this, says the default flipped to &lt;code&gt;true&lt;/code&gt; in 1.35. I didn't want to take that on faith, so I pulled three &lt;code&gt;kindest/node&lt;/code&gt; images and watched each one's kubelet directly with &lt;code&gt;docker exec&lt;/code&gt; and &lt;code&gt;journalctl&lt;/code&gt;, rather than waiting on the whole cluster to come up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;image&lt;/th&gt;
&lt;th&gt;kubelet on a cgroup v1 host&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kindest/node:v1.34.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;starts, logs one warning event, stays running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kindest/node:v1.35.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;exits immediately, restarts, exits again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kindest/node:v1.37.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;same as 1.35&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 1.34.0 the kubelet logged a single warning event and then just ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Message:cgroup v1 support is in maintenance mode, please migrate to cgroup v2
Type:Warning
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 1.35.0 and 1.37.0 it never got that far. Both produced the exact same fatal error above and both crash-looped indefinitely. The boundary is real, and it's exactly where the blog post says it is. I didn't test 1.36.1, the release in between, so I'm only reporting the boundary at 1.34 versus 1.35/1.37, not claiming anything about 1.36 specifically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the crash loop actually costs
&lt;/h2&gt;

&lt;p&gt;I measured the 1.37.0 node's restart counter twice, thirty seconds apart, with &lt;code&gt;systemctl show kubelet -p NRestarts&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;check&lt;/th&gt;
&lt;th&gt;elapsed since kubelet first ran&lt;/th&gt;
&lt;th&gt;restarts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;first&lt;/td&gt;
&lt;td&gt;~60s&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;second&lt;/td&gt;
&lt;td&gt;~90s&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 24 restarts in 30 seconds, or a full crash-and-respawn roughly every 1.25 seconds. It doesn't back off and it doesn't stop; I left a 1.35.0 node running longer and it reached 137 restarts with no sign of slowing. The node sat at 9.35% CPU doing nothing useful, and its systemd journal had already grown to 8.0MB purely from one error message looping. A single misconfigured cgroup v1 node, left alone, will burn CPU and fill its own log forever rather than failing once and going quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The documented fix stops the kubelet crash, nothing else
&lt;/h2&gt;

&lt;p&gt;The blog's workaround is a kubelet config line:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubelet.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KubeletConfiguration&lt;/span&gt;
&lt;span class="na"&gt;failCgroupV1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I applied this to a fresh 1.37.0 node through a kind config patch. It worked, exactly as advertised, at the kubelet level:&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;docker &lt;span class="nb"&gt;exec &lt;/span&gt;c137wa-control-plane systemctl show kubelet &lt;span class="nt"&gt;-p&lt;/span&gt; NRestarts
&lt;span class="nv"&gt;NRestarts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stable, no crash. But the blog's fix only covers the kubelet. On a real cluster you also run &lt;code&gt;kubeadm init&lt;/code&gt;, and kubeadm runs its own, separate preflight check before it ever touches the kubelet config. I ran that phase by hand, outside of kind, to see it fire on its own:&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;kubeadm init phase preflight &lt;span class="nt"&gt;--config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/kind/kubeadm.conf
&lt;span class="go"&gt;[preflight] Some fatal errors occurred:
    [ERROR SystemVerification]: cgroups v1 support is deprecated and will be removed
    in a future release. Please migrate to cgroups v2. To explicitly enable cgroups v1
    support for kubelet v1.35 or newer, you must set the kubelet configuration option
    'FailCgroupV1' to 'false'. You must also explicitly skip this validation. For more
    information, see https://git.k8s.io/enhancements/keps/sig-node/5573-remove-cgroup-v1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that last sentence again: kubeadm's own error message says you must &lt;em&gt;also&lt;/em&gt; explicitly skip its validation, separately from the kubelet setting. The fix is two flags, not one, and the first one I tried, &lt;code&gt;failCgroupV1: false&lt;/code&gt; alone, would have left a plain &lt;code&gt;kubeadm init&lt;/code&gt; dead on preflight regardless of what the kubelet config said. Adding the second flag does exactly what the error promises:&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;kubeadm init phase preflight &lt;span class="nt"&gt;--config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/kind/kubeadm.conf &lt;span class="nt"&gt;--ignore-preflight-errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SystemVerification
    &lt;span class="o"&gt;[&lt;/span&gt;WARNING SystemVerification]: cgroups v1 support is deprecated and will be removed
    &lt;span class="k"&gt;in &lt;/span&gt;a future release. ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same text, downgraded from a fatal error to a warning. That's the actual two-part workaround: &lt;code&gt;failCgroupV1: false&lt;/code&gt; in the kubelet config, and &lt;code&gt;--ignore-preflight-errors=SystemVerification&lt;/code&gt; on every &lt;code&gt;kubeadm init&lt;/code&gt;, &lt;code&gt;join&lt;/code&gt;, and &lt;code&gt;upgrade&lt;/code&gt;. The blog post I checked this against only documents the first half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The override has a quiet side effect
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;failCgroupV1: false&lt;/code&gt; applied and the kubelet running cleanly, I still found this in the same startup log, a few lines after the config dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I1021 05:21:07.455963 278 server.go:682] "Warning: MemoryQoS feature only works with
cgroups v2 on Linux, but enabled with cgroups v1"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Memory QoS has been a stable kubelet feature for several releases. Keeping cgroup v1 alive with the override doesn't just let an old node limp along unchanged — it silently drops a scheduling feature that's supposed to be on by default, with nothing in the fatal-error path warning you about it. If you adopt the override as a stopgap because migrating every node to cgroup v2 isn't a today problem, you've also quietly turned off memory QoS on all of them, and the only way you'd notice is reading the full startup log rather than grepping it for "error".&lt;/p&gt;

&lt;h2&gt;
  
  
  kind hides half of this by default
&lt;/h2&gt;

&lt;p&gt;While building the test above I noticed kind never actually runs the kubeadm preflight phase I quoted. The kubeadm config kind generates for every node includes this:&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;skipPhases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;preflight&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if your only exposure to this change is through kind, you will never see the &lt;code&gt;SystemVerification&lt;/code&gt; error or its warning at all — kind skips that check unconditionally, on every version, cgroup v1 or not. What you'll see instead is purely the kubelet's own internal validation killing the process a layer further down, which is the crash loop at the top of this post. The two checks exist independently in real Kubernetes, kubeadm's and kubelet's own, and testing through kind only exercises one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;My original plan was to demonstrate this against a fully working cluster: bring up kind, show &lt;code&gt;kubectl get nodes&lt;/code&gt; going &lt;code&gt;Ready&lt;/code&gt;, then break and fix it. I couldn't get that far. Every node I started, on every Kubernetes version including 1.34, failed to actually run its static pods, with containerd reporting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OCI runtime create failed: runc create failed: unable to start container process:
can't get final child's PID from pipe: EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a sandboxing limitation of the nested container environment this ran in, not a Kubernetes bug, and it hit 1.34 exactly as hard as 1.37. Once I confirmed the failure was identical and version-independent, I stopped trying to get a live cluster and tested the kubelet and kubeadm directly with &lt;code&gt;docker exec&lt;/code&gt; and &lt;code&gt;systemctl&lt;/code&gt;/&lt;code&gt;journalctl&lt;/code&gt; instead, which turned out to be the right layer for this question anyway, since the thing I was measuring happens entirely before any pod is ever scheduled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This needs &lt;code&gt;docker&lt;/code&gt; and the &lt;code&gt;kind&lt;/code&gt; and &lt;code&gt;kubectl&lt;/code&gt; binaries; no cloud account.&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;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/local/bin/kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/kind

&lt;span class="c"&gt;# default behaviour: crash loop on a cgroup v1 host&lt;/span&gt;
kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; c137 &lt;span class="nt"&gt;--image&lt;/span&gt; kindest/node:v1.37.0 &lt;span class="nt"&gt;--retain&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;c137-control-plane systemctl show kubelet &lt;span class="nt"&gt;-p&lt;/span&gt; NRestarts
docker &lt;span class="nb"&gt;exec &lt;/span&gt;c137-control-plane journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; kubelet &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"command failed"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
kind delete cluster &lt;span class="nt"&gt;--name&lt;/span&gt; c137

&lt;span class="c"&gt;# the two-part workaround&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/kind-workaround.yaml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  kubeadmConfigPatches:
  - |
    kind: KubeletConfiguration
    failCgroupV1: false
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; c137wa &lt;span class="nt"&gt;--image&lt;/span&gt; kindest/node:v1.37.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--config&lt;/span&gt; /tmp/kind-workaround.yaml &lt;span class="nt"&gt;--retain&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;c137wa-control-plane systemctl show kubelet &lt;span class="nt"&gt;-p&lt;/span&gt; NRestarts
docker &lt;span class="nb"&gt;exec &lt;/span&gt;c137wa-control-plane journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; kubelet &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; memoryqos
kind delete cluster &lt;span class="nt"&gt;--name&lt;/span&gt; c137wa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;grep cgroup /proc/filesystems&lt;/code&gt; and &lt;code&gt;stat -fc %T /sys/fs/cgroup&lt;/code&gt; first; if that prints &lt;code&gt;cgroup2fs&lt;/code&gt; you're already on v2 and none of this applies to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;stat -fc %T /sys/fs/cgroup&lt;/code&gt; on every node you plan to run Kubernetes 1.35 or later on. If it says &lt;code&gt;tmpfs&lt;/code&gt; rather than &lt;code&gt;cgroup2fs&lt;/code&gt;, you're on cgroup v1, and an in-place upgrade to 1.35+ will not degrade gracefully, it will crash-loop every kubelet on every affected node the moment the new binary starts. That's not a staged rollout risk, it's an outage the instant the new kubelet binary lands, with no warning beforehand unless someone already read the 1.35 changelog.&lt;/p&gt;

&lt;p&gt;Migrate to cgroup v2 before the upgrade if you can. If you genuinely can't yet, set both halves of the override, kubelet's &lt;code&gt;failCgroupV1: false&lt;/code&gt; and kubeadm's &lt;code&gt;--ignore-preflight-errors=SystemVerification&lt;/code&gt;, and then go looking in your kubelet logs for the Memory QoS warning, so you know, deliberately, what you gave up to keep the node alive, rather than finding out later from a scheduling decision that doesn't make sense. Cgroup v1's fallback path is on record as scheduled for removal in 1.38, two releases from now, so this buys you a reprieve measured in months, not a long-term plan. Put the migration on the calendar now, while the override still exists to buy you the time to do it properly.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>containers</category>
      <category>linux</category>
    </item>
    <item>
      <title>Docker Engine 29.9.1 fixes a docker save bug that left deleted layers on disk</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Tue, 20 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/docker-engine-2991-fixes-a-docker-save-bug-that-left-deleted-layers-on-disk-516a</link>
      <guid>https://dev.to/alexgeorgiev17/docker-engine-2991-fixes-a-docker-save-bug-that-left-deleted-layers-on-disk-516a</guid>
      <description>&lt;p&gt;I run &lt;code&gt;docker save&lt;/code&gt; sometimes to move an image between two machines that don't share a registry. I never thought about what happens to the layers afterwards once I &lt;code&gt;docker rmi&lt;/code&gt; the image. Docker Engine 29.9.1, released on 14 October, fixes a bug where the answer was: nothing. They stay on disk, forever, and no command can see them.&lt;/p&gt;

&lt;p&gt;The fix is one line in the 29.9.1 release notes: "&lt;code&gt;docker save&lt;/code&gt; no longer leaves layers on disk after image removal when an image repeats a layer." I pulled the moby pull request that made the fix (#53927) to see what "repeats a layer" meant, then built both the broken and fixed engine to check it myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The root cause is a reference count that never gets released. When &lt;code&gt;docker save&lt;/code&gt; writes out an image's config and layers, it takes a reference on each layer so it isn't garbage collected mid-write. The code released that reference in a deferred call, but only after checking whether the layer's diff ID had already been saved once in the same run. If an image lists the same diff ID twice in its &lt;code&gt;RootFS.Layers&lt;/code&gt; — two layers with byte-identical content — the second occurrence hit the "already saved" branch and returned before the deferred release ran. One layer walks away with a reference nobody ever drops.&lt;/p&gt;

&lt;p&gt;That's a strange precondition — why would an image list the same layer twice? It happens if a Dockerfile copies the same file into the image at two different steps. The content is identical, so the diff tar it produces is identical, so the layer digest is identical. I built one on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; busybox:1.36.1&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; f /f&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; f /f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;f&lt;/code&gt; was a few hundred bytes of text. &lt;code&gt;docker inspect&lt;/code&gt; showed exactly what the bug needs:&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="s2"&gt;"sha256:97e4ece8f8a4d01b66df2c55c1f1e8a2860597b54dffbbc0df2f9942062cb34d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="s2"&gt;"sha256:5a0a403383bddeef1a6e1d06106756361edc2cf35a15397ecdad4f2d10cc62d7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="s2"&gt;"sha256:5a0a403383bddeef1a6e1d06106756361edc2cf35a15397ecdad4f2d10cc62d7"&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;The second and third layers are the same digest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;

&lt;p&gt;I downloaded the static binaries for Docker Engine 29.8.2 (the version right before the fix) and 29.9.1 from &lt;code&gt;download.docker.com&lt;/code&gt;, and ran each with its own &lt;code&gt;--data-root&lt;/code&gt; so they couldn't interfere with each other. Both used the classic overlay2 graph driver, which the release notes specifically name as the affected store — I had to pass &lt;code&gt;"features": {"containerd-snapshotter": false}&lt;/code&gt; in &lt;code&gt;daemon.json&lt;/code&gt; to get that, since newer Docker defaults to the containerd image store.&lt;/p&gt;

&lt;p&gt;First, a control run on 29.8.2 with no &lt;code&gt;docker save&lt;/code&gt; involved at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker rmi repeat-layer-test busybox:1.36.1
Untagged: repeat-layer-test:latest
Deleted: sha256:d4ec96e71ec1...
Deleted: sha256:93763116f792...
Deleted: sha256:7698c5712b92...
Deleted: sha256:35dc035c6e26...
Untagged: busybox:1.36.1
Deleted: sha256:b116e1550744...
Deleted: sha256:97e4ece8f8a4...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/var/lib/docker/image/overlay2/layerdb/sha256&lt;/code&gt; went from 3 entries to 0. Clean removal, as expected.&lt;/p&gt;

&lt;p&gt;Then the same build, this time with &lt;code&gt;docker save&lt;/code&gt; in between:&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;docker save repeat-layer-test &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/repeat-layer-test.tar
&lt;span class="nv"&gt;$ &lt;/span&gt;docker rmi repeat-layer-test busybox:1.36.1
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /var/lib/docker/image/overlay2/layerdb/sha256 | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
3
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/lib/docker/overlay2
4.5M    /var/lib/docker/overlay2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tarball from &lt;code&gt;docker save&lt;/code&gt; was 4.5MB. After removing both images, &lt;code&gt;/var/lib/docker/overlay2&lt;/code&gt; was still 4.5MB. Nothing was freed. The three layer directories just sat there, owned by nothing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;scenario (29.8.2, overlay2)&lt;/th&gt;
&lt;th&gt;layerdb entries after rmi&lt;/th&gt;
&lt;th&gt;disk left after rmi&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no &lt;code&gt;docker save&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;docker save&lt;/code&gt; then &lt;code&gt;docker rmi&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;4.5MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I ran the identical build, save, and remove sequence against 29.9.1 in its own data root:&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;docker save repeat-layer-test &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/repeat-layer-test-291.tar
&lt;span class="nv"&gt;$ &lt;/span&gt;docker rmi repeat-layer-test busybox:1.36.1
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /var/lib/docker/image/overlay2/layerdb/sha256 | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
0
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/lib/docker/overlay2
8.0K    /var/lib/docker/overlay2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero entries, 8KB left, which is just empty directory structure. The fix works. The moby PR's own author noted they hadn't rerun the end-to-end reproduction against a patched daemon — as far as I can tell, this is that confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, and what doesn't show it
&lt;/h2&gt;

&lt;p&gt;The part that bothered me more than the leak itself was that nothing surfaces it. On the broken 29.8.2 daemon, after the leak:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker system df -v
Images space usage:

REPOSITORY   TAG   IMAGE ID   CREATED   SIZE   SHARED SIZE   UNIQUE SIZE   CONTAINERS

Containers space usage:
...
Build cache usage: 0B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero images, zero containers, zero build cache. The 4.5MB sitting in &lt;code&gt;overlay2&lt;/code&gt; doesn't appear in any column. I tried both prune commands docker ships:&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;docker system prune &lt;span class="nt"&gt;-af&lt;/span&gt;
&lt;span class="go"&gt;Total reclaimed space: 0B
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker builder prune &lt;span class="nt"&gt;-af&lt;/span&gt;
&lt;span class="go"&gt;Total:  0B
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither sees it, because both work from the same image and layer records that no longer reference the orphaned layers. I restarted the daemon to rule out an in-memory cache masking the problem; the 4.5MB and the three directories were still there afterwards. On an affected engine version, there's no documented command that gets the space back. The only fix is the upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is specific to one storage backend
&lt;/h2&gt;

&lt;p&gt;The release notes say this only affects "the graph-driver image store," which on current Docker means you have to be on an upgraded host still running overlay2 rather than the containerd image store that's been the default for fresh installs since Docker Engine 29.0. I checked this rather than taking it on faith, running the same build-save-remove sequence on 29.8.2 with the containerd image store enabled instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker system df -v
REPOSITORY          TAG       IMAGE ID       SIZE      SHARED SIZE   UNIQUE SIZE
repeat-layer-test    latest    1e9550303c9d   6.77MB    6.757MB       9.87kB
busybox              1.36.1    73aaf090f3d8   6.76MB    6.757MB       3.821kB

$ docker rmi repeat-layer-test busybox:1.36.1
$ docker system df -v
(nothing listed)
$ du -sh /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs
56K
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;56KB of directory overhead, no leak. The claim held up: a fresh Docker install, or any host that's already switched to the containerd store, was never exposed to this regardless of engine version. It's specifically upgraded hosts that stayed on the legacy driver — which, going by Docker's own migration notes about upgraded hosts keeping overlay2 by default, is still a lot of production fleets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak doesn't multiply with more repeats
&lt;/h2&gt;

&lt;p&gt;My next guess was that an image with more repeated layers would leak proportionally more. I built a Dockerfile with the same file copied five times instead of two, saved it, and removed it on the broken engine:&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;docker save repeat5-test &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/repeat5.tar
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /tmp/repeat5.tar
4.5M    /tmp/repeat5.tar
&lt;span class="nv"&gt;$ &lt;/span&gt;docker rmi repeat5-test busybox:1.36.1
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /var/lib/docker/image/overlay2/layerdb/sha256 | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
6
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/lib/docker/overlay2
4.5M    /var/lib/docker/overlay2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tarball was the same 4.5MB as the two-copy version, and the disk usage after the leak was the same 4.5MB too. The layerdb entry count went from 3 to 6, because overlay2 tracks a distinct chain ID for each position in the layer stack even when the underlying content is identical, but the actual layer bytes are stored once and shared across those positions. The leak is bounded to the size of the unique data, not multiplied by how many times it's repeated. That's the one number here that argues against the headline: this bug is real and the fix matters, but it isn't the kind of leak that gets worse the more you push it. A one-off accidental duplicate costs you one layer's worth of space, not five.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;My first attempt to run 29.8.2 with the classic graph driver just added &lt;code&gt;--storage-driver overlay2&lt;/code&gt; to the &lt;code&gt;dockerd&lt;/code&gt; command line, which failed immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failed to start daemon: configured driver "overlay2" not available: unavailable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd assumed the flag alone would turn off the containerd image store. It doesn't — Docker 29 defaults to containerd snapshotters regardless of &lt;code&gt;--storage-driver&lt;/code&gt;, and I had to explicitly set &lt;code&gt;"features": {"containerd-snapshotter": false}&lt;/code&gt; in a &lt;code&gt;daemon.json&lt;/code&gt; passed via &lt;code&gt;--config-file&lt;/code&gt; before the classic driver would start at all. Small thing, but it meant my first "reproduction" of the bug was actually running against the containerd store the whole time, and obviously showed no leak.&lt;/p&gt;

&lt;p&gt;I also expected three &lt;code&gt;RUN true&lt;/code&gt; instructions to produce three identical empty layers and hit the same bug through a more ordinary-looking Dockerfile. They didn't — the legacy builder recognised the empty diffs and didn't create layers for them at all, so &lt;code&gt;docker inspect&lt;/code&gt; showed only the base image's single layer. That path doesn't reproduce this bug; you need an actual non-empty duplicate, such as a repeated &lt;code&gt;COPY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This needs two Docker Engine builds. Static binaries are at &lt;code&gt;download.docker.com&lt;/code&gt;; no package manager install is required.&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;# get both engine versions&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/docker-282 /opt/docker-291
curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/d282.tgz https://download.docker.com/linux/static/stable/x86_64/docker-29.8.2.tgz
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-xzf&lt;/span&gt; /tmp/d282.tgz &lt;span class="nt"&gt;-C&lt;/span&gt; /opt/docker-282 &lt;span class="nt"&gt;--strip-components&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# force the classic graph driver (containerd snapshotter is the default otherwise)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"features": {"containerd-snapshotter": false}}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/daemon.json
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /data/docker-282
/opt/docker-282/dockerd &lt;span class="nt"&gt;--data-root&lt;/span&gt; /data/docker-282 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--host&lt;/span&gt; unix:///var/run/docker.sock &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--config-file&lt;/span&gt; /tmp/daemon.json &lt;span class="nt"&gt;--storage-driver&lt;/span&gt; overlay2 &amp;amp;

&lt;span class="c"&gt;# build an image with a genuinely repeated layer&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; /tmp/ctx &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/ctx
&lt;span class="nb"&gt;echo &lt;/span&gt;hello &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; f
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM busybox:1.36.1\nCOPY f /f\nCOPY f /f\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; Dockerfile
&lt;span class="nv"&gt;DOCKER_BUILDKIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 /opt/docker-282/docker build &lt;span class="nt"&gt;-t&lt;/span&gt; repeat-test &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# save, then remove, then check what's left&lt;/span&gt;
/opt/docker-282/docker save repeat-test &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/repeat.tar
/opt/docker-282/docker rmi repeat-test busybox:1.36.1
&lt;span class="nb"&gt;ls&lt;/span&gt; /data/docker-282/image/overlay2/layerdb/sha256 | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;   &lt;span class="c"&gt;# 3 on 29.8.2, 0 on 29.9.1&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /data/docker-282/overlay2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;/opt/docker-282&lt;/code&gt; for a 29.9.1 build with a separate &lt;code&gt;--data-root&lt;/code&gt; to see the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;docker info | grep "Storage Driver"&lt;/code&gt; on whatever host actually runs your &lt;code&gt;docker save&lt;/code&gt; step. Mine said &lt;code&gt;overlayfs&lt;/code&gt; with &lt;code&gt;driver-type: io.containerd.snapshotter.v1&lt;/code&gt; once I enabled the newer store, and plain &lt;code&gt;overlay2&lt;/code&gt; on the legacy one — that one word is the whole difference between leaking 4.5MB per affected image and leaking nothing.&lt;/p&gt;

&lt;p&gt;The hosts most likely to still show &lt;code&gt;overlay2&lt;/code&gt; are the ones nobody has touched the storage config on since before Docker Engine 29.0, because that's what upgrading in place leaves you with. If that's what you find, and a backup or image-migration script on that box calls &lt;code&gt;docker save&lt;/code&gt;, the 4.5MB I measured here won't show up in &lt;code&gt;docker system df&lt;/code&gt;, won't show up in &lt;code&gt;docker system prune -af&lt;/code&gt;, and won't go away on its own. I only got rid of mine by installing 29.9.1. There isn't a second option, and I looked.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>containers</category>
      <category>performance</category>
    </item>
    <item>
      <title>Redis 8.4's digest-based SET option cuts a 1MB compare-and-set from 4.3ms to 1.6ms</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Mon, 19 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/redis-84s-digest-based-set-option-cuts-a-1mb-compare-and-set-from-43ms-to-16ms-1f8f</link>
      <guid>https://dev.to/alexgeorgiev17/redis-84s-digest-based-set-option-cuts-a-1mb-compare-and-set-from-43ms-to-16ms-1f8f</guid>
      <description>&lt;p&gt;I keep a cached object in Redis that several workers read and occasionally rewrite. Before writing, each worker has to check that nobody else changed the object since it was last read, or the write clobbers someone else's update. Redis never had one command for that. You used &lt;code&gt;WATCH&lt;/code&gt;/&lt;code&gt;MULTI&lt;/code&gt;/&lt;code&gt;EXEC&lt;/code&gt;, or a Lua script that reads and compares server-side, or you accepted the race and moved on.&lt;/p&gt;

&lt;p&gt;Redis 8.4 adds four conditions to &lt;code&gt;SET&lt;/code&gt;: &lt;code&gt;IFEQ&lt;/code&gt;, &lt;code&gt;IFNE&lt;/code&gt;, &lt;code&gt;IFDEQ&lt;/code&gt; and &lt;code&gt;IFDNE&lt;/code&gt;. The first two compare against a literal value. The last two compare against a digest, a 16-character hex hash the server computes with &lt;code&gt;DIGEST&lt;/code&gt;. I pulled &lt;code&gt;redis:8.4&lt;/code&gt; and ran all four against the patterns they replace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline number
&lt;/h2&gt;

&lt;p&gt;The point of a digest is that you don't send the value you're comparing against, you send its hash. For a small string that saves nothing. For a large one it matters.&lt;/p&gt;

&lt;p&gt;I set a 1MB string, kept a cached digest of it client-side, and timed three ways of doing a conditional overwrite 50 times each, taking the median:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;th&gt;classic GET-then-SET&lt;/th&gt;
&lt;th&gt;Lua script CAS&lt;/th&gt;
&lt;th&gt;&lt;code&gt;SET ... IFDEQ&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 B&lt;/td&gt;
&lt;td&gt;0.29 ms&lt;/td&gt;
&lt;td&gt;0.17 ms&lt;/td&gt;
&lt;td&gt;0.21 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 KB&lt;/td&gt;
&lt;td&gt;0.32 ms&lt;/td&gt;
&lt;td&gt;0.25 ms&lt;/td&gt;
&lt;td&gt;0.17 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 KB&lt;/td&gt;
&lt;td&gt;0.55 ms&lt;/td&gt;
&lt;td&gt;1.10 ms&lt;/td&gt;
&lt;td&gt;0.48 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 MB&lt;/td&gt;
&lt;td&gt;4.44 ms&lt;/td&gt;
&lt;td&gt;7.37 ms&lt;/td&gt;
&lt;td&gt;1.77 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are medians across three separate runs of 50 trials each at every size, against a &lt;code&gt;redis:8.4&lt;/code&gt; container on localhost. At 1MB, the digest-based set was roughly a third of the classic GET-then-SET and under a quarter of the Lua script, while the data crossing the wire for the comparison itself dropped from 1,000,000 bytes to 16.&lt;/p&gt;

&lt;p&gt;The classic approach does a &lt;code&gt;GET&lt;/code&gt; (full value back to the client) followed by a conditional &lt;code&gt;SET&lt;/code&gt; (full value back to the server). The Lua script sends the full old value as an argument so the server can compare it internally. The digest option sends neither; the client already has the digest from the last time it fetched the object, and this &lt;code&gt;SET&lt;/code&gt; just needs to check it still matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that argues against it
&lt;/h2&gt;

&lt;p&gt;At 100 bytes to 10KB, none of the three were reliably faster than the others. The differences at that end were inside normal jitter between runs — sometimes the digest set won, sometimes the classic one did, by fractions of a millisecond either way. The benefit only shows up once the payload is large enough that transfer time, not command overhead, dominates the round trip.&lt;/p&gt;

&lt;p&gt;More surprising: the Lua script was the slowest option at every single size I tested, including 100 bytes. I expected it to win, since it does the comparison server-side in one round trip, the same as &lt;code&gt;IFEQ&lt;/code&gt; does natively. It didn't. &lt;code&gt;EVAL&lt;/code&gt; carries script-lookup overhead that a plain &lt;code&gt;SET&lt;/code&gt; with a condition flag doesn't, and that overhead outweighed the round-trip savings until the value got large, and even then it never caught up to the native digest option.&lt;/p&gt;

&lt;p&gt;Separately, &lt;code&gt;DIGEST&lt;/code&gt; itself isn't free. Its own cost scales with the value, because it has to read every byte to hash it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;value size&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;DIGEST&lt;/code&gt; latency (median)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 B&lt;/td&gt;
&lt;td&gt;0.17 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 KB&lt;/td&gt;
&lt;td&gt;0.15 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 MB&lt;/td&gt;
&lt;td&gt;1.03 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 MB&lt;/td&gt;
&lt;td&gt;6.20 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the win from &lt;code&gt;IFDEQ&lt;/code&gt; is specifically a network-bytes win, not a CPU-avoidance win. If you have to call &lt;code&gt;DIGEST&lt;/code&gt; fresh every time rather than reusing a digest you already hold from an earlier read, you've moved the cost from the wire to the server's CPU, not eliminated it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it refuses
&lt;/h2&gt;

&lt;p&gt;A handful of error cases, with the exact messages:&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;127.0.0.1:6379&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;DEL mylist
&lt;span class="gp"&gt;127.0.0.1:6379&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;RPUSH mylist a b c
&lt;span class="gp"&gt;127.0.0.1:6379&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SET mylist v IFEQ a
&lt;span class="go"&gt;(error) WRONGTYPE Operation against a key holding the wrong kind of value
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1:6379&amp;gt; SET anotherkey v NX IFEQ x
(error) ERR syntax error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NX&lt;/code&gt; and &lt;code&gt;IFEQ&lt;/code&gt; are mutually exclusive, which makes sense once you think about it: &lt;code&gt;NX&lt;/code&gt; means "only if the key doesn't exist", &lt;code&gt;IFEQ&lt;/code&gt; means "only if it holds a specific value", and a key can't satisfy both at once except trivially.&lt;/p&gt;

&lt;p&gt;A more useful refusal: &lt;code&gt;IFEQ&lt;/code&gt; and &lt;code&gt;IFDEQ&lt;/code&gt; never succeed against a key that doesn't exist, even though you might reasonably expect "the value matches what I think it should be" to include "there is no value yet". I deleted a key, then ran &lt;code&gt;SET nosuch v IFEQ anything&lt;/code&gt; against it, and got a plain &lt;code&gt;(nil)&lt;/code&gt; — no error, just a silent no-op, same as a normal failed condition. If your code checks &lt;code&gt;IFEQ&lt;/code&gt; on a key that might legitimately not exist yet, you need &lt;code&gt;NX&lt;/code&gt; as well, or a separate existence check; &lt;code&gt;IFEQ&lt;/code&gt; alone won't create it.&lt;/p&gt;

&lt;p&gt;And a digest with the wrong shape is a hard error, not a silent failure:&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;127.0.0.1:6379&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SET cs1 c IFDEQ zz
&lt;span class="go"&gt;(error) ERR must be exactly 16 hexadecimal characters
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Under concurrency
&lt;/h2&gt;

&lt;p&gt;The whole point of these flags is optimistic concurrency control, so I tested it directly. Twenty threads, each doing fifty read-increment-write cycles against the same ~950-byte JSON blob with a counter field inside it. Expected final count: 1,000.&lt;/p&gt;

&lt;p&gt;Plain &lt;code&gt;GET&lt;/code&gt; then unconditional &lt;code&gt;SET&lt;/code&gt;, no retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;naive GET+SET   trial 1: final=109  lost_updates=891
naive GET+SET   trial 2: final=105  lost_updates=895
naive GET+SET   trial 3: final=105  lost_updates=895
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly 89 to 90 percent of increments vanished every single run. That's not a surprising result in itself — it's the textbook lost-update problem — but the size of it, with twenty threads on commodity hardware, was bigger than I expected going in.&lt;/p&gt;

&lt;p&gt;Same workload, &lt;code&gt;SET ... IFEQ &amp;lt;old-blob&amp;gt;&lt;/code&gt; in a retry loop instead of an unconditional &lt;code&gt;SET&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;SET...IFEQ loop trial 1: final=1000  lost_updates=0  total_retries=4892
SET...IFEQ loop trial 2: final=1000  lost_updates=0  total_retries=5111
SET...IFEQ loop trial 3: final=1000  lost_updates=0  total_retries=5111
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero lost updates every run, which backs up Redis's own description of these flags as enabling "optimistic concurrency control patterns". But correctness wasn't free: getting 1,000 successful writes took on the order of 5,000 extra attempts, roughly five tries per success, because twenty threads hammering the same key collide constantly. &lt;code&gt;IFEQ&lt;/code&gt; fixes correctness. It doesn't fix contention, and under heavy contention on one hot key, you pay for that in retries, not in lost data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;I tried the new option through &lt;code&gt;redis-py&lt;/code&gt;'s low-level &lt;code&gt;execute_command&lt;/code&gt; escape hatch first, the same way I'd call any command the client doesn't have a typed wrapper for yet:&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SET&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;gtest4&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;attempted-new&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;IFEQ&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wrong-guess&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# -&amp;gt; False
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I read that &lt;code&gt;False&lt;/code&gt; as "the server doesn't return the old value when the condition fails, only when it succeeds", and nearly wrote that up as a limitation. It isn't true. I opened a raw socket and sent the same command by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -c "..."
failed-condition with GET, raw reply: b'$8\r\noriginal\r\n'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server was sending the old value back correctly the whole time. &lt;code&gt;redis-py&lt;/code&gt;'s &lt;code&gt;execute_command&lt;/code&gt; path has a response-type table for known commands, and it doesn't yet know that &lt;code&gt;SET&lt;/code&gt; with &lt;code&gt;IFEQ&lt;/code&gt; and &lt;code&gt;GET&lt;/code&gt; together returns a bulk string on a failed condition, so it falls back to treating the reply as a plain boolean and throws the actual value away. &lt;code&gt;redis-py&lt;/code&gt; 8.1.0 does have a proper typed wrapper for this — &lt;code&gt;r.set(key, value, ifeq=..., get=True)&lt;/code&gt; — and that one returns the real old value correctly. It's marked experimental and prints a &lt;code&gt;UserWarning&lt;/code&gt; on every call, which is a fair trade for a command three months old. The bug is specifically in the generic low-level path, which gives no warning at all and just quietly hands you the wrong answer. If you're reaching for &lt;code&gt;execute_command&lt;/code&gt; because your client doesn't have the new kwargs yet, check the actual reply type before you trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it in production
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;INFO commandstats&lt;/code&gt; tracks these like any other command, including failed conditional writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ redis-cli INFO commandstats | grep -E "cmdstat_(set|digest)"
cmdstat_set:calls=3,usec=38,usec_per_call=12.67,rejected_calls=0,failed_calls=1
cmdstat_digest:calls=1,usec=13,usec_per_call=13.00,rejected_calls=0,failed_calls=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;failed_calls&lt;/code&gt; on &lt;code&gt;set&lt;/code&gt; rises every time an &lt;code&gt;IFEQ&lt;/code&gt;/&lt;code&gt;IFNE&lt;/code&gt;/&lt;code&gt;IFDEQ&lt;/code&gt;/&lt;code&gt;IFDNE&lt;/code&gt; condition doesn't match. That's a cheap way to watch contention on a hot key in production without instrumenting your own retry loop: if that counter is climbing fast relative to successful &lt;code&gt;set&lt;/code&gt; calls, you have the same problem my twenty threads had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; redistest &lt;span class="nt"&gt;-p&lt;/span&gt; 16379:6379 redis:8.4
pip &lt;span class="nb"&gt;install &lt;/span&gt;redis

python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
import redis
r = redis.Redis(host='localhost', port=16379, decode_responses=True)
r.set('k', 'v1')
print(r.set('k', 'v2', ifeq='v1'))      # True, condition matched
print(r.get('k'))                        # v2
digest = r.execute_command('DIGEST', 'k')
print(r.set('k', 'v3', ifdeq=digest))    # True, digest matched
print(r.get('k'))                        # v3
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bandwidth/latency comparison and the twenty-worker concurrency test are both plain Python scripts against that same container; the core of the concurrency test is this loop, run on twenty threads at once:&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;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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="nf"&gt;parse_counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SET&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;make_blob&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;IFEQ&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blob&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're already doing optimistic locking against Redis with &lt;code&gt;WATCH&lt;/code&gt;/&lt;code&gt;MULTI&lt;/code&gt;/&lt;code&gt;EXEC&lt;/code&gt; or a Lua script, &lt;code&gt;IFEQ&lt;/code&gt; replaces the common case in one round trip and one command. If the values you're comparing are large and you already keep a digest of the last version you read, reach for &lt;code&gt;IFDEQ&lt;/code&gt; instead of &lt;code&gt;IFEQ&lt;/code&gt; and skip sending the payload twice. Check whichever client library you use against its raw reply before trusting the boolean it hands back on a brand-new command — the server was right in my case; the convenience wrapper around it was not.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>database</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>SQLite 3.54's diskused command needs a build flag sqlite3_analyzer never required</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Sun, 18 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/sqlite-354s-diskused-command-needs-a-build-flag-sqlite3analyzer-never-required-4na8</link>
      <guid>https://dev.to/alexgeorgiev17/sqlite-354s-diskused-command-needs-a-build-flag-sqlite3analyzer-never-required-4na8</guid>
      <description>&lt;p&gt;SQLite 3.54.0 shipped on 9 October, and the change list has one line that's easy to skim past: the &lt;code&gt;sqlite3_analyzer&lt;/code&gt; utility is now deprecated, replaced by a &lt;code&gt;diskused()&lt;/code&gt; SQL function built straight into the command-line shell as a new &lt;code&gt;.diskused&lt;/code&gt; dot-command. I wanted to know what that actually buys you, and what it costs, so I built both versions from source and ran them against the same database.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sqlite3_analyzer&lt;/code&gt; has been the way to answer "which table is eating my disk space" for as long as I can remember using SQLite seriously. It's a separate program you download or build on top of the library, and it opens your database file cold, walks every b-tree page, and prints a report. &lt;code&gt;.diskused&lt;/code&gt; does the identical analysis, but it runs inside the shell you already have open, using a virtual table (&lt;code&gt;dbstat&lt;/code&gt;) the engine already exposes internally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same report, less than half the time
&lt;/h2&gt;

&lt;p&gt;I built a 20MB test database — four tables, one index, 350,000 rows — and ran both tools against copies of the same file, five times each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== .diskused (new, 3.54) ===
0.028s  0.023s  0.022s  0.024s  0.029s

=== sqlite3_analyzer (old, deprecated) ===
0.056s  0.061s  0.058s  0.055s  0.076s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;fastest&lt;/th&gt;
&lt;th&gt;mean of 5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.diskused&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.022s&lt;/td&gt;
&lt;td&gt;0.025s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sqlite3_analyzer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.055s&lt;/td&gt;
&lt;td&gt;0.061s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's roughly 2.4 times faster on the mean, and the gap holds at the fastest runs too. Part of that is architectural rather than algorithmic: &lt;code&gt;sqlite3_analyzer&lt;/code&gt; is a 3.3MB standalone binary that has to start its own process and open its own connection to the file from scratch, where &lt;code&gt;.diskused&lt;/code&gt; runs a SQL function against a connection the shell already has open.&lt;/p&gt;

&lt;p&gt;The two reports agree on every number I checked — page counts per table, bytes of payload, unused bytes per page, b-tree depth — which is exactly what you'd want from a replacement. But &lt;code&gt;.diskused&lt;/code&gt;'s output is 242 lines and 13KB; &lt;code&gt;sqlite3_analyzer&lt;/code&gt;'s is 432 lines and 21KB for the same database. The new tool drops some of the older report's redundant subsections (separate "all indexes" and "all tables" rollups that &lt;code&gt;.diskused&lt;/code&gt; folds into fewer blocks) without losing any of the figures I actually used.&lt;/p&gt;

&lt;h2&gt;
  
  
  It fails, and tells you nothing about it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;diskused()&lt;/code&gt; depends on &lt;code&gt;dbstat&lt;/code&gt;, a virtual table that SQLite only exposes when it's compiled with &lt;code&gt;SQLITE_ENABLE_DBSTAT_VTAB&lt;/code&gt;. The official binaries on sqlite.org have that flag set, so if you download the prebuilt shell, none of this applies to you. But a lot of people don't use the prebuilt shell — they drop &lt;code&gt;sqlite3.c&lt;/code&gt; into their own project and compile it with their own flags, which is the whole point of the amalgamation. I did exactly that, with no extra flags beyond &lt;code&gt;-O2&lt;/code&gt;, and ran the new command against my test database:&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;./sqlite3_nodbstat test.db &lt;span class="s2"&gt;".diskused"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output. No error. Just a clean exit. My first assumption was that I'd broken something in my own minimal build — I went back and double-checked the compile line, assuming I'd missed a header. I hadn't. The command had run, found nothing it could use, and said nothing about it.&lt;/p&gt;

&lt;p&gt;Calling the underlying SQL function directly, instead of the dot-command wrapper, finally showed the real cause:&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;./sqlite3_nodbstat test.db &lt;span class="s2"&gt;"SELECT diskused('main');"&lt;/span&gt;
Error &lt;span class="k"&gt;in &lt;/span&gt;2nd &lt;span class="nb"&gt;command &lt;/span&gt;line argument: SQL parse error: no such table: dbstat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dbstat&lt;/code&gt; isn't there, because I didn't ask for it at compile time. For comparison, running &lt;code&gt;.diskused&lt;/code&gt; against 3.53.2, the version before this command existed, gives you an actual error:&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;./sqlite3-3.53.2 test.db &lt;span class="s2"&gt;".diskused"&lt;/span&gt;
Error: unknown &lt;span class="nb"&gt;command &lt;/span&gt;or invalid arguments:  &lt;span class="s2"&gt;"diskused"&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; Enter &lt;span class="s2"&gt;".help"&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So an unsupported command on an old version tells you plainly that it doesn't exist. A supported command on a new version, missing one dependency, tells you nothing at all and exits zero. If you build your own shell from the amalgamation and rely on &lt;code&gt;.diskused&lt;/code&gt; in a script to catch a bloated table before it causes problems, that script will quietly do nothing on a build missing one flag, and report success while doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the 4% claim, and why wall-clock time couldn't
&lt;/h2&gt;

&lt;p&gt;The release notes claim 3.54 is "about 4% faster" than 3.53 and "about 7.5% faster" than 3.51, measured as speedtest1 CPU-cycle counts under valgrind. I tried to check that first with ordinary wall-clock timing: a small C program against each version's &lt;code&gt;sqlite3.c&lt;/code&gt;, inserting 300,000 rows, building an index, then running 100,000 indexed point lookups, three runs per version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3.53.2: total 0.554s, 0.586s, 0.602s
3.54.0: total 0.563s, 0.600s, 0.635s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not 4% faster. If anything it points the other way. Before writing that down as a real finding, I distrusted the harness rather than the claim: a 4% difference on a workload with real disk I/O, page cache behaviour, and OS scheduling noise is well within the run-to-run jitter I was already seeing between repeats of the &lt;em&gt;same&lt;/em&gt; binary. Wall-clock timing on a live filesystem was never going to resolve a few percent.&lt;/p&gt;

&lt;p&gt;So I ran the identical workload under &lt;code&gt;valgrind --tool=callgrind&lt;/code&gt;, which counts CPU instructions retired rather than elapsed time — the same kind of measurement the release notes describe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3.53.2: 2,557,436,360 instructions (confirmed on a second run: 2,557,436,359)
3.54.0: 2,504,239,038 instructions (confirmed on a second run: 2,504,239,042)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a reduction of about 2.1%, consistent across repeats. The direction matches the documented claim; the size doesn't. My workload is dominated by writes and an index build, not the query-heavy mix speedtest1 actually runs, and the release notes say as much themselves ("results may vary depending on workload"). I can confirm 3.54 does less CPU work on a real workload. I can't confirm it does 4% less, because that number was never about my workload to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under concurrency, nothing changed
&lt;/h2&gt;

&lt;p&gt;I ran &lt;code&gt;.diskused&lt;/code&gt; against a database with an open write transaction in another connection, once in WAL mode and once in the default rollback-journal mode, expecting the reader to either block or get &lt;code&gt;SQLITE_BUSY&lt;/code&gt; in the second case. Neither happened. &lt;code&gt;.diskused&lt;/code&gt; only needs a &lt;code&gt;SHARED&lt;/code&gt; lock, and &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt; on the writer only takes a &lt;code&gt;RESERVED&lt;/code&gt; lock, which is compatible with any number of concurrent readers under SQLite's normal locking rules. This isn't a new behaviour &lt;code&gt;diskused()&lt;/code&gt; introduced; it's SQLite doing what SQLite has always done. Worth checking, nothing to report.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;I spent longer than I'd like to admit convinced that the silent, empty output from my no-&lt;code&gt;dbstat&lt;/code&gt; build was a bug in how I'd compiled the shell, not a property of the tool. The fact that it exited &lt;code&gt;0&lt;/code&gt; was the tell I ignored for too long — a crash doesn't give you a clean exit code. Once I stopped assuming my build was broken and started asking the SQL function directly what it thought was wrong, the actual cause (&lt;code&gt;no such table: dbstat&lt;/code&gt;) came back in one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This needs a C compiler and the usual build tools. It downloads and builds two SQLite versions from source, so expect it to take a few minutes.&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;mkdir &lt;/span&gt;sqlite-diskused-test &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;sqlite-diskused-test

curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; 354.tar.gz https://sqlite.org/2026/sqlite-autoconf-3540000.tar.gz
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf 354.tar.gz &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;sqlite-autoconf-3540000
./configure &lt;span class="nv"&gt;CFLAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-O2 -DSQLITE_ENABLE_DBSTAT_VTAB=1"&lt;/span&gt;
make &lt;span class="nt"&gt;-j4&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ..

&lt;span class="c"&gt;# a build with no extra flags at all, to see the silent failure&lt;/span&gt;
cc &lt;span class="nt"&gt;-O2&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; sqlite3_nodbstat sqlite-autoconf-3540000/shell.c sqlite-autoconf-3540000/sqlite3.c &lt;span class="nt"&gt;-lpthread&lt;/span&gt; &lt;span class="nt"&gt;-ldl&lt;/span&gt; &lt;span class="nt"&gt;-lm&lt;/span&gt;

./sqlite-autoconf-3540000/sqlite3 test.db &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
CREATE TABLE t(id INTEGER PRIMARY KEY, a INTEGER, b TEXT);
WITH RECURSIVE seq(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM seq WHERE x&amp;lt;300000)
INSERT INTO t(a,b) SELECT x%997, 'padding' FROM seq;
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;./sqlite-autoconf-3540000/sqlite3 test.db &lt;span class="s2"&gt;".diskused"&lt;/span&gt;      &lt;span class="c"&gt;# full report&lt;/span&gt;
./sqlite3_nodbstat test.db &lt;span class="s2"&gt;".diskused"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exit: &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    &lt;span class="c"&gt;# silent, exit 0&lt;/span&gt;
./sqlite3_nodbstat test.db &lt;span class="s2"&gt;"SELECT diskused('main');"&lt;/span&gt;      &lt;span class="c"&gt;# the real error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do with this
&lt;/h2&gt;

&lt;p&gt;If you ship your own SQLite build compiled from the amalgamation, check now whether &lt;code&gt;SQLITE_ENABLE_DBSTAT_VTAB&lt;/code&gt; is in your flags, rather than finding out the day a disk-space script reports "nothing to analyse" on a database that's actually full of bloat. If you're still shelling out to a separate &lt;code&gt;sqlite3_analyzer&lt;/code&gt; binary in a build pipeline or cron job, &lt;code&gt;.diskused&lt;/code&gt; on 3.54 does the same job from inside a shell you already have running, in under half the time, and you can drop the extra binary from whatever you're currently downloading to get it.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>database</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Docker Compose 5.6's jobs type only starts through docker compose run</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Sat, 17 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/docker-compose-56s-jobs-type-only-starts-through-docker-compose-run-4c1a</link>
      <guid>https://dev.to/alexgeorgiev17/docker-compose-56s-jobs-type-only-starts-through-docker-compose-run-4c1a</guid>
      <description>&lt;p&gt;Docker Compose 5.6.0 shipped on 2 October with a new top-level &lt;code&gt;jobs:&lt;/code&gt; section, sitting next to &lt;code&gt;services:&lt;/code&gt; in the compose file. I wanted to know what actually happens when you declare one, because the release notes say almost nothing beyond "partial support for jobs: only manually-triggered jobs are supported for now."&lt;/p&gt;

&lt;p&gt;The first thing that happened was my compose file got rejected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker compose config
validating /home/user/compose-jobs-test/compose.yaml: jobs.migrate missing property 'triggers'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;jobs:&lt;/code&gt; entry is not just a service with a different label. It needs its own &lt;code&gt;triggers:&lt;/code&gt; block, and compose will not even parse the file without one. Once I added &lt;code&gt;triggers: {manual: true}&lt;/code&gt;, it validated and I could start testing what a job actually does differently from a service.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a job refuses to do
&lt;/h2&gt;

&lt;p&gt;I wrote a small compose file: a long-running &lt;code&gt;db&lt;/code&gt; service, and a &lt;code&gt;migrate&lt;/code&gt; job that depends on it, sleeps a second and exits 0.&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;db&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;alpine:3.20&lt;/span&gt;
    &lt;span class="na"&gt;command&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;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;db-up;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;600"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;migrate&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;alpine:3.20&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
    &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;manual&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;command&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;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migrating;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;done;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker compose up -d&lt;/code&gt; started &lt;code&gt;db&lt;/code&gt; and said this about the job, then moved on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;time="...Z" level=warning msg="jobs are not started by up; trigger them with `docker compose run`: migrate"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 0. &lt;code&gt;docker compose ps -a&lt;/code&gt; afterwards shows only &lt;code&gt;db&lt;/code&gt;. The job is simply absent, not failed, not pending, not listed anywhere &lt;code&gt;up&lt;/code&gt; reports on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;create&lt;/code&gt; and &lt;code&gt;start&lt;/code&gt; are less forgiving. Both refuse outright:&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;docker compose create migrate
job &lt;span class="s2"&gt;"migrate"&lt;/span&gt; can only be triggered with &lt;span class="s2"&gt;"docker compose run"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same message, same exit code, for &lt;code&gt;start&lt;/code&gt;. So of the five commands that normally bring a service into existence, four of them (&lt;code&gt;up&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;, and as far as I could find no fifth alternative) will not touch a job at all. Only &lt;code&gt;run&lt;/code&gt; works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opposite reading: declaring the documented future breaks the present
&lt;/h2&gt;

&lt;p&gt;The release notes are honest that scheduled jobs aren't supported yet, only manual ones. I wanted to see what "not supported yet" meant in practice, so I added a second job with a &lt;code&gt;schedule&lt;/code&gt; trigger instead of &lt;code&gt;manual&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nightly&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;alpine:3.20&lt;/span&gt;
    &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;schedule&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;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;command&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;sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;nightly-run"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the asymmetry shows up. A manual-only job that &lt;code&gt;up&lt;/code&gt; can't start gets a warning and is skipped; the rest of the stack comes up fine. A job with a schedule trigger makes &lt;code&gt;up&lt;/code&gt; refuse to run at all:&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;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
scheduled &lt;span class="nb"&gt;jobs &lt;/span&gt;are not supported &lt;span class="k"&gt;in &lt;/span&gt;this version: nightly
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a warning this time. The whole &lt;code&gt;up&lt;/code&gt; fails, and nothing starts, not even the unrelated &lt;code&gt;db&lt;/code&gt; service sitting in the same file. If you're migrating an existing stack to use jobs and you reach for the field that matches what you actually want (a job that runs on a schedule), you get a worse outcome than if you'd done nothing: the entire compose file stops coming up until you remove that trigger. Manual-only jobs are the only kind that currently coexist peacefully with the rest of your stack.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose run --rm nightly&lt;/code&gt; still runs the scheduled job by hand without complaint, which matches what the Compose team's own notes say about manual execution being "always available... unless a job sets triggers.manual: false." I checked that half of the claim directly too: a job declared with &lt;code&gt;triggers: {manual: false}&lt;/code&gt; is refused by &lt;code&gt;run&lt;/code&gt; as well:&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;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; migrate_locked
job &lt;span class="s2"&gt;"migrate_locked"&lt;/span&gt; is declared with manual: &lt;span class="nb"&gt;false&lt;/span&gt;, it cannot be run manually
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since nothing can trigger it on a schedule yet either, a job with &lt;code&gt;manual: false&lt;/code&gt; in this release is not deferred to a scheduler. It's unreachable. There is currently no path in Compose 5.6.0 that starts it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a job costs you to run
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker compose run&lt;/code&gt; on a job starts its declared dependencies first, same as it does for a one-off command on a service. I measured how much that costs by timing &lt;code&gt;docker compose run --rm migrate&lt;/code&gt; three times with &lt;code&gt;db&lt;/code&gt; already running, and three times after &lt;code&gt;docker compose down -v&lt;/code&gt; so &lt;code&gt;db&lt;/code&gt; had to be created and started from scratch each time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Cold (db stopped first)&lt;/th&gt;
&lt;th&gt;Warm (db already up)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1.716s&lt;/td&gt;
&lt;td&gt;1.512s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1.718s&lt;/td&gt;
&lt;td&gt;1.467s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1.717s&lt;/td&gt;
&lt;td&gt;1.481s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean&lt;/td&gt;
&lt;td&gt;1.717s&lt;/td&gt;
&lt;td&gt;1.487s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The job's own work (a one-second sleep plus container overhead) is identical in both cases, so the roughly 230ms gap, about 15%, is what it costs to create and start the dependency container before the job can begin. That's a small number on a single Alpine container; it's the number to budget for if your job's real dependency is a database image with its own startup routine, multiplied by however many &lt;code&gt;depends_on&lt;/code&gt; entries the job declares.&lt;/p&gt;

&lt;p&gt;Exit codes pass through exactly. A job that ends with &lt;code&gt;exit 17&lt;/code&gt; hands that 17 straight back to the shell that ran &lt;code&gt;docker compose run&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="nv"&gt;$ &lt;/span&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; migrate_fail
failing
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
17
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matters if you're wiring a job into a CI step and checking &lt;code&gt;$?&lt;/code&gt; afterwards, which is presumably the point of a dedicated one-off-task primitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under concurrency
&lt;/h2&gt;

&lt;p&gt;I ran the same job twice at once, both in the background, to see whether two manual triggers would collide:&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;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; migrate &amp;amp; docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; migrate &amp;amp; &lt;span class="nb"&gt;wait&lt;/span&gt;
&lt;span class="go"&gt; Container compose-jobs-test-migrate-run-4fdc2a756110 Creating
 Container compose-jobs-test-migrate-run-f3445db266a6 Creating
migrating 1
migrating 1
done 1
done 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two distinct container names, both ran to completion, both cleaned up by their own &lt;code&gt;--rm&lt;/code&gt;. No lock, no queue, no contention on the shared &lt;code&gt;db&lt;/code&gt; dependency either, since it was already running. Jobs don't serialise against each other; if you need that, you still have to build it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A claim I checked and found true
&lt;/h2&gt;

&lt;p&gt;The one documented behaviour I could directly verify was profile activation: a job's own &lt;code&gt;profiles:&lt;/code&gt; entry is meant to be enabled automatically when you &lt;code&gt;run&lt;/code&gt; it, without needing &lt;code&gt;--profile&lt;/code&gt; on the command line. I gave a job &lt;code&gt;profiles: ["migrations"]&lt;/code&gt;, ran it with no &lt;code&gt;--profile&lt;/code&gt; flag, and it ran:&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;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; migrate
migrated-with-profile
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one held up exactly as documented.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;My first attempt at the schedule trigger was &lt;code&gt;triggers: schedule: "0 2 * * *"&lt;/code&gt;, a bare string, because that's what cron usually looks like everywhere else. Compose rejected it: &lt;code&gt;jobs.nightly.triggers.schedule must be a array&lt;/code&gt;. It wants a list of objects, each with a &lt;code&gt;cron:&lt;/code&gt; key — &lt;code&gt;schedule: [{cron: "0 2 * * *"}]&lt;/code&gt; once compose canonicalises it. I'd assumed the YAML shorthand I wrote would work because it reads naturally; the schema disagreed, and the error message was specific enough that I didn't need to go digging through compose-go source to fix it.&lt;/p&gt;

&lt;p&gt;I also nearly reported "jobs leak containers that &lt;code&gt;down&lt;/code&gt; can't clean up" as a jobs-specific finding, since a &lt;code&gt;migrate&lt;/code&gt; container left over from a &lt;code&gt;run&lt;/code&gt; without &lt;code&gt;--rm&lt;/code&gt; survived both &lt;code&gt;docker compose down&lt;/code&gt; and &lt;code&gt;docker compose rm -f&lt;/code&gt;. Before writing that down as a cost of the new feature, I ran the identical test against an ordinary service behind a &lt;code&gt;profiles: ["tools"]&lt;/code&gt; guard, the pattern people have used for one-off tasks in Compose for years. It leaked exactly the same way. Jobs didn't introduce this; they inherited it. It's worth knowing either way, but it would have been the wrong feature to blame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;This needs Docker Compose 5.6.0 or later (&lt;code&gt;docker compose version&lt;/code&gt; to check) and a working Docker daemon.&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;mkdir &lt;/span&gt;jobs-test &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;jobs-test
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; compose.yaml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
services:
  db:
    image: alpine:3.20
    command: ["sh", "-c", "echo db-up; sleep 600"]

jobs:
  migrate:
    image: alpine:3.20
    depends_on:
      - db
    triggers:
      manual: true
    command: ["sh", "-c", "echo migrating; sleep 1; echo done; exit 0"]
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;          &lt;span class="c"&gt;# starts db, warns and skips migrate&lt;/span&gt;
docker compose create migrate &lt;span class="c"&gt;# refused: "can only be triggered with docker compose run"&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; migrate   &lt;span class="c"&gt;# this is the one that actually works&lt;/span&gt;
docker compose config &lt;span class="nt"&gt;--services&lt;/span&gt;  &lt;span class="c"&gt;# lists db only, migrate is invisible here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a second job with &lt;code&gt;triggers: {schedule: [{cron: "0 2 * * *"}]}&lt;/code&gt; and run &lt;code&gt;docker compose up -d&lt;/code&gt; again to see the whole file refuse to start, rather than just skip the one job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do with this
&lt;/h2&gt;

&lt;p&gt;If you're reaching for &lt;code&gt;jobs:&lt;/code&gt; today, use &lt;code&gt;triggers: {manual: true}&lt;/code&gt; and only that. A schedule trigger doesn't give you a scheduled job yet, it gives you a compose file that &lt;code&gt;up&lt;/code&gt; refuses to run at all, and &lt;code&gt;manual: false&lt;/code&gt; with no scheduler gives you a job nothing can start. Keep using &lt;code&gt;run --rm&lt;/code&gt; rather than bare &lt;code&gt;run&lt;/code&gt;, because the orphaned-container problem is the same one you already had with profile-gated one-off services, and jobs don't fix it. And if any script you have loops over &lt;code&gt;docker compose config --services&lt;/code&gt; to do per-service work, remember it won't see your jobs at all; you'll need to list them separately until Compose grows a command that does.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>containers</category>
      <category>performance</category>
    </item>
    <item>
      <title>PostgreSQL 19 beta's parallel autovacuum launches workers but leaves vacuum time unchanged</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Fri, 16 Oct 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/postgresql-19-betas-parallel-autovacuum-launches-workers-but-leaves-vacuum-time-unchanged-3g4j</link>
      <guid>https://dev.to/alexgeorgiev17/postgresql-19-betas-parallel-autovacuum-launches-workers-but-leaves-vacuum-time-unchanged-3g4j</guid>
      <description>&lt;p&gt;Autovacuum in PostgreSQL has never been able to split the work of cleaning up a table's indexes across more than one process. A manual &lt;code&gt;VACUUM (PARALLEL n)&lt;/code&gt; could do it since PostgreSQL 13, but the background worker that actually runs on your production database at 3am could not. PostgreSQL 19 beta4 changes that, through a new &lt;code&gt;autovacuum_max_parallel_workers&lt;/code&gt; setting and an accompanying scoring system that decides which bloated table gets attention first.&lt;/p&gt;

&lt;p&gt;I built a table designed to make that matter, bloated it the same way three times over, and measured what parallel index vacuuming actually bought me. The answer, on this workload, was close to nothing on the clock that counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 19 beta4, run from the official &lt;code&gt;postgres:19beta4&lt;/code&gt; Docker image. A table with six indexes and 400,000 rows, updated twice so that every indexed column changed on every row, leaving 800,000 dead tuples behind:&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;wide_bloat&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;c1&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c2&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c3&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c4&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c5&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c6&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c7&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autovacuum_enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_wb_c1&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wide_bloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_wb_c2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wide_bloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_wb_c3&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wide_bloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_wb_c4&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wide_bloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_wb_c5&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wide_bloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_wb_c6&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wide_bloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I disabled autovacuum on the table while loading it, bloated it, then flipped &lt;code&gt;autovacuum_enabled&lt;/code&gt; back on and let the real autovacuum worker pick it up, reading the outcome from &lt;code&gt;log_autovacuum_min_duration = 0&lt;/code&gt;. I rebuilt the table fresh for every trial so each run started from the same 800,000 dead tuples.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline number, and why it undersells the result
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;autovacuum_max_parallel_workers = 0&lt;/code&gt; (the default — this feature does nothing unless you turn it on), three runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Wall time&lt;/th&gt;
&lt;th&gt;Leader CPU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1.24s&lt;/td&gt;
&lt;td&gt;0.57s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1.36s&lt;/td&gt;
&lt;td&gt;0.57s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1.32s&lt;/td&gt;
&lt;td&gt;0.55s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With &lt;code&gt;autovacuum_max_parallel_workers = 4&lt;/code&gt;, confirmed in the log by &lt;code&gt;parallel workers: index vacuum: 4 planned, 4 launched in total&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Wall time&lt;/th&gt;
&lt;th&gt;Leader CPU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1.23s&lt;/td&gt;
&lt;td&gt;0.42s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1.38s&lt;/td&gt;
&lt;td&gt;0.40s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1.14s&lt;/td&gt;
&lt;td&gt;0.38s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wall-clock time averaged 1.31s serial versus 1.25s parallel — about 4%, well inside the run-to-run noise I was already seeing. The CPU time billed to the autovacuum worker itself dropped by a real margin, roughly 30%, because the index-scanning work for five of the six indexes got handed to launched workers instead of done by the leader alone.&lt;/p&gt;

&lt;p&gt;That split matters. If you're watching autovacuum's CPU footprint on a busy database, parallel workers genuinely reduce what the worker process itself burns. If you're watching how long a table stays bloated before the dead tuples disappear, this table's bottleneck was the heap scan and the WAL it generated (official instrumentation logged 101,000+ WAL records and 70,000+ dirtied buffers on every run, serial or parallel), not the index cleanup phase that got parallelised. Autovacuum doesn't parallelise the heap scan. On a table where index cleanup actually dominates, I'd expect a different result; on this one it didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The same log line reports dead-tuple (TID) storage. Serial runs used 1.45MB. Parallel runs with 4 workers used 3.00MB — roughly double, for the same 800,000 dead tuples, because each launched worker gets its own share of that bookkeeping. On an 800,000-row vacuum this is nothing. On a multi-terabyte table with &lt;code&gt;autovacuum_work_mem&lt;/code&gt; already stretched thin and several autovacuum workers each spawning their own parallel helpers, that multiplication is the real thing to budget for, not CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting that doesn't do what I assumed
&lt;/h2&gt;

&lt;p&gt;PostgreSQL has had &lt;code&gt;max_parallel_maintenance_workers&lt;/code&gt; since version 11, controlling how many workers a manual &lt;code&gt;VACUUM (PARALLEL)&lt;/code&gt; or &lt;code&gt;CREATE INDEX&lt;/code&gt; can use. I assumed it would also cap autovacuum's new parallel workers. It doesn't.&lt;/p&gt;

&lt;p&gt;I set &lt;code&gt;max_parallel_maintenance_workers = 0&lt;/code&gt; and &lt;code&gt;autovacuum_max_parallel_workers = 4&lt;/code&gt; with &lt;code&gt;max_parallel_workers = 8&lt;/code&gt;, and autovacuum still logged &lt;code&gt;parallel workers: index vacuum: 4 planned, 4 launched in total&lt;/code&gt;. The actual gate, per the PostgreSQL 19 documentation for &lt;code&gt;autovacuum_max_parallel_workers&lt;/code&gt;, is this sentence: "The actual number of parallel workers is further limited by max_parallel_workers." Setting that one to 0 instead produced &lt;code&gt;4 planned, 0 launched&lt;/code&gt;, exactly as documented. &lt;code&gt;max_parallel_maintenance_workers&lt;/code&gt; isn't mentioned in that page at all. If you're budgeting parallel workers across a cluster, the knob to turn is the global pool, not the maintenance-specific one that governs manual commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring system, confirmed
&lt;/h2&gt;

&lt;p&gt;Beta4's release notes mention "fixes for the new autovacuum scoring system" without saying what it does day to day. I built six tables with dead-tuple counts of 100, 500, 5,000, 40,000, 150,000 and 500,000, in that creation order, and enabled autovacuum on all six in the same transaction. A single autovacuum worker picked them up in this order, by timestamp in the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p_massive  (500,000 dead)  05:28:13.989
p_huge     (150,000 dead)  05:28:14.081
p_big      ( 40,000 dead)  05:28:14.122
p_med      (  5,000 dead)  05:28:14.141
p_small    (    500 dead)  05:28:14.147
p_tiny     (    100 dead)  05:28:14.149
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strictly descending by dead-tuple count, the exact reverse of creation order. The scoring system does prioritise the most urgent table first, at least for this one signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under concurrency, two things went wrong at once
&lt;/h2&gt;

&lt;p&gt;I capped &lt;code&gt;max_parallel_workers&lt;/code&gt; at 4 globally, then started a separate session running a deliberately slow parallel query (&lt;code&gt;pg_sleep()&lt;/code&gt; inside a &lt;code&gt;WHERE&lt;/code&gt; clause, three workers, over a 1.5 million row table) to hold three of those four slots. With that session running, I re-bloated &lt;code&gt;wide_bloat&lt;/code&gt; and enabled autovacuum.&lt;/p&gt;

&lt;p&gt;Three consecutive autovacuum attempts over about 40 seconds each logged &lt;code&gt;parallel workers: index cleanup: 4 planned, 0 launched&lt;/code&gt; — no slots free, correctly degrading to zero rather than failing. But the more useful finding was in the same log lines: &lt;code&gt;tuples: 0 removed, 1200000 remain, 800000 are dead but not yet removable&lt;/code&gt;. The long-running session wasn't just holding parallel workers, it was holding a snapshot that kept those 800,000 dead tuples below the removable horizon. Nothing was deleted, parallel or not, for the full 40 seconds that session stayed open.&lt;/p&gt;

&lt;p&gt;The moment I killed that session, the very next cycle logged &lt;code&gt;4 planned, 4 launched&lt;/code&gt; and cleared all 800,000 tuples in about a second. The lesson isn't really about parallel autovacuum: a long-running query anywhere on the database stalls vacuum regardless of how many workers you give it, and parallel autovacuum doesn't change that. It just means the workers you configured can also sit idle at the worst possible time, for the same old reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it happen
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt; in PostgreSQL 19 reports live phase and index progress. Polling it every 300ms during a 1.5 million row autovacuum run on a three-index table caught this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;05:30:13.949 | scanning heap     | heap_blks_scanned=137335/142858 | indexes_processed=0/0
05:30:14.370 | vacuuming indexes | heap_blks_scanned=142858/142858 | indexes_processed=2/4
05:30:14.791 | vacuuming heap    | heap_blks_scanned=142858/142858 | indexes_processed=0/0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;indexes_total&lt;/code&gt; and &lt;code&gt;indexes_processed&lt;/code&gt; are enough to tell whether a slow vacuum is stuck scanning the heap or working through indexes, which is the question I actually want answered at 3am.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;My first run of that test looked like a bug. I set &lt;code&gt;max_parallel_maintenance_workers = 0&lt;/code&gt; with &lt;code&gt;ALTER SYSTEM&lt;/code&gt;, called &lt;code&gt;pg_reload_conf()&lt;/code&gt;, got back &lt;code&gt;applied: t&lt;/code&gt; from &lt;code&gt;pg_file_settings&lt;/code&gt; — and &lt;code&gt;SHOW max_parallel_maintenance_workers&lt;/code&gt; still said 8. I spent ten minutes convinced the setting wasn't taking effect at all, before I remembered why: I'd started that container with &lt;code&gt;-c max_parallel_maintenance_workers=8&lt;/code&gt; on the &lt;code&gt;docker run&lt;/code&gt; line. A value set that way is fixed for as long as the server runs. &lt;code&gt;ALTER SYSTEM&lt;/code&gt; writes to &lt;code&gt;postgresql.auto.conf&lt;/code&gt; happily, the file even says it applied, but nothing reads it back over a command-line override. I had to kill the container and start a fresh one without that flag before the test told me anything real.&lt;/p&gt;

&lt;p&gt;There's a second, smaller one worth naming: my first contention test used a global &lt;code&gt;max_parallel_workers&lt;/code&gt; of 8 against a competing query holding only 3 workers, leaving 5 free for autovacuum's request of 4 — plenty of room, so nothing contended and I nearly wrote up "no effect" as a finding. Dropping the pool to 4 total is what actually forced the two requests to compete, and that's the version that produced something worth reporting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pgq19 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres &lt;span class="nt"&gt;-p&lt;/span&gt; 15432:5432 postgres:19beta4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;log_autovacuum_min_duration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;autovacuum_naptime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;autovacuum_vacuum_threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;autovacuum_vacuum_scale_factor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;autovacuum_vacuum_cost_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;maintenance_work_mem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256MB &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;max_parallel_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;autovacuum_worker_slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;16 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nv"&gt;autovacuum_max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3

docker &lt;span class="nb"&gt;exec &lt;/span&gt;pgq19 psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
CREATE TABLE wide_bloat (id bigint PRIMARY KEY, c1 text, c2 text, c3 text, c4 int, c5 int, c6 timestamp)
  WITH (autovacuum_enabled = false);
CREATE INDEX idx_wb_c1 ON wide_bloat(c1); CREATE INDEX idx_wb_c2 ON wide_bloat(c2);
CREATE INDEX idx_wb_c3 ON wide_bloat(c3); CREATE INDEX idx_wb_c4 ON wide_bloat(c4);
CREATE INDEX idx_wb_c5 ON wide_bloat(c5); CREATE INDEX idx_wb_c6 ON wide_bloat(c6);
INSERT INTO wide_bloat SELECT g, 'v'||(g%5000), 'c'||(g%300), md5(g::text), g%1000, g%97, now()
  FROM generate_series(1,400000) g;
UPDATE wide_bloat SET c1='v2_'||(id%5000), c2='c2_'||(id%300), c3=md5((id+1)::text);
UPDATE wide_bloat SET c1='v3_'||(id%5000), c2='c3_'||(id%300), c3=md5((id+2)::text);
"&lt;/span&gt;

&lt;span class="c"&gt;# serial baseline — ALTER SYSTEM must be its own statement, not chained with ;&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pgq19 psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"ALTER SYSTEM SET autovacuum_max_parallel_workers = 0;"&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pgq19 psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT pg_reload_conf();"&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pgq19 psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"ALTER TABLE wide_bloat SET (autovacuum_enabled = true);"&lt;/span&gt;

&lt;span class="c"&gt;# then rebuild the table and repeat with:&lt;/span&gt;
&lt;span class="c"&gt;# ALTER SYSTEM SET autovacuum_max_parallel_workers = 4;&lt;/span&gt;
&lt;span class="c"&gt;# and read the result from: docker logs pgq19 | grep -A20 'automatic vacuum of table'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do with this
&lt;/h2&gt;

&lt;p&gt;If your autovacuum workers are CPU-starved on wide tables, turning &lt;code&gt;autovacuum_max_parallel_workers&lt;/code&gt; up is a real lever on that specific cost, and it's off by default so nobody gets it by accident on an upgrade. It is not a lever on how long your table stays bloated if the heap scan or the WAL it writes is the actual bottleneck, so measure your own table before assuming it helps — &lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt;'s phase column will tell you which one you've got. And budget the global &lt;code&gt;max_parallel_workers&lt;/code&gt; pool, not &lt;code&gt;max_parallel_maintenance_workers&lt;/code&gt;, because that's the setting that actually gates this, and a long transaction left open anywhere will still starve a bloated table whether or not you've given autovacuum workers to spend.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>RevCheck, compare the cars you want before you buy your next daily driver</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:33:30 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/revcheck-paste-two-used-car-listings-get-a-second-opinion-before-you-drive-across-town-1e7e</link>
      <guid>https://dev.to/alexgeorgiev17/revcheck-paste-two-used-car-listings-get-a-second-opinion-before-you-drive-across-town-1e7e</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Buying a used car in Bulgaria goes like this. You have twelve tabs open on mobile.bg and cars.bg. Half of them say "нов внос" (fresh import), "реални километри" (real mileage) and "без забележки" (nothing to fix). The same car shows up on both sites. Then you lose a Saturday driving to Plovdiv for a car that turned out to have a different engine, a seat that's been through a dog, and a price nobody else is asking.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://georgievalex.github.io/revcheck/" rel="noopener noreferrer"&gt;RevCheck&lt;/a&gt;&lt;/strong&gt;. You paste two listings, from either site, and about 30 seconds later you get a side-by-side report and a verdict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every photo is checked&lt;/strong&gt; by Gemma 4: color, dents, scratches, mismatched panels, and the equipment you can actually see (M Sport bumpers, panoramic roof, leather, navigation). It also flags extras the seller lists but the photos contradict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The market position.&lt;/strong&gt; It finds similar cars for sale on &lt;em&gt;both&lt;/em&gt; sites (same model, body type, fuel and engine power, year ±1) and tells you "priced higher than 91% of 44 similar listings". A car listed on both sites only counts once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fair price range from TabPFN&lt;/strong&gt;, trained on those comparables during the request: "€4,718 – €8,665, so this one is €2,276 above the expected price".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The known weak spots of that exact engine.&lt;/strong&gt; DeepSeek V4 names the likely engine code from year, power and displacement (N47D20C, N52 or N53, and so on) and lists what to check at the viewing: timing chain rattle on a cold start, swirl flaps, the ZF gearbox, the high-pressure fuel pump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain checks with no AI:&lt;/strong&gt; a missing VIN, unusually low or high km per year, too few photos, a US or Canada import (go and run that VIN).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The verdict:&lt;/strong&gt; which car to go and see, why, what to check on each one, and what to ask the seller before you get in the car.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;English and Bulgarian&lt;/strong&gt;, with the flags at the top. The report is written in whichever language you pick.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Who would pay for this?&lt;/strong&gt; Anyone about to spend half a day and a tank of fuel on a viewing. People already pay mechanics and inspectors to look at a car in person before they buy. RevCheck is the step before that: it tells you which of the two is worth the trip. I'd keep a couple of comparisons a day free and charge flippers and small dealers, who compare dozens of cars a week, a small monthly fee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live site:&lt;/strong&gt; &lt;a href="https://georgievalex.github.io/revcheck/" rel="noopener noreferrer"&gt;https://georgievalex.github.io/revcheck/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try it with two BMW 320d estates that are for sale right now:&lt;br&gt;
&lt;a href="https://georgievalex.github.io/revcheck/?a=https://www.mobile.bg/obiava-11744111052659454-bmw-320-2-0d&amp;amp;b=https://www.mobile.bg/obiava-11787314145036432" rel="noopener noreferrer"&gt;https://georgievalex.github.io/revcheck/?a=https://www.mobile.bg/obiava-11744111052659454-bmw-320-2-0d&amp;amp;b=https://www.mobile.bg/obiava-11787314145036432&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Paste two links, tick the box, click Compare:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5pmfl0dlpbb1trpwx1pa.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5pmfl0dlpbb1trpwx1pa.jpg" alt="The RevCheck form with two mobile.bg links" width="799" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The verdict comes back first. Each car card fills in as soon as that car is done:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy5rpmyb1fx9be4j9v37v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy5rpmyb1fx9be4j9v37v.jpg" alt="The verdict: listing B is the better deal, with the reasons and questions for the seller" width="799" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The market position, the TabPFN fair price range and what the photos show:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm93hh99mc8zsde3mg8gv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm93hh99mc8zsde3mg8gv.jpg" alt="Market position, TabPFN fair range and the photo check for both cars" width="799" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the known weak spots for the engine, with what to check at the viewing:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnsvgui0jlxjwujvvw730.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnsvgui0jlxjwujvvw730.jpg" alt="Known issues for the N47 diesel: timing chain, EGR, swirl flaps, dual-mass flywheel" width="799" height="405"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&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/GeorgievAlex" rel="noopener noreferrer"&gt;
        GeorgievAlex
      &lt;/a&gt; / &lt;a href="https://github.com/GeorgievAlex/revcheck" rel="noopener noreferrer"&gt;
        revcheck
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Compare two used-car listings from mobile.bg or cars.bg with open-weight AI (Gemma 4 vision + TabPFN)
    &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;RevCheck&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;The pre-purchase check from the RevSofia family. Paste two used-car listings from &lt;strong&gt;mobile.bg&lt;/strong&gt; or &lt;strong&gt;cars.bg&lt;/strong&gt; and get a side-by-side report and a verdict on which one is the better buy.&lt;/p&gt;
&lt;p&gt;Built from scratch on 15–18 October 2026 for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12" rel="nofollow"&gt;DEV Hacktoberfest Open-Source AI Challenge, Week 2: Side Hustle&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Site:&lt;/strong&gt; &lt;a href="https://georgievalex.github.io/revcheck/" rel="nofollow noopener noreferrer"&gt;https://georgievalex.github.io/revcheck/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Write-up on DEV:&lt;/strong&gt; &lt;a href="https://dev.to/alexgeorgiev17/revcheck-paste-two-used-car-listings-get-a-second-opinion-before-you-drive-across-town-1e7e" rel="nofollow"&gt;https://dev.to/alexgeorgiev17/revcheck-paste-two-used-car-listings-get-a-second-opinion-before-you-drive-across-town-1e7e&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How the listings are read&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;mobile.bg and cars.bg refuse requests from DigitalOcean (a Cloudflare challenge or a 403). From Cloudflare's own network they answer. So the function reads pages through &lt;strong&gt;revcheck-reader&lt;/strong&gt; (&lt;code&gt;cloudflare/reader/&lt;/code&gt;), a small Cloudflare Worker:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Honest about who it is:&lt;/strong&gt; a &lt;code&gt;RevCheck/0.1 (+github link)&lt;/code&gt; User-Agent, plus the &lt;code&gt;CF-Worker&lt;/code&gt; header Cloudflare adds to every Worker request. Either site can block it with one rule, and if they do, we stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a public proxy:&lt;/strong&gt; it requires a shared secret that only the DigitalOcean function has, and it only fetches a mobile.bg listing…&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/GeorgievAlex/revcheck" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Same rule as my last two entries: no framework, no database, no npm dependencies. A static page on GitHub Pages, one DigitalOcean Function and one tiny Cloudflare Worker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;browser ── GitHub Pages (static) + Cloudflare Turnstile
   │  start   → bot check, returns a 3-minute signed ticket for these two links
   │  car ×2  → one request per car, in parallel
   │  verdict → the written comparison
   ▼
DigitalOcean Function  revcheck/compare
   ├─ reads listings + search pages ──► Cloudflare Worker ──► mobile.bg / cars.bg
   ├─ Gemma 4 (vision): every photo, 8 per call      ┐
   ├─ DeepSeek V4 Pro: engine + known weak spots      ├ DigitalOcean Serverless Inference
   ├─ Gemma 4: the verdict                            ┘
   └─ TabPFN 3.5: fair price from the comparables ──► Prior Labs API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Why this one&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Look at the photos&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gemma-4-31B-it&lt;/code&gt; (open-weight)&lt;/td&gt;
&lt;td&gt;Reads a dashboard, a bumper and a seat in one pass, in Bulgarian or English&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engine and weak spots&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deepseek-v4-pro&lt;/code&gt; (open-weight)&lt;/td&gt;
&lt;td&gt;I tried four open models on the same car. Two returned broken JSON, and of the other two only DeepSeek brought up the gearbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fair price&lt;/td&gt;
&lt;td&gt;TabPFN 3.5&lt;/td&gt;
&lt;td&gt;Trains on the ~50 comparables inside the request, no training pipeline of my own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The verdict&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-31B-it&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Weighs all of the above and writes like a friend who knows cars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things I'm happy with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The browser data is untrusted.&lt;/strong&gt; The verdict step gets each car's results back from the page, so every field is rebuilt from a known list with size caps before it reaches the model. Photo URLs have to point at the two sites' image servers, because Gemma downloads them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scary stuff is decided in code, not by the model.&lt;/strong&gt; The odometer flag, the VIN check, km per year and US imports are all rules. The model can only &lt;em&gt;suggest&lt;/em&gt; something to check (more on why below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every slow step has a deadline.&lt;/strong&gt; If the known-issues lookup hangs, you get the car without it instead of no car at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;26 tests&lt;/strong&gt;, all &lt;code&gt;node:test&lt;/code&gt;, including the ugly real-world cases from the next section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://github.com/GeorgievAlex/revcheck/commits/main" rel="noopener noreferrer"&gt;commit history&lt;/a&gt; has one commit per lesson below, if you want to see them land.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong on the way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The listing sites block DigitalOcean.&lt;/strong&gt; My first version fetched listings from the function, and on my laptop it worked perfectly. Once deployed, mobile.bg answered with a Cloudflare "Just a moment..." page and cars.bg with a 403. They block cloud servers, which is fair, they're tired of scrapers. The browser can't read them either, because neither site allows other websites to (no CORS headers). I built a small browser extension, and then threw it away, because nobody is going to install an extension from a site they've never heard of. What worked was a Cloudflare Worker that does nothing but fetch a listing, a search page or a model list. It sends &lt;code&gt;RevCheck/0.1&lt;/code&gt; and a link to the repo as its User-Agent, and Cloudflare adds a header naming the Worker, so either site can see exactly who's asking and block it with one rule. If they do, that's their call and I'll stop. It needs a secret only my function has, and it refuses any other URL, so it isn't an open proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma accused a seller of odometer fraud.&lt;/strong&gt; On a 2012 Audi Q5 listed at 104,000 km, it read the dashboard photo as 266,777 km and the verdict called it "severe odometer fraud". I zoomed into the photo myself: the dash says 104,677. A blurry digit nearly got a real person called a fraudster on a public page. Now the model only &lt;em&gt;reads&lt;/em&gt; the odometer and rates the reading clear or unclear, and the code decides. A flag only appears if the reading is clear and more than 15% off, and even then it says "check the dash in person". The prompts now ban accusations outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Not enough photos" on a listing with 10 photos.&lt;/strong&gt; DigitalOcean's endpoint takes at most 8 image links per call, so I check photos in parallel batches of 8. The last batch had just 2 interior photos, and the model, seeing only those, wrote "very few photos, interior only". The model doesn't get to judge photo count any more. The code does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A coupe compared against sedans.&lt;/strong&gt; One cars.bg seller typed the body type as "Kупе", with a Latin K. My body-type filter didn't recognise it and quietly searched without it. Funny enough, cars.bg's own search form spells it with a Latin K too. Body types are now compared after mapping Latin look-alike letters to Cyrillic, and there's a test with that exact string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AI invented a recall.&lt;/strong&gt; I wanted RevCheck to list manufacturer campaigns, the "VANOS bolts" kind. DeepSeek confidently listed a VANOS bolt campaign for an N52 engine, which as far as I know was for different engines. I'd also gotten something wrong myself while testing: I told it a 2007 272 hp 330i &lt;em&gt;had&lt;/em&gt; to be an N53, when the N52 made the same power until the changeover that year. So two changes. Recalls aren't generated at all: the page tells you to have a dealer check open campaigns by VIN, and links to a VIN decoder. And when an engine changed mid-year, the model has to give both and say "likely", never "certain".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Requests died at exactly 40 seconds.&lt;/strong&gt; With photos, TabPFN and the known-issues lookup, a full comparison took 33–45 seconds, and every so often the page said "couldn't reach the comparer". I deployed a test function that just sleeps: DigitalOcean's web gateway answers &lt;code&gt;202 Response not yet ready&lt;/code&gt; after exactly 40 seconds, whatever the function's own timeout is. So a comparison is now three kinds of short requests (start in ~2 s, each car in ~19 s in parallel, the verdict in ~8 s), tied together by a signed ticket so the bot check still covers all of them. As a bonus, the cars now show up before the verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The right model for each job, behind one endpoint.&lt;/strong&gt; Gemma looks at photos, DeepSeek knows engines, TabPFN does the numbers. With open-weight models on DigitalOcean's serverless inference, switching the known-issues step from Gemma to DeepSeek after testing was one string. With a single closed API, I'd have been stuck with whatever that vendor's one model knows about BMW timing chains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It has to be cheap per comparison, or the side hustle doesn't work.&lt;/strong&gt; One comparison is 6 to 8 model calls. Open models on prepaid inference keep that cheap enough to give a couple away free every day, and the prepaid balance with auto-reload off is a hard cap on the bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TabPFN trains on today's market, inside the request.&lt;/strong&gt; No dataset to collect, no model to retrain. The 50 cars for sale &lt;em&gt;right now&lt;/em&gt; are the training set, which is exactly what you want for used-car prices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open code is how you earn trust when you read other people's sites.&lt;/strong&gt; The Worker that fetches listings is about 50 lines in the repo. Anyone, including mobile.bg and cars.bg, can read exactly what it fetches and how often.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of DigitalOcean:&lt;/strong&gt; a DigitalOcean Function runs the whole backend, and DigitalOcean Serverless Inference serves both open-weight models (Gemma 4 and DeepSeek V4 Pro). The 40-second gateway lesson above is a DigitalOcean story too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; &lt;code&gt;gemma-4-31B-it&lt;/code&gt; checks every photo, with damage, equipment and odometer readings, and writes the final verdict in English or Bulgarian.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of TabPFN:&lt;/strong&gt; TabPFN 3.5 predicts a fair price range (10th to 90th percentile) for each car, trained on live comparables from two sites, and that prediction feeds the verdict. It's one step inside the larger prediction pipeline, not a standalone demo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exact engine codes and campaigns from the VIN.&lt;/strong&gt; The BMW decoders I use myself don't allow automated lookups, so for now RevCheck just links to them. OEMNavigations sells an official BMW VIN API, which would give the exact engine, build date and campaigns instead of "likely".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking mobile.bg and cars.bg for proper access&lt;/strong&gt;, now that there's something real to show them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mobile.de&lt;/strong&gt;, through its official API, for the "what would this car cost in Germany plus import" question that every Bulgarian buyer asks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Buying a used car in Bulgaria?&lt;/strong&gt; Paste your two favourites into &lt;a href="https://georgievalex.github.io/revcheck/" rel="noopener noreferrer"&gt;RevCheck&lt;/a&gt; before you drive anywhere.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
