<?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>MongoDB 8.2's encrypted substring search costs 20 times more per insert than equality</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Wed, 16 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/mongodb-82s-encrypted-substring-search-costs-20-times-more-per-insert-than-equality-lbf</link>
      <guid>https://dev.to/alexgeorgiev17/mongodb-82s-encrypted-substring-search-costs-20-times-more-per-insert-than-equality-lbf</guid>
      <description>&lt;p&gt;I inserted 200 documents into a MongoDB collection with one encrypted, substring-searchable field and it took 4.6 seconds. The same 200 documents into a collection with only encrypted equality search took 232 milliseconds. Plaintext took 7 milliseconds.&lt;/p&gt;

&lt;p&gt;MongoDB 8.2 added public preview support for prefix, suffix and substring queries against Queryable Encryption fields, through three new aggregation operators: &lt;code&gt;$encStrStartsWith&lt;/code&gt;, &lt;code&gt;$encStrEndsWith&lt;/code&gt; and &lt;code&gt;$encStrContains&lt;/code&gt;. Until now, Queryable Encryption could only do equality and range lookups on an encrypted field — nothing that resembles a &lt;code&gt;LIKE '%foo%'&lt;/code&gt;. I built a small encrypted collection, indexed an &lt;code&gt;email&lt;/code&gt; field for substring search, and measured what that actually costs against the same field indexed for plain equality, and against no encryption at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;I ran MongoDB 8.2.12 (&lt;code&gt;docker run mongo:8.2&lt;/code&gt;, Community Edition, confirmed via &lt;code&gt;db.runCommand({buildInfo:1}).modules&lt;/code&gt; returning an empty array — no Enterprise module) as a single-node replica set, which Queryable Encryption requires. Automatic encryption needs a query-analysis component; I used the &lt;code&gt;crypt_shared&lt;/code&gt; shared library, downloaded directly from MongoDB's public download server with no login or Enterprise license involved. The client was PyMongo 4.18.1 with &lt;code&gt;pymongo[encryption]&lt;/code&gt; and a local (non-production) KMS key. This is a public preview feature — MongoDB's own docs say not to use it in production, and preview functionality will be incompatible with the eventual GA version.&lt;/p&gt;

&lt;p&gt;One thing worth correcting up front: I'd read that automatic Queryable Encryption needs MongoDB Enterprise. It doesn't. The &lt;code&gt;crypt_shared&lt;/code&gt; library is a free download, and the whole setup above ran against stock Community Edition without complaint.&lt;/p&gt;

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

&lt;p&gt;I built three versions of the same collection, differing only in how the &lt;code&gt;email&lt;/code&gt; field is indexed, inserted the same 200 generated documents into each (three trials, fastest reported), and compared to an unencrypted collection:&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;Insert cost (ms/doc, fastest of 3)&lt;/th&gt;
&lt;th&gt;Avg document size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plaintext, no encryption&lt;/td&gt;
&lt;td&gt;0.03&lt;/td&gt;
&lt;td&gt;133 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queryable Encryption, equality only&lt;/td&gt;
&lt;td&gt;1.16&lt;/td&gt;
&lt;td&gt;489 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queryable Encryption, substringPreview&lt;/td&gt;
&lt;td&gt;22.9&lt;/td&gt;
&lt;td&gt;24,129 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The equality number already reflects the tax you already know about with Queryable Encryption: roughly 35x the insert cost and 3.7x the document size of plaintext, because every write also has to touch the hidden &lt;code&gt;ESC&lt;/code&gt; and &lt;code&gt;ECOC&lt;/code&gt; metadata collections that back the encrypted index. Substring search pays that same tax and then a much larger one on top: 20x the insert cost of equality-only encryption, and storage that dwarfs both.&lt;/p&gt;

&lt;p&gt;For a 500-document run I measured the full picture, including the metadata collections:&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;Main collection&lt;/th&gt;
&lt;th&gt;ESC&lt;/th&gt;
&lt;th&gt;ECOC&lt;/th&gt;
&lt;th&gt;Total for 500 docs&lt;/th&gt;
&lt;th&gt;Per doc&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plaintext&lt;/td&gt;
&lt;td&gt;66.5 KB&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;66.5 KB&lt;/td&gt;
&lt;td&gt;133 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Equality QE&lt;/td&gt;
&lt;td&gt;244.5 KB&lt;/td&gt;
&lt;td&gt;23.5 KB&lt;/td&gt;
&lt;td&gt;51.5 KB&lt;/td&gt;
&lt;td&gt;319.5 KB&lt;/td&gt;
&lt;td&gt;639 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;substringPreview QE&lt;/td&gt;
&lt;td&gt;12.06 MB&lt;/td&gt;
&lt;td&gt;4.07 MB&lt;/td&gt;
&lt;td&gt;9.17 MB&lt;/td&gt;
&lt;td&gt;25.3 MB&lt;/td&gt;
&lt;td&gt;50.6 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 380 times the storage of plaintext, and 79 times the storage of the same field encrypted for equality only. The reason is visible in the ECOC collection: it held 86,500 entries for 500 documents, or about 173 encrypted tokens per email address. &lt;code&gt;strMaxLength&lt;/code&gt; was set to 40 and the query length range to 3–10 characters, and MongoDB has to generate an indexable token for every substring in that length range so any of them can later be matched without decrypting the field. A 25-character email address has roughly that many valid substrings between 3 and 10 characters long, so the token count checks out.&lt;/p&gt;

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

&lt;p&gt;The field-length and query-length bounds are enforced, not advisory. Inserting a 47-character email into a field configured with &lt;code&gt;strMaxLength: 40&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;EncryptionError: StrEncode: String passed in was longer than the maximum
length for substring indexing -- String len: 47, max len: 40
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Querying with a 2-character substring against a field configured with &lt;code&gt;strMinQueryLength: 3&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;EncryptionError: StrQuery: string value was shorter than the minimum query
length for this field after folding -- folded codepoint len: 2, min query len: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both fail on the client, before anything reaches the server. So does a plain &lt;code&gt;$regex&lt;/code&gt; against an encrypted field, run through the same encrypted client — it doesn't silently return nothing, it's rejected outright: &lt;code&gt;Invalid match expression operator on encrypted field 'email'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The sharper limitation is in how many query types one field can carry. I first tried configuring &lt;code&gt;email&lt;/code&gt; with all three preview types — substring, prefix and suffix — on the theory that one indexed field should support all three lookup styles. MongoDB refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OperationFailure: The number of query types for an encrypted field cannot
exceed two
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropping to two types, substring plus prefix, still failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OperationFailure: Multiple query types may only include the suffixPreview
and prefixPreview query types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;substringPreview&lt;/code&gt; cannot be combined with anything, not even equality, on the same field. The only legal pair is prefix and suffix together. I set that up — &lt;code&gt;email&lt;/code&gt; configured with both &lt;code&gt;prefixPreview&lt;/code&gt; and &lt;code&gt;suffixPreview&lt;/code&gt; — and it worked: &lt;code&gt;$encStrStartsWith&lt;/code&gt; and &lt;code&gt;$encStrEndsWith&lt;/code&gt; both ran correctly against it, returning 61 and 113 matches respectively, sane numbers for the test data. That combined field cost 4.3 ms/doc to insert — cheaper than substring alone, but still 130x the 0.03 ms/doc plaintext baseline — and averaged 2,526 bytes per document, about a tenth of what substring alone needed. If your schema needs both prefix/suffix search and substring search on the same logical value, today that means two separate encrypted fields carrying the same plaintext, because one field can't carry both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query latency
&lt;/h2&gt;

&lt;p&gt;Against 500 documents, &lt;code&gt;$encStrContains&lt;/code&gt; for the string "acme" (112 matches) took a fastest time of 25.3 ms across five runs. The equivalent plaintext &lt;code&gt;$regex&lt;/code&gt; query against the same data took 1.0 ms. Both returned exactly 112 documents, so correctness wasn't in question, only cost — roughly 25x slower on a dataset barely large enough to notice.&lt;/p&gt;

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

&lt;p&gt;My first concurrency test used a Python thread pool: ten threads, each running the same &lt;code&gt;$encStrContains&lt;/code&gt; query concurrently through a shared PyMongo client. Single-threaded, the query took 28.7 ms. At ten concurrent threads, the average jumped to 485.7 ms — a 17x slowdown that looked like severe server-side lock contention on the encrypted index.&lt;/p&gt;

&lt;p&gt;It wasn't. Query analysis for these operators happens client-side, inside &lt;code&gt;crypt_shared&lt;/code&gt;, and that work is CPU-bound. Python threads share one interpreter lock, so ten threads doing CPU-bound cryptographic work serialize against each other regardless of what the server is doing. I reran the same test using separate OS processes instead of threads, each with its own client and no GIL to contend over. The baseline (including per-process client setup) was 34–43 ms, and at ten concurrent processes the average was 87.2 ms — about 2x, not 17x.&lt;/p&gt;

&lt;p&gt;The lesson isn't about MongoDB, it's about benchmarking anything that does client-side crypto from Python: a thread-pool concurrency test measures your interpreter's lock contention as much as it measures the database.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;db.serverStatus().fle&lt;/code&gt; reports live counts of which query types are in use across encrypted fields on the deployment:&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="err"&gt;indexTypeStats:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;unindexed:&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="err"&gt;equality:&lt;/span&gt;&lt;span class="w"&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="err"&gt;substringPreview:&lt;/span&gt;&lt;span class="w"&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="err"&gt;suffixPreview:&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="err"&gt;prefixPreview:&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="err"&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;That's the one place I found to check, at a glance, whether anyone has put a &lt;code&gt;substringPreview&lt;/code&gt; field into a cluster — useful given the preview warning against doing that in production. The same section also reports &lt;code&gt;compactStats&lt;/code&gt; and &lt;code&gt;cleanupStats&lt;/code&gt; for the ESC and ECOC collections, which is where you'd watch the housekeeping that keeps those metadata collections from growing forever as documents are updated or deleted.&lt;/p&gt;

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

&lt;p&gt;This needs Docker and Python 3.11+.&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;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; mongoqe &lt;span class="nt"&gt;--network&lt;/span&gt; host mongo:8.2 &lt;span class="nt"&gt;--replSet&lt;/span&gt; rs0 &lt;span class="nt"&gt;--port&lt;/span&gt; 27117 &lt;span class="nt"&gt;--bind_ip_all&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;4
docker &lt;span class="nb"&gt;exec &lt;/span&gt;mongoqe mongosh &lt;span class="nt"&gt;--port&lt;/span&gt; 27117 &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--eval&lt;/span&gt; &lt;span class="s1"&gt;'rs.initiate({_id:"rs0", members:[{_id:0, host:"localhost:27117"}]})'&lt;/span&gt;

curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; crypt_shared.tgz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://downloads.mongodb.com/linux/mongo_crypt_shared_v1-linux-x86_64-enterprise-ubuntu2204-8.2.12.tgz"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir &lt;/span&gt;crypt_shared &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;tar &lt;/span&gt;xzf crypt_shared.tgz &lt;span class="nt"&gt;-C&lt;/span&gt; crypt_shared

python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"pymongo[encryption]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, in Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pymongo&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MongoClient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pymongo.encryption&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClientEncryption&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AutoEncryptionOpts&lt;/span&gt;

&lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mongodb://localhost:27117/?replicaSet=rs0&amp;amp;directConnection=true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;kms_providers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&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="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MongoClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ClientEncryption&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kms_providers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encryption.__keyVault&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;codec_options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&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;email&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;bsonType&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;string&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;queries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queryType&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;substringPreview&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;contention&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strMaxLength&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strMinQueryLength&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strMaxQueryLength&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;caseSensitive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diacriticSensitive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;}]}&lt;/span&gt;
&lt;span class="n"&gt;coll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_encrypted_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qedemo&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;users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;auto_opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AutoEncryptionOpts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kms_providers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;encryption.__keyVault&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="n"&gt;crypt_shared_lib_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crypt_shared/lib/mongo_crypt_v1.so&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ecoll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MongoClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auto_encryption_opts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;auto_opts&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qedemo&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;users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;ecoll&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert_one&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&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;alice@acme.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ecoll&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aggregate&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;$match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$expr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$encStrContains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&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;$email&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;substring&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;acme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}}},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__safeContent__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
&lt;span class="p"&gt;])))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last &lt;code&gt;$project&lt;/code&gt; matters: every document in an encrypted collection carries a &lt;code&gt;__safeContent__&lt;/code&gt; array of encrypted search tags, and without excluding it you'll get a wall of binary blobs back with your result. I verified every command above against a fresh container before publishing.&lt;/p&gt;

&lt;p&gt;If you're evaluating Queryable Encryption for a field that needs pattern matching, measure the insert and storage cost on your own document size and access pattern before committing to it — the multiplier scales with how many substrings your &lt;code&gt;strMaxLength&lt;/code&gt;/query-length settings force it to index, so a longer field or a wider query-length range will cost more than what I measured here, not less. And if you need more than one style of pattern lookup on the same value, budget for a second encrypted field, because MongoDB won't let one field carry substring search alongside anything else.&lt;/p&gt;

</description>
      <category>mongodb</category>
      <category>database</category>
      <category>security</category>
      <category>performance</category>
    </item>
    <item>
      <title>Tigris marks every read with the region and cache layer that actually served it</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/tigris-marks-every-read-with-the-region-and-cache-layer-that-actually-served-it-28p</link>
      <guid>https://dev.to/alexgeorgiev17/tigris-marks-every-read-with-the-region-and-cache-layer-that-actually-served-it-28p</guid>
      <description>&lt;p&gt;Disclosure: I have no affiliation with Tigris. Nobody asked me to write this and nobody paid for it. I signed up and generated an access key like anyone else would.&lt;/p&gt;

&lt;p&gt;I created a bucket with no region argument at all, put one object in it, then read it back and looked at the response headers instead of just the body. Three of them named something most S3-compatible services never tell you: &lt;code&gt;X-Tigris-Regions: fra&lt;/code&gt;, &lt;code&gt;X-Tigris-Served-From: fra&lt;/code&gt;, &lt;code&gt;X-Tigris-Read-Source: cache&lt;/code&gt;. The read wasn't served from an abstraction, it was served from Frankfurt, from a cache layer, and the API told me so without being asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Tigris is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.tigrisdata.com" rel="noopener noreferrer"&gt;Tigris&lt;/a&gt; is S3-compatible object storage built around one specific pitch: write from anywhere, read from anywhere, and the data follows access patterns globally instead of living in one region you have to pick upfront. It's a $25M Series A company (Spark Capital led, a16z participating, October 2025), founded 2021, and despite claiming 4,000+ customers, its dev.to and Hacker News presence is almost entirely other people's tutorials using it as an S3/R2 alternative inside broader posts, not dedicated coverage of the company itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Global by default, pinnable if you want it
&lt;/h2&gt;

&lt;p&gt;Creating a bucket with the plain S3 API and no location constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api create-bucket &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-bucket &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
&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;aws s3api get-bucket-location &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-bucket &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"LocationConstraint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"global"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;global&lt;/code&gt; is the default, not a special mode you opt into. If you want the opposite, standard single-region behavior, that's one parameter away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api create-bucket &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-pinned-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-bucket-configuration&lt;/span&gt; &lt;span class="nv"&gt;LocationConstraint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;iad &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
&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;aws s3api get-bucket-location &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-pinned-bucket &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"LocationConstraint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iad"&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;Both requests succeeded on the first try, no special account flag or enterprise-tier gate to reach either mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the read path actually reveals
&lt;/h2&gt;

&lt;p&gt;Back on the global bucket, &lt;code&gt;head-object&lt;/code&gt; and &lt;code&gt;get-object&lt;/code&gt; both return the same three headers on every response, not just the first one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api get-object &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-bucket &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt;/object.txt /tmp/out.txt &lt;span class="nt"&gt;--debug&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s2"&gt;"X-Tigris-[A-Za-z-]+': '[a-z]+'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-Tigris-Read-Source': 'cache'
X-Tigris-Regions': 'fra'
X-Tigris-Served-From': 'fra'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran that same request three times in a row and got the identical answer each time: &lt;code&gt;fra&lt;/code&gt;, cache, &lt;code&gt;fra&lt;/code&gt;. That's the honest limit of what a single test from a single network location can show, this confirms the metadata is real and consistently exposed, not that I've proven true global read-latency optimization, which would need requests actually originating from multiple regions to test properly. What it does prove is that the abstraction isn't a black box: if a read from your application looks slow, the response headers tell you which physical region and cache layer actually handled it, instead of leaving you to guess.&lt;/p&gt;

&lt;p&gt;Presigned URLs work the way you'd expect and don't require the requester to have any credentials 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;aws s3 presign s3://my-bucket/test/object.txt &lt;span class="nt"&gt;--expires-in&lt;/span&gt; 300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetching that URL with a plain &lt;code&gt;curl&lt;/code&gt;, no AWS credentials anywhere on that machine, returned the exact same bytes I'd uploaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it gets wrong
&lt;/h2&gt;

&lt;p&gt;The S3 API surface isn't complete, and where it's incomplete, the failure mode is more confusing than it needs to be. Trying to enable versioning on an existing bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api put-bucket-versioning &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-bucket &lt;span class="nt"&gt;--versioning-configuration&lt;/span&gt; &lt;span class="nv"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Enabled &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
&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;An error occurred (BucketAlreadyExists) when calling the PutBucketVersioning operation:
The requested bucket name is not available. The bucket namespace is shared by
all users of the system. Specify a different name and try again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error is for bucket &lt;em&gt;creation&lt;/em&gt;, not versioning, on a bucket that already exists and that I already own. It reads like the versioning call got routed into the same handler as &lt;code&gt;CreateBucket&lt;/code&gt; rather than into real versioning logic, or rejected into whatever error path was closest at hand. Either way, &lt;code&gt;get-bucket-versioning&lt;/code&gt; before and after the failed call returned an empty response both times, so versioning is not silently on, but a user hitting this would spend real time debugging a bucket-naming problem that doesn't exist before concluding the feature just isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero-egress claim
&lt;/h2&gt;

&lt;p&gt;Tigris's own pricing page states $0.02/GB/month for standard storage and zero egress fees, with the same rate everywhere rather than per-region pricing. I didn't generate enough traffic in a short test to see a real bill, so I can't independently confirm the egress number the way I confirmed the region headers or the versioning error, this is what their pricing page says, not something I measured. Worth stating plainly rather than repeating it as if I'd verified it myself.&lt;/p&gt;

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

&lt;p&gt;Sign up at &lt;a href="https://www.tigrisdata.com" rel="noopener noreferrer"&gt;tigrisdata.com&lt;/a&gt;, generate an access key from the dashboard, then:&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;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_key
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_secret
aws s3api create-bucket &lt;span class="nt"&gt;--bucket&lt;/span&gt; your-test-bucket &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
aws s3api get-bucket-location &lt;span class="nt"&gt;--bucket&lt;/span&gt; your-test-bucket &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; test.txt
aws s3api put-object &lt;span class="nt"&gt;--bucket&lt;/span&gt; your-test-bucket &lt;span class="nt"&gt;--key&lt;/span&gt; test.txt &lt;span class="nt"&gt;--body&lt;/span&gt; test.txt &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
aws s3api get-object &lt;span class="nt"&gt;--bucket&lt;/span&gt; your-test-bucket &lt;span class="nt"&gt;--key&lt;/span&gt; test.txt out.txt &lt;span class="nt"&gt;--debug&lt;/span&gt; &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; tigris
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean up when you're done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 &lt;span class="nb"&gt;rm &lt;/span&gt;s3://your-test-bucket &lt;span class="nt"&gt;--recursive&lt;/span&gt; &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
aws s3api delete-bucket &lt;span class="nt"&gt;--bucket&lt;/span&gt; your-test-bucket &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; https://t3.storage.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  If you're weighing other object storage
&lt;/h2&gt;

&lt;p&gt;DigitalOcean Spaces and AWS S3 are the two most likely comparison points if you're evaluating this space, and they price the global-vs-regional tradeoff very differently from Tigris.&lt;/p&gt;

&lt;p&gt;DigitalOcean Spaces charges $5/month for a base subscription covering 250GiB of storage and 1,024GiB of outbound transfer, with $0.02/GiB for additional storage and $0.01/GiB for additional egress beyond that. Egress is only free in specific same-region Spaces-to-Droplet pairs (for example Spaces in FRA1 to Droplets in FRA1), not globally, so a Spaces bucket is a regional resource you pay to move data out of once you leave that pairing.&lt;/p&gt;

&lt;p&gt;AWS S3 Standard charges $0.023/GB for the first 50TB of storage per month in us-east-1, and egress to the internet is free only for the first 100GB per month (aggregated across all AWS services), then $0.09/GB for the next roughly 10TB, stepping down at higher volume tiers. Both storage and egress are priced per region, and moving data between AWS regions costs more again.&lt;/p&gt;

&lt;p&gt;Tigris's pitch is specifically that neither of those tradeoffs applies: one global price, zero egress, and the region-pinning option still there if you genuinely need data to stay put. Whether that's worth it depends on how much of your bill today is egress versus storage, that's a question only your own traffic pattern can answer, not something a single test bucket can tell you.&lt;/p&gt;

</description>
      <category>storage</category>
      <category>s3</category>
      <category>cloud</category>
      <category>devops</category>
    </item>
    <item>
      <title>Redis 8.10's compact hashes need HIMPORT or a restart to kick in</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Tue, 15 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/redis-810s-compact-hashes-need-himport-or-a-restart-to-kick-in-hj8</link>
      <guid>https://dev.to/alexgeorgiev17/redis-810s-compact-hashes-need-himport-or-a-restart-to-kick-in-hj8</guid>
      <description>&lt;p&gt;Redis 8.10 shipped in July with a feature called compact hashes: if many hashes share the same field names, Redis can store those field names once instead of once per key. The release blog claims up to 50% lower memory and 2x higher loading throughput. I wanted to know what "up to" actually means for a normal schema, and what you have to do to get it, because the answer to the second question turned out to be less obvious than I expected.&lt;/p&gt;

&lt;p&gt;I ran Redis 8.10.1 in Docker and tested it against two schemas, a throughput benchmark, a concurrency check, and the two ways to actually trigger the new encoding: the new &lt;code&gt;HIMPORT&lt;/code&gt; command, and a server restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The straightforward result
&lt;/h2&gt;

&lt;p&gt;Redis 8.10 adds hash templates: an internal encoding where hashes with the same field names keep one shared copy of those names. The only documented way to create one directly is &lt;code&gt;HIMPORT&lt;/code&gt;, a new command pair that declares a field list once per connection and then loads values against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HIMPORT PREPARE u name email country last_login
HIMPORT SET user:1 u Alice alice@example.com UK 2026-07-14
HIMPORT SET user:2 u Bob bob@example.com US 2026-07-14
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I loaded 100,000 hashes with a four-field user-profile schema (&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;country&lt;/code&gt;, &lt;code&gt;last_login&lt;/code&gt;) two ways: plain &lt;code&gt;HSET&lt;/code&gt; per key, and &lt;code&gt;HIMPORT&lt;/code&gt; against a prepared fieldset. I measured &lt;code&gt;used_memory&lt;/code&gt; from &lt;code&gt;INFO memory&lt;/code&gt; before and after each load, flushing the database between runs.&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;Bytes/hash&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain HSET&lt;/td&gt;
&lt;td&gt;159.9&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HIMPORT&lt;/td&gt;
&lt;td&gt;127.6&lt;/td&gt;
&lt;td&gt;20.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nowhere near the advertised 50%. So I built a second schema designed to favour the feature: eight fields with longer names (&lt;code&gt;account_status_code&lt;/code&gt;, &lt;code&gt;two_factor_enabled_flag&lt;/code&gt;, and so on) but short single-character or short-word values, the shape you'd get from flags and codes rather than free text.&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;Bytes/hash&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain HSET&lt;/td&gt;
&lt;td&gt;271.3&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HIMPORT&lt;/td&gt;
&lt;td&gt;87.3&lt;/td&gt;
&lt;td&gt;67.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the 50% figure is workload-dependent in both directions: my ordinary schema landed well under it, my field-name-heavy schema went well over it. The saving comes specifically from field names, so the more your schema's memory is field names rather than values, the more you get back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that didn't hold up
&lt;/h2&gt;

&lt;p&gt;The blog also claims "up to 2x higher hash loading throughput" for HIMPORT versus per-key HSET. I loaded 500,000 hashes both ways, three times each, timed from inside the container with &lt;code&gt;bash&lt;/code&gt;'s &lt;code&gt;time&lt;/code&gt; builtin to avoid &lt;code&gt;docker exec&lt;/code&gt; overhead skewing the numbers:&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;Fastest of 3 runs&lt;/th&gt;
&lt;th&gt;Hashes/sec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain HSET&lt;/td&gt;
&lt;td&gt;2.698s&lt;/td&gt;
&lt;td&gt;185,323&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HIMPORT&lt;/td&gt;
&lt;td&gt;1.992s&lt;/td&gt;
&lt;td&gt;251,004&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a 35% improvement, not 2x. I don't know what workload produces the blog's number, maybe a wider schema, maybe a network-bound client where skipping repeated field names in the wire protocol matters more than it does over a Unix socket to a local container. What I can say is that on a bulk pipe load with &lt;code&gt;redis-cli --pipe&lt;/code&gt;, the throughput gain was real but far more modest than advertised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plain HSET gets nothing, ever, until you restart
&lt;/h2&gt;

&lt;p&gt;This was the part I hadn't expected going in. I loaded the same 100,000-hash, shared-schema dataset with ordinary &lt;code&gt;HSET&lt;/code&gt; calls, then checked &lt;code&gt;INFO stats&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;hash_templates&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;hash_template_keys&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. Redis does not look at a live hash, notice it shares a field set with a thousand others, and fold it into a template. That only happens two ways: through &lt;code&gt;HIMPORT&lt;/code&gt;, or by reloading an RDB file with three new config parameters set, all of which default to off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;hash&lt;/span&gt;-&lt;span class="n"&gt;rdb&lt;/span&gt;-&lt;span class="n"&gt;load&lt;/span&gt;-&lt;span class="n"&gt;min&lt;/span&gt;-&lt;span class="n"&gt;template&lt;/span&gt;-&lt;span class="n"&gt;entries&lt;/span&gt;    &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;hash&lt;/span&gt;-&lt;span class="n"&gt;rdb&lt;/span&gt;-&lt;span class="n"&gt;load&lt;/span&gt;-&lt;span class="n"&gt;max&lt;/span&gt;-&lt;span class="n"&gt;template&lt;/span&gt;-&lt;span class="n"&gt;entries&lt;/span&gt;    &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;hash&lt;/span&gt;-&lt;span class="n"&gt;rdb&lt;/span&gt;-&lt;span class="n"&gt;load&lt;/span&gt;-&lt;span class="n"&gt;template&lt;/span&gt;-&lt;span class="n"&gt;disassembly&lt;/span&gt;-&lt;span class="n"&gt;threshold&lt;/span&gt;  &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I set these to 3, 20 and 2 and restarted the container expecting the conversion to happen. It didn't, and &lt;code&gt;hash_templates&lt;/code&gt; stayed at 0. The reason turned out to be my mistake, covered below. Once I fixed it, a &lt;code&gt;BGSAVE&lt;/code&gt; followed by a container restart converted all 100,000 plain-HSET hashes into a single shared template, and &lt;code&gt;used_memory&lt;/code&gt; dropped from 17,459,464 to 14,182,656 bytes, an 18.8% reduction, consistent with the direct HIMPORT comparison on the same schema.&lt;/p&gt;

&lt;p&gt;The practical upshot: if your application already writes hashes with plain &lt;code&gt;HSET&lt;/code&gt; and a consistent schema, compact hashes give you nothing until you either rewrite your write path to use &lt;code&gt;HIMPORT&lt;/code&gt;, or turn on the RDB-load config and restart (or fail over) every node that holds the data. A long-lived primary that never restarts and never uses &lt;code&gt;HIMPORT&lt;/code&gt; sees zero benefit from this feature, indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small numbers of shared keys make things worse, not better
&lt;/h2&gt;

&lt;p&gt;A template has its own fixed overhead, and something has to pay for it. I varied the number of keys sharing one fieldset and read &lt;code&gt;MEMORY USAGE&lt;/code&gt; on the first key each time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keys sharing the template&lt;/th&gt;
&lt;th&gt;MEMORY USAGE (bytes)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;129&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;108&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An equivalent plain hash with the same fields reports 112 bytes. So with two or three keys sharing a template, you're worse off than not using the feature at all. The crossover is around four to five keys, and the saving plateaus by about a hundred. If you're grouping small, short-lived batches of hashes under one fieldset, this feature can quietly cost you memory rather than save it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens if you mutate a templated hash
&lt;/h2&gt;

&lt;p&gt;I ran &lt;code&gt;HSET&lt;/code&gt; to add a field that wasn't in the schema on one of my 1,000-key templated hashes, and &lt;code&gt;HDEL&lt;/code&gt; to remove a field on another:&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;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;HSET user:1 extra_field zzz
&lt;span class="go"&gt;(integer) 1
&lt;/span&gt;&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;MEMORY USAGE user:1
&lt;span class="go"&gt;(integer) 265
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I expected the key to fall back to being a normal hash. It didn't. &lt;code&gt;INFO stats&lt;/code&gt; showed &lt;code&gt;hash_templates&lt;/code&gt; go from 1 to 2, then to 3 after the &lt;code&gt;HDEL&lt;/code&gt;, while &lt;code&gt;hash_template_keys&lt;/code&gt; stayed at 1,000 throughout. Redis had forked off a brand new, single-key template for each mutated hash rather than detaching it back to plain encoding. &lt;code&gt;user:1&lt;/code&gt; went from 76 bytes to 265, and the &lt;code&gt;HDEL&lt;/code&gt;'d key went to 208, both worse than the 112-byte plain hash baseline. The other 998 untouched keys stayed at 76 bytes, unaffected. So a single stray field on a templated hash doesn't just lose the sharing benefit for that key, it costs more than never having used the feature at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusals and connection scoping
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HIMPORT&lt;/code&gt;'s fieldsets live on the connection, not the server, and Redis enforces this strictly. Sending the wrong number of values:&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;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;HIMPORT PREPARE u2 name email country last_login
&lt;span class="go"&gt;OK
&lt;/span&gt;&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;HIMPORT SET user:3 u2 Carol carol@example.com
&lt;span class="go"&gt;(error) ERR value count does not match fieldset field count
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Referencing a name nobody prepared:&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;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;HIMPORT SET user:4 doesnotexist X Y Z W
&lt;span class="go"&gt;(error) ERR no such fieldset
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And, importantly, opening a fresh connection and trying to use a fieldset prepared on another one fails with the same error. I confirmed this by preparing a fieldset in one &lt;code&gt;redis-cli&lt;/code&gt; session and calling &lt;code&gt;HIMPORT SET&lt;/code&gt; against it from a second, separate &lt;code&gt;redis-cli&lt;/code&gt; invocation. Any connection-pooled client, which most production Redis clients are, will hit this the moment a request lands on a different pooled connection than the one that ran &lt;code&gt;PREPARE&lt;/code&gt;. You'd need to re-run &lt;code&gt;PREPARE&lt;/code&gt; on every connection in the pool, or pin the whole import to a single connection. &lt;code&gt;RESET&lt;/code&gt; also drops the fieldset immediately, which I checked directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency behaved better than I expected
&lt;/h2&gt;

&lt;p&gt;I ran ten separate &lt;code&gt;redis-cli --pipe&lt;/code&gt; processes in parallel, each opening its own connection, each declaring the same four-field schema under its own &lt;code&gt;PREPARE&lt;/code&gt;, each writing 2,000 disjoint keys. All ten finished with zero errors in under a third of a second combined. The interesting part was in &lt;code&gt;INFO stats&lt;/code&gt; afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;hash_templates&lt;/span&gt;:&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;hash_template_keys&lt;/span&gt;:&lt;span class="m"&gt;20000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One template, not ten. Even though each connection prepared its fieldset independently, Redis deduplicated them server-side by the actual field names, not by the client-chosen fieldset label. Ten unrelated connections converged on one shared template with no coordination required and no errors under concurrent writes.&lt;/p&gt;

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

&lt;p&gt;My first attempt to test the RDB auto-conversion config failed silently. I set the three &lt;code&gt;hash-rdb-load-*&lt;/code&gt; parameters with &lt;code&gt;CONFIG SET&lt;/code&gt;, ran &lt;code&gt;BGSAVE&lt;/code&gt;, then did &lt;code&gt;docker restart&lt;/code&gt; on the container. After the restart, &lt;code&gt;hash_templates&lt;/code&gt; was still 0, and the config values had reverted to 0 as well. I assumed the container had picked up the same settings, since &lt;code&gt;docker restart&lt;/code&gt; re-runs the same command. It does, but I'd started the original container with only &lt;code&gt;--save "" --appendonly no&lt;/code&gt;, no config file and none of the template flags on the command line, so &lt;code&gt;CONFIG SET&lt;/code&gt; values I'd applied at runtime were never persisted anywhere Redis would read them again. &lt;code&gt;docker restart&lt;/code&gt; doesn't remember &lt;code&gt;CONFIG SET&lt;/code&gt; history. I had to stop the container, remove it, and start a new one with the three parameters passed directly as &lt;code&gt;redis-server&lt;/code&gt; arguments before the RDB reload conversion worked at all.&lt;/p&gt;

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

&lt;p&gt;This loads 100,000 hashes both ways and prints the memory delta for each.&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;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; redis810 &lt;span class="nt"&gt;-p&lt;/span&gt; 16379:6379 redis:8.10.1 &lt;span class="se"&gt;\&lt;/span&gt;
  redis-server &lt;span class="nt"&gt;--save&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;--appendonly&lt;/span&gt; no

python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
for i in range(100000):
    print(f'HSET plain:{i} name User{i} email user{i}@example.com country UK last_login 2026-07-14')
"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; plain.txt

python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
print('HIMPORT PREPARE u name email country last_login')
for i in range(100000):
    print(f'HIMPORT SET himp:{i} u User{i} user{i}@example.com UK 2026-07-14')
"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; himp.txt

docker &lt;span class="nb"&gt;exec &lt;/span&gt;redis810 redis-cli FLUSHALL
docker &lt;span class="nb"&gt;exec &lt;/span&gt;redis810 redis-cli INFO memory | &lt;span class="nb"&gt;grep&lt;/span&gt; ^used_memory:
&lt;span class="nb"&gt;cat &lt;/span&gt;plain.txt | docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; redis810 redis-cli &lt;span class="nt"&gt;--pipe&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;redis810 redis-cli INFO memory | &lt;span class="nb"&gt;grep&lt;/span&gt; ^used_memory:

docker &lt;span class="nb"&gt;exec &lt;/span&gt;redis810 redis-cli FLUSHALL
docker &lt;span class="nb"&gt;exec &lt;/span&gt;redis810 redis-cli INFO memory | &lt;span class="nb"&gt;grep&lt;/span&gt; ^used_memory:
&lt;span class="nb"&gt;cat &lt;/span&gt;himp.txt | docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; redis810 redis-cli &lt;span class="nt"&gt;--pipe&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;redis810 redis-cli INFO memory | &lt;span class="nb"&gt;grep&lt;/span&gt; ^used_memory:
docker &lt;span class="nb"&gt;exec &lt;/span&gt;redis810 redis-cli INFO stats | &lt;span class="nb"&gt;grep &lt;/span&gt;hash_template
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran this exact script against Redis 8.10.1 while writing this post; the numbers match the first table above within a few percent between runs.&lt;/p&gt;

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

&lt;p&gt;If you're already running a schema where thousands of hashes share the same fields, HIMPORT is worth adopting for bulk loads and ETL paths specifically, not as a drop-in replacement for HSET in your normal write path. Check your actual field-name-to-value ratio before trusting the 50% figure either way. If you can't change your write path, the RDB-reload config is a lower-effort option, but remember it does nothing until the next restart or failover, and it needs the three &lt;code&gt;hash-rdb-load-*&lt;/code&gt; parameters set explicitly since none of them default to on. Either way, watch &lt;code&gt;INFO stats&lt;/code&gt; for &lt;code&gt;hash_templates&lt;/code&gt; and &lt;code&gt;hash_template_keys&lt;/code&gt; before and after, because at low key-sharing counts, or after a single stray HSET on one of the keys, the feature can leave you worse off than not using it.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>database</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Django 6.1's FETCH_PEERS collapses a 2,001-query loop into 2</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Mon, 14 Sep 2026 08:30:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/django-61s-fetchpeers-collapses-a-2001-query-loop-into-2-77m</link>
      <guid>https://dev.to/alexgeorgiev17/django-61s-fetchpeers-collapses-a-2001-query-loop-into-2-77m</guid>
      <description>&lt;p&gt;A loop over 2,000 Django model instances that touched a foreign key on each one fired 2,001 queries against Postgres and took, at its fastest run, 1.14 seconds on localhost. That is the N+1 problem everyone who has used Django has hit at least once. Django 6.1, released on 5 August 2026, added a way to fix it without adding &lt;code&gt;select_related()&lt;/code&gt; or &lt;code&gt;prefetch_related()&lt;/code&gt; calls at every call site: a queryset-level setting called &lt;code&gt;fetch_mode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I built a small Postgres-backed project, ran the loop both ways, then spent the rest of the afternoon trying to break the new mode. It mostly held up. One combination silently didn't work at all, and I only found it because I went looking for the cases the documentation doesn't mention.&lt;/p&gt;

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

&lt;p&gt;Two models, a classic one-to-many:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;bio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;books&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;price_cents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IntegerField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;50 authors, 2,000 books, seeded with &lt;code&gt;bulk_create&lt;/code&gt; into Postgres 17 running in Docker, no cloud, no managed service. Django 6.1.1 on Python 3.12, &lt;code&gt;psycopg2-binary&lt;/code&gt; as the driver. I counted queries with &lt;code&gt;django.test.utils.reset_queries()&lt;/code&gt; and &lt;code&gt;len(connection.queries)&lt;/code&gt;, and timed each run with &lt;code&gt;time.perf_counter()&lt;/code&gt;, five repetitions per case.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;default_n_plus_1&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_peers&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FETCH_PEERS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
&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;approach&lt;/th&gt;
&lt;th&gt;queries&lt;/th&gt;
&lt;th&gt;fastest of 5 runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default (&lt;code&gt;FETCH_ONE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;2,001&lt;/td&gt;
&lt;td&gt;1,144.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch_mode(FETCH_PEERS)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;13.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;select_related("author")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;13.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;FETCH_PEERS&lt;/code&gt; took the 2,001-query loop down to 2 queries and cut the wall time by roughly 87x. It landed within a millisecond of &lt;code&gt;select_related&lt;/code&gt;, despite firing one extra query, because that second query is a single &lt;code&gt;WHERE id IN (...)&lt;/code&gt; batch fetch rather than 2,000 round trips. I checked the actual SQL Django ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"blog_book"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"blog_book"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"blog_book"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"author_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"blog_book"&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"blog_author"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"blog_author"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"blog_author"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"bio"&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"blog_author"&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"blog_author"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IN&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="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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is exactly what &lt;code&gt;prefetch_related()&lt;/code&gt; produces, except you didn't have to write it. The documentation's claim that this "works like an on-demand &lt;code&gt;prefetch_related()&lt;/code&gt;" and "reduces most cases of the N+1 problem to two queries" held up precisely, down to the query count.&lt;/p&gt;

&lt;p&gt;It held for deferred fields too, not just foreign keys. Loading books with &lt;code&gt;.only("id", "title")&lt;/code&gt; and then touching the deferred &lt;code&gt;description&lt;/code&gt; field, under &lt;code&gt;FETCH_PEERS&lt;/code&gt;, also went from 2,001 queries and roughly 1.3 seconds to 2 queries and 14ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it refuses: the many side
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FETCH_PEERS&lt;/code&gt; documentation lists what it applies to: forward foreign keys, one-to-one fields and their reverse accessors, deferred fields, generic relations. It does not list reverse foreign key managers, and testing confirmed that gap is real, not an oversight in my reading.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reverse_fk_fetch_peers&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FETCH_PEERS&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;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&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;total&lt;/span&gt;
&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;approach&lt;/th&gt;
&lt;th&gt;queries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default, &lt;code&gt;author.books.all()&lt;/code&gt; per author&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fetch_mode(FETCH_PEERS)&lt;/code&gt;, same loop&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Identical. Setting &lt;code&gt;fetch_mode&lt;/code&gt; on the &lt;code&gt;Author&lt;/code&gt; queryset does nothing for &lt;code&gt;author.books.all()&lt;/code&gt;, because that call returns a fresh &lt;code&gt;RelatedManager&lt;/code&gt; queryset rather than fetching a field value. If your N+1 problem is on the "many" side of a relation, &lt;code&gt;fetch_mode&lt;/code&gt; will not touch it; you still need &lt;code&gt;prefetch_related("books")&lt;/code&gt;. That is a real limitation for anyone reading the feature announcement and assuming it replaces &lt;code&gt;prefetch_related()&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it rejects outright
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FETCH_RAISE&lt;/code&gt; is the third mode, meant to catch accidental lazy loading in code that should already have everything it needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FETCH_RAISE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&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;django.core.exceptions.FieldFetchBlocked: Fetching of Book.description blocked.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same exception, same message shape, for a blocked forward foreign key: &lt;code&gt;Fetching of Book.author blocked.&lt;/code&gt; Both are precise enough to paste into a bug report and know exactly which field and which model tripped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha nobody warns you about
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FETCH_PEERS&lt;/code&gt; works by looking at every other instance that came out of the same queryset evaluation and batching the missing field across all of them. That requires the queryset to have materialised its full result list in memory, because peer tracking is done through weak references stored on each instance once the list exists.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;QuerySet.iterator()&lt;/code&gt; exists specifically to avoid that materialisation, for cases where a queryset is too large to hold in memory at once. So I tried combining them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FETCH_PEERS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;iterator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: 2,001 queries. Not an error, not a warning, just the full N+1 pattern the feature exists to prevent. I checked the Django source (&lt;code&gt;django/db/models/fetch_modes.py&lt;/code&gt;) to confirm this wasn't a bug in my own code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FetchPeers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FetchMode&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;instances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peers&lt;/span&gt; &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;w&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;fetcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_many&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;fetcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;.iterator()&lt;/code&gt;, each instance's &lt;code&gt;peers&lt;/code&gt; list never grows past itself, so &lt;code&gt;len(instances) &amp;gt; 1&lt;/code&gt; is never true, and every access quietly falls through to a single-row fetch. I could not find this interaction called out anywhere in the 6.1 release notes or the fetch modes documentation page I read. Anyone who reaches for &lt;code&gt;.iterator()&lt;/code&gt; on a large table for memory reasons, which is exactly when you'd want the query-count win most, gets none of it and no signal that anything went wrong.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;FETCH_PEERS&lt;/code&gt; batches eagerly, not lazily per access. I built a 2,000-row queryset and touched the author field on only every fourth book, 500 accesses out of 2,000 possible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;books=2000 accessed=500 queries=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still 2 queries. The first access to any instance's author field fetches authors for every peer in the queryset, whether or not you go on to touch the rest. That's the right trade-off if you're going to touch most of the set, and a waste if you only needed a slice of it, in which case a plain filtered queryset with &lt;code&gt;select_related&lt;/code&gt; would cost less.&lt;/p&gt;

&lt;p&gt;Peer tracking also turned out to be correctly scoped even when instances get mixed together after the fact. I built two separate querysets, both under &lt;code&gt;FETCH_PEERS&lt;/code&gt;, concatenated their result lists into one Python list, then accessed &lt;code&gt;.author&lt;/code&gt; across the combined list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queries after touching .author on ALL 199 combined elements: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two queries, one per original queryset, not one per Python list. Objects remember which evaluation they came from regardless of how you recombine them afterwards, which is the behaviour I'd want but hadn't seen stated anywhere.&lt;/p&gt;

&lt;p&gt;Memory cost was measurable but small at this scale: materialising 2,000 &lt;code&gt;Book&lt;/code&gt; instances added about 1.77 MiB over baseline, and the &lt;code&gt;FETCH_PEERS&lt;/code&gt; batch fetch of 50 authors added a further 400 KiB. For a table with a genuinely large row count, that first number is the one to watch, since it scales with how many rows you load before touching anything, independent of whether you use &lt;code&gt;FETCH_PEERS&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;Under 10 concurrent threads, each running its own independent &lt;code&gt;FETCH_PEERS&lt;/code&gt; loop against the same Postgres instance, every thread still saw exactly 2 queries, and results were correct in all 10. Per-thread wall time rose from 13ms solo to between 87ms and 179ms under contention, which is Postgres connection load, not a fetch_mode problem: the query-count discipline held regardless of how many callers were doing it at once.&lt;/p&gt;

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

&lt;p&gt;My first version of the query-counting harness used &lt;code&gt;django.test.utils.CaptureQueriesContext&lt;/code&gt;, which slices into &lt;code&gt;connection.queries_log&lt;/code&gt;, a deque capped at 9,000 entries. Running the 2,001-query N+1 case five times in a row, inside the same process, blew past that cap partway through, and every benchmark after the cap was hit silently reported 0 queries instead of an error. I only noticed because a &lt;code&gt;select_related&lt;/code&gt; case that should have shown 1 query showed 0, which was too suspicious to accept. Switching to &lt;code&gt;reset_queries()&lt;/code&gt; before each run, so the deque never had to hold more than one run's worth of history, fixed it. The lesson generalises: any tool that logs to a fixed-size buffer will lie to you quietly once you exceed it, and a suspiciously clean zero is worth more suspicion than a suspiciously large number.&lt;/p&gt;

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

&lt;p&gt;This needs Python 3.12+ (Django 6.1 requires it) and either a local Postgres or Docker:&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;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pgtest &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;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;testdb &lt;span class="nt"&gt;-p&lt;/span&gt; 5432:5432 postgres:17
python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./venv/bin/pip &lt;span class="nb"&gt;install &lt;/span&gt;django psycopg2-binary
./venv/bin/django-admin startproject testproj &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# add 'blog' to INSTALLED_APPS, point DATABASES at testdb/postgres/postgres/127.0.0.1&lt;/span&gt;
./venv/bin/python manage.py makemigrations blog &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./venv/bin/python manage.py migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the benchmark itself, saved and run as &lt;code&gt;bench.py&lt;/code&gt; from inside the project directory (so it can import &lt;code&gt;testproj.settings&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;django&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DJANGO_SETTINGS_MODULE&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;testproj.settings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.test.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;reset_queries&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blog.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;timed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;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="nf"&gt;reset_queries&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="nf"&gt;fn&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;),&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms&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;default_n_plus_1&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;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_peers&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;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FETCH_PEERS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;

&lt;span class="nf"&gt;timed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_n_plus_1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;timed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch_peers&lt;/span&gt;&lt;span class="p"&gt;)&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're on Django 6.1 and have a view or serializer with a loop that touches a related object per instance, &lt;code&gt;fetch_mode(models.FETCH_PEERS)&lt;/code&gt; on the queryset is a genuine drop-in fix, cheaper to write than auditing every call site for missing &lt;code&gt;select_related&lt;/code&gt;. Don't reach for it on the reverse side of a relation, it won't help there. And if your reason for using &lt;code&gt;.iterator()&lt;/code&gt; in the first place was a large table, check your query count after adding &lt;code&gt;fetch_mode&lt;/code&gt;, don't assume it's working just because the code runs without error.&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Docker Engine 29's default image store lets --storage-opt size fail silently</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Sun, 13 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/docker-engine-29s-default-image-store-lets-storage-opt-size-fail-silently-p65</link>
      <guid>https://dev.to/alexgeorgiev17/docker-engine-29s-default-image-store-lets-storage-opt-size-fail-silently-p65</guid>
      <description>&lt;p&gt;Docker Engine 29 switches new installs from the classic overlay2 graphdriver to the containerd snapshotter as the default image store. I wanted to know what actually changes when that flag flips, so I ran the same host, the same images, and the same flags against both backends and compared what came out.&lt;/p&gt;

&lt;p&gt;The interesting result wasn't a speed number, although I got one of those too. It was a container disk quota that stopped being enforced and didn't tell me.&lt;/p&gt;

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

&lt;p&gt;I have root on a fresh Docker 29.3.1 install (&lt;code&gt;overlay2&lt;/code&gt; root filesystem is plain ext4, no project quota mount option). I can flip the backend with a daemon flag and restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"features"&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;"containerd-snapshotter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker info&lt;/code&gt; confirms which one is active:&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;Storage Driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;overlayfs&lt;/span&gt;
  &lt;span class="s"&gt;driver-type&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;io.containerd.snapshotter.v1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;versus&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;Storage Driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;overlay2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding 1: the quota flag stops erroring, and stops working
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--storage-opt size=&amp;lt;limit&amp;gt;&lt;/code&gt; is supposed to cap a container's writable layer. It has a well-known precondition: it only works on overlay2 over XFS with the &lt;code&gt;pquota&lt;/code&gt; mount option. My ext4 root doesn't have that. Under the classic graphdriver, Docker knows this and refuses:&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;--rm&lt;/span&gt; &lt;span class="nt"&gt;--storage-opt&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M alpine:3.20 &lt;span class="nb"&gt;echo &lt;/span&gt;ok
&lt;span class="go"&gt;docker: Error response from daemon: --storage-opt is supported only for overlay over xfs with 'pquota' mount option
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 125, every time, three runs in a row. The container never starts. That's a correct, loud failure on a host that can't honour the option.&lt;/p&gt;

&lt;p&gt;Under the containerd snapshotter — the Docker 29 default — the exact same command and the exact same filesystem behave differently:&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;--rm&lt;/span&gt; &lt;span class="nt"&gt;--storage-opt&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M alpine:3.20 sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    "dd if=/dev/zero of=/bigfile bs=1M count=300;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; /bigfile&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;300+0 records in
300+0 records out
314572800 bytes (300.0MB) copied, 1.81 s, 165.5MB/s
-rw-r--r--  1 root root  300.0M Sep 13 05:06 /bigfile
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 0. I asked for a 100MB limit and wrote 300MB with no resistance, no warning, no log line. I repeated it three times to be sure it wasn't a one-off race:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;--storage-opt size=100M&lt;/code&gt;, write 300MB&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;overlay2 (graphdriver)&lt;/td&gt;
&lt;td&gt;refuses to start&lt;/td&gt;
&lt;td&gt;125, ×3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;containerd snapshotter&lt;/td&gt;
&lt;td&gt;writes all 300MB&lt;/td&gt;
&lt;td&gt;0, ×3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It gets worse. &lt;code&gt;docker inspect&lt;/code&gt; still reports the option as if it applied:&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 inspect quotatest &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.HostConfig.StorageOpt}}'&lt;/span&gt;
&lt;span class="go"&gt;map[size:100M]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have anything that checks container config to confirm a quota is set — a compliance script, an audit tool, a human reading &lt;code&gt;docker inspect&lt;/code&gt; output — it will tell you the limit is in place. Nothing enforces it. This is the kind of thing that looks fine in every dashboard until a runaway container fills the disk that three other containers are also writing to.&lt;/p&gt;

&lt;p&gt;I don't think this is a deliberate design choice so much as an unimplemented check: the containerd image store path doesn't currently validate that the backing filesystem supports the quota before accepting the option, where the graphdriver path does. Whatever the cause, the behaviour changed under a default flip, and nothing in the CLI tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: pulls are genuinely faster, independent of why
&lt;/h2&gt;

&lt;p&gt;I pulled &lt;code&gt;node:22&lt;/code&gt; (8 layers, 1.64GB unpacked) from a clean state, alternating backends, always removing the local image first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;th&gt;Run 1&lt;/th&gt;
&lt;th&gt;Run 2&lt;/th&gt;
&lt;th&gt;Run 3&lt;/th&gt;
&lt;th&gt;Average&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;containerd snapshotter&lt;/td&gt;
&lt;td&gt;19.9s&lt;/td&gt;
&lt;td&gt;17.7s&lt;/td&gt;
&lt;td&gt;16.4s&lt;/td&gt;
&lt;td&gt;18.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;overlay2 (graphdriver)&lt;/td&gt;
&lt;td&gt;28.9s&lt;/td&gt;
&lt;td&gt;28.5s&lt;/td&gt;
&lt;td&gt;28.1s&lt;/td&gt;
&lt;td&gt;28.5s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's roughly 37% faster on the new default, consistently, and the graphdriver runs happened later in my test sequence, so it isn't warm-cache bias in the snapshotter's favour — if anything a registry or proxy cache would have made the later runs faster, and they were the slow ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the concurrency knob barely matters, which wasn't what I expected
&lt;/h2&gt;

&lt;p&gt;Docker 29's own release notes mention a bug, fixed in 29.7.0, where concurrent pull limits weren't honoured on the snapshotter path. I'm running 29.3.1, so I expected to see it: set &lt;code&gt;max-concurrent-downloads&lt;/code&gt; to 1 and watch pulls slow down a lot more under the snapshotter than under the graphdriver.&lt;/p&gt;

&lt;p&gt;That's not what happened. Setting the limit to 1 versus 8 moved the snapshotter average from 18.0s to 15.0s — about 17%. Under the graphdriver it moved from 28.5s to 27.5s — about 3.5%, close to noise:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;th&gt;max-concurrent-downloads=1&lt;/th&gt;
&lt;th&gt;max-concurrent-downloads=8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;containerd snapshotter&lt;/td&gt;
&lt;td&gt;18.0s&lt;/td&gt;
&lt;td&gt;15.0s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;overlay2 (graphdriver)&lt;/td&gt;
&lt;td&gt;28.5s&lt;/td&gt;
&lt;td&gt;27.5s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither backend shows the dramatic serialization I was looking for. My conclusion, from these numbers, is that unpacking the layers into the filesystem dominates the wall-clock time for a pull far more than the number of concurrent HTTP downloads does, at least on this network path. The backend-versus-backend gap in finding 2 is real and reproducible; the concurrency-setting gap inside either backend is not where the time goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: the new mount type does what it says, mostly
&lt;/h2&gt;

&lt;p&gt;Docker 29 also graduates &lt;code&gt;--mount type=image&lt;/code&gt; out of experimental status — in 29.7.0, according to the release notes. On 29.3.1 it's still flagged:&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;--rm&lt;/span&gt; &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;image,source&lt;span class="o"&gt;=&lt;/span&gt;alpine:3.20,target&lt;span class="o"&gt;=&lt;/span&gt;/mnt &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    busybox:latest ls /mnt
WARNING: Image mount is an experimental feature
bin  dev  etc  home  lib  ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It correctly refuses writes:&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;--rm&lt;/span&gt; &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;image,source&lt;span class="o"&gt;=&lt;/span&gt;alpine:3.20,target&lt;span class="o"&gt;=&lt;/span&gt;/mnt &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    busybox:latest touch /mnt/testfile
touch: /mnt/testfile: Read-only file system
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit 1, as it should be — this is the one place where the new feature enforced a restriction I expected and the quota flag didn't. It also fails cleanly on a bad source image (&lt;code&gt;No such image&lt;/code&gt;, exit 125) and on a missing target (&lt;code&gt;field Target must not be empty&lt;/code&gt;). What I couldn't get working on this build was a subpath option to mount only part of an image; every spelling I tried (&lt;code&gt;subpath=&lt;/code&gt;, &lt;code&gt;src.subpath=&lt;/code&gt;) came back as an unrecognised option. That may simply not exist yet in 29.3.1's experimental implementation — I'm reporting what this specific version accepts, not what later docs promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: dedup accounting still works
&lt;/h2&gt;

&lt;p&gt;One thing I checked because I assumed it might have regressed, and it hadn't: &lt;code&gt;docker system df -v&lt;/code&gt; still correctly attributes shared base layers under the snapshotter. &lt;code&gt;python:3.12-slim&lt;/code&gt; and &lt;code&gt;python:3.13-slim&lt;/code&gt; share a Debian base:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REPOSITORY   TAG         SIZE     SHARED SIZE   UNIQUE SIZE
python       3.12-slim   190MB    87.45MB       102.9MB
python       3.13-slim   189MB    87.45MB       101.7MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;87.45MB shared, matching on both images, which is what you'd want to see if you're trying to estimate real disk cost from a fleet of related images. This is the one finding in this post that's a non-finding — I looked for a problem and didn't find one.&lt;/p&gt;

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

&lt;p&gt;I set out to reproduce the "concurrent download limits not honoured" bug mentioned in the 29.7.0 changelog, expecting my &lt;code&gt;max-concurrent-downloads=1&lt;/code&gt; runs to be dramatically slower than &lt;code&gt;=8&lt;/code&gt; on the snapshotter. When the difference came out small on both backends, my first instinct was that my harness was broken — maybe the proxy in front of my registry was capping throughput regardless of client-side concurrency, hiding the effect. I re-ran with a completely fresh image (&lt;code&gt;python:3.13-slim&lt;/code&gt;, untouched by earlier tests) to rule out any registry-side caching of my specific pull pattern, and got the same shape of result. The actual finding wasn't the one I went looking for: the backend swap (finding 2) explains far more of the wall-clock time than the concurrency setting does (finding 3). I'd rather report that than force a bug I couldn't actually reproduce on this version.&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;&lt;span class="c"&gt;# check which image store is active&lt;/span&gt;
docker info | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"storage driver"&lt;/span&gt;

&lt;span class="c"&gt;# flip to the classic graphdriver (needs root, restarts the daemon)&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="nb"&gt;sudo tee&lt;/span&gt; /etc/docker/daemon.json
&lt;span class="nb"&gt;sudo &lt;/span&gt;pkill dockerd &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;dockerd &amp;amp;

&lt;span class="c"&gt;# the quota test — compare exit codes between backends&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--storage-opt&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M alpine:3.20 &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"dd if=/dev/zero of=/bigfile bs=1M count=300; echo EXIT=&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;?"&lt;/span&gt;

&lt;span class="c"&gt;# flip back to the containerd snapshotter (Docker 29 default)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"features": {"containerd-snapshotter": true}}'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/docker/daemon.json
&lt;span class="nb"&gt;sudo &lt;/span&gt;pkill dockerd &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;dockerd &amp;amp;

&lt;span class="c"&gt;# pull timing, repeat 3x per backend, always from a clean state&lt;/span&gt;
docker rmi &lt;span class="nt"&gt;-f&lt;/span&gt; node:22
&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; docker pull node:22 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s.%N&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | bc

&lt;span class="c"&gt;# the read-only mount check&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--mount&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;image,source&lt;span class="o"&gt;=&lt;/span&gt;alpine:3.20,target&lt;span class="o"&gt;=&lt;/span&gt;/mnt &lt;span class="se"&gt;\&lt;/span&gt;
  busybox:latest &lt;span class="nb"&gt;touch&lt;/span&gt; /mnt/testfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're running Docker 29 on ext4 or any filesystem without project quota support, and you rely on &lt;code&gt;--storage-opt size&lt;/code&gt; anywhere — a shared build host, a CI runner, a multi-tenant container platform — go check which image store you're actually running, and don't trust &lt;code&gt;docker inspect&lt;/code&gt; to tell you whether the limit is real. Run the write test above against your own host. It takes ten seconds and it either fills up or it doesn't.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>performance</category>
      <category>containers</category>
    </item>
    <item>
      <title>Nile isolates Postgres tenants with a tenant_id column and one session variable</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Sat, 12 Sep 2026 08:32:08 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/nile-isolates-postgres-tenants-with-a-tenantid-column-and-one-session-variable-4e3l</link>
      <guid>https://dev.to/alexgeorgiev17/nile-isolates-postgres-tenants-with-a-tenantid-column-and-one-session-variable-4e3l</guid>
      <description>&lt;p&gt;Disclosure: I have no affiliation with Nile. Nobody asked me to write this and nobody paid for it. I signed up for their free tier like anyone else would. I'm on my way to test products and share my findings with the community just in case someone wondered how will this perform and etc.&lt;/p&gt;

&lt;p&gt;I created two tenants, put a row in each, then ran the same query without telling the connection which tenant it was. It returned both rows. Turns out that's intentional, documented behaviour, not a bug I'd stumbled onto — but it raised a question worth asking if you're building on this: what actually guarantees your app sets that context every time?&lt;/p&gt;

&lt;h2&gt;
  
  
  What Nile is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.thenile.dev" rel="noopener noreferrer"&gt;Nile&lt;/a&gt; is Postgres reengineered for multi-tenant B2B apps: instead of managing one database per customer, you mark a table as tenant-aware and Nile handles isolating each tenant's rows underneath a single connection string. It's an $11.6M seed company (Benchmark, January 2024, board seat taken by Eric Vishria), founded by Sriram Subramanian and Gwen Shapira, and it's had zero dev.to coverage despite that.&lt;/p&gt;

&lt;p&gt;A tenant-aware table needs one thing: a &lt;code&gt;tenant_id uuid&lt;/code&gt; column.&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;todos&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&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;title&lt;/span&gt; &lt;span class="nb"&gt;varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;complete&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&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;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Isolation is a session variable:&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;SET&lt;/span&gt; &lt;span class="n"&gt;nile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'11111111-1111-1111-1111-111111111111'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where the isolation actually lives
&lt;/h2&gt;

&lt;p&gt;I created two tenants, set the session to tenant A, inserted two rows, switched to tenant B, inserted one row. Querying as each tenant worked exactly as advertised:&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="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt;
              &lt;span class="n"&gt;tenant_id&lt;/span&gt;               &lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="c1"&gt;--------------------------------------+-------------&lt;/span&gt;
 &lt;span class="mi"&gt;11111111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;111111111111&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Acme&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
 &lt;span class="mi"&gt;11111111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;111111111111&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Acme&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

&lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt;
              &lt;span class="n"&gt;tenant_id&lt;/span&gt;               &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="c1"&gt;--------------------------------------+---------------&lt;/span&gt;
 &lt;span class="mi"&gt;22222222&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2222&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2222&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2222&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;222222222222&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I ran the identical &lt;code&gt;SELECT tenant_id, title FROM todos;&lt;/code&gt; on a fresh connection, with &lt;code&gt;nile.tenant_id&lt;/code&gt; never set at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;              &lt;span class="n"&gt;tenant_id&lt;/span&gt;               &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="c1"&gt;--------------------------------------+---------------&lt;/span&gt;
 &lt;span class="mi"&gt;11111111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;111111111111&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Acme&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
 &lt;span class="mi"&gt;11111111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1111&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;111111111111&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Acme&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
 &lt;span class="mi"&gt;22222222&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2222&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2222&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2222&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;222222222222&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three rows, both tenants. Nile's own docs confirm this is deliberate: without a tenant context set, a connection can read across all tenants, by design, presumably so admin and migration connections aren't locked out. So the isolation lives in the session rather than the table itself. The open question, for anyone building a pooled-connection app on top of this, is which layer is responsible for making sure &lt;code&gt;SET nile.tenant_id&lt;/code&gt; gets called on every request. Worth checking early rather than assuming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens if a write's tenant doesn't match the session
&lt;/h2&gt;

&lt;p&gt;Naturally I tried inserting a row for tenant B while the session was set to tenant A:&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;SET&lt;/span&gt; &lt;span class="n"&gt;nile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'11111111-1111-1111-1111-111111111111'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'22222222-2222-2222-2222-222222222222'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Smuggled into Widget'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  Multiple tenant IDs specified in write query
DETAIL:  Writes to tenant-aware tables must specify exactly one tenant ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rejected outright, with a clear error naming the problem. Good sign — the write path actively checks the session's tenant against the row's tenant.&lt;/p&gt;

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

&lt;p&gt;The &lt;code&gt;tenants&lt;/code&gt; table itself is tenant-aware on its own &lt;code&gt;id&lt;/code&gt; column, and that column has a rule I didn't expect: it has to be a literal or a bind parameter, not an expression.&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;tenants&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'Acme Corp'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  cannot determine tenant ID. Tenant ID must be a constant or a parameter reference (i.e: $1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A literal UUID works fine; you just can't compute it inline. And deleting from &lt;code&gt;tenants&lt;/code&gt; only accepts one shape of &lt;code&gt;WHERE&lt;/code&gt; clause:&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;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tenants&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Acme Corp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  DELETE operations on the tenants table have to provide a 'id'=tenant_id condition. Further conditions are not supported.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WHERE id = '...'&lt;/code&gt; is the only accepted filter. I hit both by trial and error, not from anything in the docs I'd read going in.&lt;/p&gt;

&lt;h2&gt;
  
  
  EXPLAIN doesn't pass through
&lt;/h2&gt;

&lt;p&gt;Every connection to Nile goes through a proxy layer, and I found one command it doesn't forward:&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;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  command tag EXPLAIN unhandled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rules out the usual first move for debugging a slow query. It also left me curious about something an earlier error had already hinted at: a constraint-violation message named a physical relation &lt;code&gt;todos_200149e&lt;/code&gt;, which doesn't show up in &lt;code&gt;pg_class&lt;/code&gt; or &lt;code&gt;pg_tables&lt;/code&gt; from this connection. So &lt;code&gt;todos&lt;/code&gt; isn't stored as one plain table underneath — I just couldn't see the plan or the structure to say more than that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get for it
&lt;/h2&gt;

&lt;p&gt;The one thing that's unambiguously generous: &lt;code&gt;\dx&lt;/code&gt; on a fresh free-tier database lists 35 extensions already installed beyond the standard &lt;code&gt;plpgsql&lt;/code&gt; every Postgres ships with, including &lt;code&gt;vector&lt;/code&gt; and &lt;code&gt;vectorscale&lt;/code&gt; (DiskANN) for embeddings, &lt;code&gt;postgis&lt;/code&gt; for geospatial, &lt;code&gt;pg_trgm&lt;/code&gt; and &lt;code&gt;pg_bigm&lt;/code&gt; for text search, and less common ones like &lt;code&gt;h3&lt;/code&gt; and &lt;code&gt;financial&lt;/code&gt;. On a stock RDS Postgres instance you'd be enabling most of these one at a time and, for some, not have the option at all.&lt;/p&gt;

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

&lt;p&gt;My first attempt at inserting a row assumed &lt;code&gt;SET nile.tenant_id&lt;/code&gt; would populate the column for me:&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;SET&lt;/span&gt; &lt;span class="n"&gt;nile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'11111111-1111-1111-1111-111111111111'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Acme task 1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  null value in column "tenant_id" of relation "todos_200149e" violates not-null constraint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't. The session variable scopes what you can read and enforces what you're allowed to write, but you still have to supply &lt;code&gt;tenant_id&lt;/code&gt; explicitly on every insert. That error message is also where the hidden relation name first showed up.&lt;/p&gt;

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

&lt;p&gt;Free tier, no credit card: sign up at &lt;a href="https://console.thenile.dev" rel="noopener noreferrer"&gt;console.thenile.dev&lt;/a&gt;, create a database, and grab the connection string from Settings → Connection. A &lt;code&gt;tenants&lt;/code&gt; table already exists in every new Nile database, so the &lt;code&gt;INSERT&lt;/code&gt;s below work immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NILE_CONNECTION_STRING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &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;SQL&lt;/span&gt;&lt;span class="sh"&gt;'
CREATE TABLE todos (
  id uuid DEFAULT gen_random_uuid(),
  tenant_id uuid,
  title varchar(256),
  complete boolean,
  PRIMARY KEY (tenant_id, id)
);

INSERT INTO tenants (id, name) VALUES ('11111111-1111-1111-1111-111111111111', 'Acme Corp');
INSERT INTO tenants (id, name) VALUES ('22222222-2222-2222-2222-222222222222', 'Widget LLC');

SET nile.tenant_id = '11111111-1111-1111-1111-111111111111';
INSERT INTO todos (tenant_id, title, complete) VALUES ('11111111-1111-1111-1111-111111111111', 'Acme task 1', false);

SET nile.tenant_id = '22222222-2222-2222-2222-222222222222';
INSERT INTO todos (tenant_id, title, complete) VALUES ('22222222-2222-2222-2222-222222222222', 'Widget task 1', false);
&lt;/span&gt;&lt;span class="no"&gt;SQL

&lt;/span&gt;&lt;span class="c"&gt;# now compare a scoped read against an unscoped one&lt;/span&gt;
psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NILE_CONNECTION_STRING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SET nile.tenant_id = '11111111-1111-1111-1111-111111111111'; SELECT tenant_id, title FROM todos;"&lt;/span&gt;
psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NILE_CONNECTION_STRING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT tenant_id, title FROM todos;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're evaluating Nile for a real app, the question worth answering early is which layer of your stack is responsible for calling &lt;code&gt;SET nile.tenant_id&lt;/code&gt; on every pooled connection before it touches tenant data. That's a question about your own architecture as much as theirs.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>security</category>
      <category>saas</category>
    </item>
    <item>
      <title>Bun 1.4's Rust rewrite cuts script startup time from 10.5ms to 4.2ms</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Sat, 12 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/bun-14s-rust-rewrite-cuts-script-startup-time-from-105ms-to-42ms-2je0</link>
      <guid>https://dev.to/alexgeorgiev17/bun-14s-rust-rewrite-cuts-script-startup-time-from-105ms-to-42ms-2je0</guid>
      <description>&lt;p&gt;Bun 1.4 shipped on 20 August as the first stable release built on Bun's new Rust-based core, replacing the Zig runtime layer that shipped everything since 1.0. The release notes claim faster startup, less idle CPU, and lower memory. I installed the last pre-rewrite build and the new one side by side and measured all three, plus what happens when you point a &lt;code&gt;node&lt;/code&gt; symlink at the new binary, which is how a lot of people actually run Bun in production.&lt;/p&gt;

&lt;p&gt;I put Bun 1.3.14 in &lt;code&gt;~/.bun-old&lt;/code&gt; and 1.4.2 in &lt;code&gt;~/.bun-new&lt;/code&gt; using the official install script with a pinned version tag, so both binaries sit on the same machine and I can call either one directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Startup time
&lt;/h2&gt;

&lt;p&gt;My first pass measured &lt;code&gt;bun --version&lt;/code&gt; twenty times per binary. The difference was almost nothing: 2.24ms median for 1.3.14, 2.00ms for 1.4.2. That command exits before it initialises the JS engine, so it wasn't measuring what the release notes were talking about. Once I switched to actually running a script, the gap showed up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty runs of &lt;code&gt;bun run hello.js&lt;/code&gt; each, timed with &lt;code&gt;time.perf_counter()&lt;/code&gt; around a &lt;code&gt;subprocess.run&lt;/code&gt; call:&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;median&lt;/th&gt;
&lt;th&gt;min&lt;/th&gt;
&lt;th&gt;max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.3.14&lt;/td&gt;
&lt;td&gt;10.54ms&lt;/td&gt;
&lt;td&gt;9.32ms&lt;/td&gt;
&lt;td&gt;18.00ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.4.2&lt;/td&gt;
&lt;td&gt;4.22ms&lt;/td&gt;
&lt;td&gt;3.90ms&lt;/td&gt;
&lt;td&gt;5.25ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a 60% drop in median startup time, better than the "50% faster on Linux" the release notes claim. The old binary also had a much longer tail (up to 18ms), while the new one stayed inside a 1.4ms band across all 30 runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idle memory
&lt;/h2&gt;

&lt;p&gt;I ran a two-line &lt;code&gt;Bun.serve&lt;/code&gt; echo server on each version, hit it once to warm it up, then read &lt;code&gt;VmRSS&lt;/code&gt; from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; after a further 60 seconds of doing nothing. Three runs each:&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;RSS after warmup&lt;/th&gt;
&lt;th&gt;RSS after 60s idle&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.3.14 (run 1)&lt;/td&gt;
&lt;td&gt;34,876 KB&lt;/td&gt;
&lt;td&gt;34,960 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.3.14 (run 2)&lt;/td&gt;
&lt;td&gt;34,924 KB&lt;/td&gt;
&lt;td&gt;34,928 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.3.14 (run 3)&lt;/td&gt;
&lt;td&gt;34,872 KB&lt;/td&gt;
&lt;td&gt;34,936 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.4.2 (run 1)&lt;/td&gt;
&lt;td&gt;18,864 KB&lt;/td&gt;
&lt;td&gt;18,920 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.4.2 (run 2)&lt;/td&gt;
&lt;td&gt;19,068 KB&lt;/td&gt;
&lt;td&gt;19,136 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.4.2 (run 3)&lt;/td&gt;
&lt;td&gt;19,056 KB&lt;/td&gt;
&lt;td&gt;19,144 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Averaging the idle figures: 34.9MB down to 19.0MB, a 46% reduction. The release notes say "up to 35%", so on this workload it beat its own number, though a bare echo server is close to the best case for a claim like this — there's no application memory to dwarf the runtime's own footprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idle CPU: this is where the claim didn't hold up
&lt;/h2&gt;

&lt;p&gt;The release notes claim 5x less idle CPU. This is the one I'd flag as not backed by what I measured.&lt;/p&gt;

&lt;p&gt;I sampled &lt;code&gt;utime + stime&lt;/code&gt; from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/stat&lt;/code&gt; before and after a 60-second idle window, three times per version (100 clock ticks per second, so each tick is 10ms of CPU 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;1.3.14 ticks/60s&lt;/th&gt;
&lt;th&gt;1.4.2 ticks/60s&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;11&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean&lt;/td&gt;
&lt;td&gt;10.67&lt;/td&gt;
&lt;td&gt;4.33&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's roughly 0.178% average CPU for 1.3.14 against 0.072% for 1.4.2 — a real improvement, and in the right direction, but a 2.5x reduction, not 5x. Both numbers are small enough that a single bad sample would swing the ratio a lot, which is exactly why I went from a 5-second window (0 to 2 ticks total, useless) to 60 seconds before trusting any of it. It's possible Bun's 5x figure comes from a different idle workload — something with an active file watcher or timer loop rather than a bare &lt;code&gt;Bun.serve&lt;/code&gt; with no traffic — but on the simplest possible idle server, I got 2.5x.&lt;/p&gt;

&lt;h2&gt;
  
  
  Throughput under load
&lt;/h2&gt;

&lt;p&gt;The release notes don't claim a throughput number, so I measured one anyway, since idle numbers only tell you about servers doing nothing. I ran &lt;code&gt;autocannon -c 50 -d 8&lt;/code&gt; against the same echo server, three runs per version:&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;1.3.14 avg req/s&lt;/th&gt;
&lt;th&gt;1.4.2 avg req/s&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;29,456&lt;/td&gt;
&lt;td&gt;62,310&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;29,578&lt;/td&gt;
&lt;td&gt;61,998&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;28,042&lt;/td&gt;
&lt;td&gt;62,922&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean&lt;/td&gt;
&lt;td&gt;29,025&lt;/td&gt;
&lt;td&gt;62,410&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 2.15x more requests per second at 50 concurrent connections, and p50 latency dropped from 1ms to sub-millisecond in autocannon's output. This one held up better under concurrency than at idle, which is the opposite of what I expected going in — I assumed a rewrite aimed at idle efficiency would show its biggest gains with nothing happening, not under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regression: invoking Bun as &lt;code&gt;node&lt;/code&gt; silently drops your &lt;code&gt;.env&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Bun positions itself as a drop-in replacement for Node, and one common way people use that is symlinking a &lt;code&gt;node&lt;/code&gt; binary to &lt;code&gt;bun&lt;/code&gt; so existing tooling picks it up without changes. I tested exactly that setup.&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/nodebin &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /root/.bun-old/bin/bun /tmp/nodebin/node
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'MY_SECRET=hello123'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/tmp/nodebin/node check.js
&lt;span class="go"&gt;MY_SECRET=hello123
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 1.3.14, invoked through a symlink literally named &lt;code&gt;node&lt;/code&gt;. It loads &lt;code&gt;.env&lt;/code&gt; as expected. Now the same setup against 1.4.2:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/nodebin &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /root/.bun-new/bin/bun /tmp/nodebin/node
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/tmp/nodebin/node check.js
&lt;span class="go"&gt;MY_SECRET=undefined
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error, no warning, exit code 0. The process just runs with &lt;code&gt;MY_SECRET&lt;/code&gt; undefined. I checked whether this was specific to the symlink trick or a wider regression: plain &lt;code&gt;bun run check.js&lt;/code&gt; and &lt;code&gt;bun --bun run check.js&lt;/code&gt; both still load &lt;code&gt;.env&lt;/code&gt; correctly on 1.4.2. It's specifically the "binary is named &lt;code&gt;node&lt;/code&gt;" detection path that stopped auto-loading environment files.&lt;/p&gt;

&lt;p&gt;This matters because "point a node symlink at Bun" is a documented migration path, not an edge case I invented. Anyone running Bun that way in a container image or CI pipeline, expecting Node-compatible behaviour, will get a process that starts cleanly and silently runs with missing configuration. The workaround exists and works:&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;/tmp/nodebin/node &lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env check.js
&lt;span class="go"&gt;MY_SECRET=hello123
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But you have to know to add it. Nothing in the normal run output tells you your &lt;code&gt;.env&lt;/code&gt; was skipped.&lt;/p&gt;

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

&lt;p&gt;I nearly wrote off the startup claim entirely after my first benchmark. &lt;code&gt;bun --version&lt;/code&gt; is a fast path that never spins up the JS engine, so it wasn't exercising the thing the release notes described, and it showed almost no difference between versions. I only found the real 60% gap after switching to a script that actually gets parsed and run. If a "no difference" result comes out of the first thing you try, it's worth asking whether you measured the feature or just the binary's exit path.&lt;/p&gt;

&lt;p&gt;The same thing happened with idle CPU: my first sampling window was 5 seconds, which produced 0-2 clock ticks total per run — not enough resolution to say anything. Stretching the window to 60 seconds is what made the numbers usable, and it's also what exposed that the claimed 5x doesn't hold at this scale.&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;&lt;span class="c"&gt;# install both versions side by side&lt;/span&gt;
&lt;span class="nv"&gt;BUN_INSTALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.bun-old bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"'&lt;/span&gt;
&lt;span class="nv"&gt;BUN_INSTALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.bun-new bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -fsSL https://bun.sh/install | bash'&lt;/span&gt;

&lt;span class="c"&gt;# startup timing&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'console.log("hi")'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; hello.js
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import subprocess, time, statistics
for label, binpath in [('old', '&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.bun-old/bin/bun'), ('new', '&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.bun-new/bin/bun')]:
    times = []
    for _ in range(30):
        t0 = time.perf_counter()
        subprocess.run([binpath, 'run', 'hello.js'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        times.append((time.perf_counter() - t0) * 1000)
    times.sort()
    print(label, 'median', round(times[15], 2), 'ms')
"&lt;/span&gt;

&lt;span class="c"&gt;# the node-symlink .env regression&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /tmp/nodebin &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sf&lt;/span&gt; ~/.bun-new/bin/bun /tmp/nodebin/node
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'MY_SECRET=hello123'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'console.log("MY_SECRET=" + process.env.MY_SECRET)'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; check.js
/tmp/nodebin/node check.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run Bun 1.4 through anything that presents it as &lt;code&gt;node&lt;/code&gt; — a container base image, an nvm-style shim, a CI runner that symlinks a runtime — go and check one thing before you rely on it: put a throwaway variable in your &lt;code&gt;.env&lt;/code&gt;, run your actual entrypoint through that symlink, and print the variable back out. If it comes back &lt;code&gt;undefined&lt;/code&gt;, you've found the same gap I found, and &lt;code&gt;--env-file&lt;/code&gt; on your start command is the fix. The startup and memory gains are worth having either way; I'd rather find out about the missing config here than from a support ticket.&lt;/p&gt;

</description>
      <category>node</category>
      <category>performance</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Built anything cool with AI lately?</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:42:08 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/built-anything-cool-with-ai-lately-3djb</link>
      <guid>https://dev.to/alexgeorgiev17/built-anything-cool-with-ai-lately-3djb</guid>
      <description>&lt;p&gt;I train a lot, and with AI's help I built a small app that pulls my Garmin and Strava data and helps me structure my training around it.&lt;/p&gt;

&lt;p&gt;Made me curious what else people are building. What's something you've put together recently with AI's help, big or small?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>community</category>
    </item>
    <item>
      <title>Valkey 9.1's hash field TTL triples memory use for the pattern its own docs show</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/valkey-91s-hash-field-ttl-triples-memory-use-for-the-pattern-its-own-docs-show-22k8</link>
      <guid>https://dev.to/alexgeorgiev17/valkey-91s-hash-field-ttl-triples-memory-use-for-the-pattern-its-own-docs-show-22k8</guid>
      <description>&lt;p&gt;Valkey 9.0 added the ability to put a TTL on a single field inside a hash, rather than on the whole key. Valkey 9.1 followed up with &lt;code&gt;HSETEX&lt;/code&gt;, which sets a field and its expiry in one round trip. The example in Valkey's own blog post is a per-user auth token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HSETEX user:123 EX 900 FIELDS 1 auth_token "eyJhbGciOiJ..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads like a drop-in replacement for &lt;code&gt;SET user:123:auth_token "..." EX 900&lt;/code&gt;. I ran both patterns against 20,000 keys and measured memory with &lt;code&gt;INFO memory&lt;/code&gt;. The hash version used three times as much RAM as the plain string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;I ran Valkey 9.1.2 in Docker on a single host, nothing exotic:&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 run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; valkey9 &lt;span class="nt"&gt;-p&lt;/span&gt; 6399:6379 valkey/valkey:9.1
&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;valkey9 valkey-server &lt;span class="nt"&gt;--version&lt;/span&gt;
Valkey server &lt;span class="nv"&gt;v&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9.1.2 &lt;span class="nv"&gt;sha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;00000000:0 &lt;span class="nv"&gt;malloc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;jemalloc-5.3.0 &lt;span class="nv"&gt;bits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All measurements below are the fastest of three runs unless noted, using &lt;code&gt;redis-py&lt;/code&gt; 8.1.0 talking to the container over the exposed port. For the &lt;code&gt;valkey-cli&lt;/code&gt; snippets I aliased &lt;code&gt;valkey-cli&lt;/code&gt; to &lt;code&gt;docker exec valkey9 valkey-cli&lt;/code&gt; so the commands read the same as they would against a local install.&lt;/p&gt;

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

&lt;p&gt;I built three versions of "one item, one expiring value" for 20,000 items: a hash per item with one field carrying a TTL (the pattern in the docs), a hash per item with no TTL, and a plain string key with &lt;code&gt;EXPIRE&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;Pattern&lt;/th&gt;
&lt;th&gt;Bytes used&lt;/th&gt;
&lt;th&gt;Bytes / key&lt;/th&gt;
&lt;th&gt;Encoding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hash per item, field TTL set (&lt;code&gt;HSETEX&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;5,226,560&lt;/td&gt;
&lt;td&gt;261.33&lt;/td&gt;
&lt;td&gt;hashtable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash per item, no TTL (&lt;code&gt;HSET&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;1,754,720&lt;/td&gt;
&lt;td&gt;87.74&lt;/td&gt;
&lt;td&gt;listpack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain string + &lt;code&gt;EXPIRE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,733,680&lt;/td&gt;
&lt;td&gt;86.68&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction (2026-09-12):&lt;/strong&gt; "Almost exactly the same" overstates it. A reader added the missing control, a hash forced into hashtable with &lt;code&gt;CONFIG SET hash-max-listpack-entries 0&lt;/code&gt; but no TTL, which isolates the encoding switch from the TTL metadata. That put the encoding conversion at about 82 percent of the regression, with the remaining ~18 percent being the TTL bookkeeping itself. I reproduced their numbers independently on a fresh Valkey 9.1.2 instance and got the same split.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Giving a single field a TTL costs almost exactly the same extra memory as switching the whole hash's encoding from &lt;code&gt;listpack&lt;/code&gt; to &lt;code&gt;hashtable&lt;/code&gt;. It is not a small tax on top of the hash: it roughly triples it, and it lands you back at the same memory cost as the string-based pattern the feature was meant to improve on, except worse, because now you are also paying the hash's own bookkeeping.&lt;/p&gt;

&lt;p&gt;I checked encoding directly to confirm this was the 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;valkey-cli DEL h3
&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli HSET h3 x 1
&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli OBJECT ENCODING h3
listpack
&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli HEXPIRE h3 100 FIELDS 1 x
&lt;span class="o"&gt;(&lt;/span&gt;integer&lt;span class="o"&gt;)&lt;/span&gt; 1
&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli OBJECT ENCODING h3
hashtable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hash-max-listpack-entries&lt;/code&gt; was still the default 512 the whole time. It made no difference: the very first field TTL set on a hash forces it out of &lt;code&gt;listpack&lt;/code&gt; and into &lt;code&gt;hashtable&lt;/code&gt;, regardless of how few fields it has or how small they are. This is a known, tracked gap: &lt;a href="https://github.com/valkey-io/valkey/issues/2618" rel="noopener noreferrer"&gt;Valkey issue #2618&lt;/a&gt; describes exactly this and proposes encoding the expiry inside the listpack itself for small hashes, but as of 9.1.2 that has not landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that actually works
&lt;/h2&gt;

&lt;p&gt;The docs' one-hash-per-item example is the wrong shape for this feature. It is meant for hashes that already hold several fields, where only some carry a TTL, not for a hash that exists solely to wrap one expiring value.&lt;/p&gt;

&lt;p&gt;I tested that shape instead: one hash, 20,000 fields, each field set through &lt;code&gt;HSETEX&lt;/code&gt; with its own TTL, against the same hash with the same 20,000 fields set without any TTL.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Bytes used&lt;/th&gt;
&lt;th&gt;Bytes / field&lt;/th&gt;
&lt;th&gt;Encoding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One hash, 20,000 fields, each with a TTL&lt;/td&gt;
&lt;td&gt;1,563,728&lt;/td&gt;
&lt;td&gt;78.19&lt;/td&gt;
&lt;td&gt;hashtable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One hash, 20,000 fields, no TTL&lt;/td&gt;
&lt;td&gt;1,095,056&lt;/td&gt;
&lt;td&gt;54.75&lt;/td&gt;
&lt;td&gt;hashtable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both end up &lt;code&gt;hashtable&lt;/code&gt;-encoded regardless, because 20,000 fields is well past the 512-entry listpack ceiling anyway. The difference here, 23.44 bytes per field, is the actual cost of tracking an expiry once you are already paying for &lt;code&gt;hashtable&lt;/code&gt; encoding. That lines up with the 16-to-29-byte range the Valkey maintainers cite in the same GitHub issue. Used this way, the feature does what it says: cheap per-field TTLs on a shared structure.&lt;/p&gt;

&lt;p&gt;The lesson is really about encoding, not about the TTL feature being expensive. If your hash already has enough fields to sit in &lt;code&gt;hashtable&lt;/code&gt; encoding, field TTLs are close to free. If your hash is small enough to want &lt;code&gt;listpack&lt;/code&gt;, adding a single TTL field takes that away from you completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reads are not slower
&lt;/h2&gt;

&lt;p&gt;Valkey's blog claims the tracking structure behind field TTLs does not degrade normal hash operations. I benchmarked plain &lt;code&gt;HGET&lt;/code&gt; against a 5,000-field hash where no field had a TTL, then against an identical hash where every field did:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hash&lt;/th&gt;
&lt;th&gt;HGET throughput&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No TTL on any field&lt;/td&gt;
&lt;td&gt;4,885 ops/s&lt;/td&gt;
&lt;td&gt;204.7 us&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTL on every field&lt;/td&gt;
&lt;td&gt;4,889 ops/s&lt;/td&gt;
&lt;td&gt;204.6 us&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three runs each, same spread. No measurable difference. This is the one claim in the marketing that held up exactly as stated. The per-op latency here is dominated by the network round trip through Docker's port mapping, not by anything server-side, but the comparison is apples to apples and the two numbers are indistinguishable.&lt;/p&gt;

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

&lt;p&gt;A few error paths worth knowing before you hit them in production:&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;valkey-cli SET plainstring hello
OK
&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli HEXPIRE plainstring 100 FIELDS 1 f1
&lt;span class="o"&gt;(&lt;/span&gt;error&lt;span class="o"&gt;)&lt;/span&gt; WRONGTYPE Operation against a key holding the wrong kind of value

&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli HEXPIRE h5 &lt;span class="nt"&gt;-5&lt;/span&gt; FIELDS 1 a
&lt;span class="o"&gt;(&lt;/span&gt;error&lt;span class="o"&gt;)&lt;/span&gt; ERR invalid expire &lt;span class="nb"&gt;time &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s1"&gt;'hexpire'&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli HEXPIRE h5 100 NX XX FIELDS 1 a
&lt;span class="o"&gt;(&lt;/span&gt;error&lt;span class="o"&gt;)&lt;/span&gt; ERR NX and XX, GT or LT options at the same &lt;span class="nb"&gt;time &lt;/span&gt;are not compatible

&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli HEXPIRE h5 100 FIELDS 0
&lt;span class="o"&gt;(&lt;/span&gt;error&lt;span class="o"&gt;)&lt;/span&gt; ERR wrong number of arguments &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="s1"&gt;'hexpire'&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HEXPIRE&lt;/code&gt; on a field that does not exist, or on a hash that does not exist, returns &lt;code&gt;-2&lt;/code&gt; rather than an error, matching plain &lt;code&gt;TTL&lt;/code&gt;'s convention. &lt;code&gt;HEXPIRE ... GT&lt;/code&gt; against a field that has no TTL (infinite, by convention) is a documented no-op: it returns &lt;code&gt;0&lt;/code&gt; and leaves the field persistent, because "greater than infinite" can never be true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency does not change the picture
&lt;/h2&gt;

&lt;p&gt;I ran 10 threads, 5,000 &lt;code&gt;HSETEX&lt;/code&gt; calls each, pipelined in batches of 500, against three layouts: one hash key per thread, one shared hash key for all ten threads, and 5,000 independent string keys per thread with &lt;code&gt;SET ... EX&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;Layout&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One hash per thread&lt;/td&gt;
&lt;td&gt;64,800-68,900 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All threads on one shared hash&lt;/td&gt;
&lt;td&gt;61,700-68,000 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Independent string keys&lt;/td&gt;
&lt;td&gt;61,900-62,600 ops/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across three runs each, the numbers overlap within noise. I expected the single shared hash to show contention against the per-thread hashes. It did not, because Valkey executes commands on one thread regardless of how many client connections are pushing at it; there is no per-key lock to contend over in the first place. If you were worried that concentrating expiring fields into one big hash creates a hot-key bottleneck under load, this test did not find one at this scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Field TTLs survive a restart, and expire on schedule
&lt;/h2&gt;

&lt;p&gt;I set a field TTL, forced a background save, and restarted the container outright rather than trusting &lt;code&gt;DEBUG RELOAD&lt;/code&gt; (which this image disables by default under &lt;code&gt;enable-debug-command no&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;valkey-cli HTTL persisthash FIELDS 1 tok
&lt;span class="o"&gt;(&lt;/span&gt;integer&lt;span class="o"&gt;)&lt;/span&gt; 292
&lt;span class="nv"&gt;$ &lt;/span&gt;docker restart valkey9
&lt;span class="nv"&gt;$ &lt;/span&gt;valkey-cli HTTL persisthash FIELDS 1 tok
&lt;span class="o"&gt;(&lt;/span&gt;integer&lt;span class="o"&gt;)&lt;/span&gt; 288
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TTL survived the RDB round trip and kept counting down rather than resetting, which is the behaviour you want and not something I'd have bet on without checking.&lt;/p&gt;

&lt;p&gt;For timing, I set 2,000 fields in one hash to a 2-second TTL and polled &lt;code&gt;HLEN&lt;/code&gt; every 200ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t=2.01s hlen=2000
t=2.21s hlen=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 2,000 fields disappeared in the same 200ms polling window, not gradually. &lt;code&gt;INFO stats&lt;/code&gt; backed this up:&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="s"&gt;expired_fields:2000&lt;/span&gt;
&lt;span class="s"&gt;expire_cycle_cpu_milliseconds:3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the number to watch in production, not a guess I made. There is no &lt;code&gt;expired_subkeys&lt;/code&gt; stat, despite what you might expect from the naming used elsewhere in Redis-family docs; the counter is called &lt;code&gt;expired_fields&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;My first pass at measuring expiry precision checked &lt;code&gt;INFO stats&lt;/code&gt; for a field called &lt;code&gt;expired_subkeys&lt;/code&gt;, because that's the name I'd seen used informally when this feature was being discussed. It does not exist. &lt;code&gt;r.info('stats').get('expired_subkeys')&lt;/code&gt; silently returned &lt;code&gt;None&lt;/code&gt; both before and after 2,000 fields expired, and for a few minutes I nearly wrote down "no visible counter for field expiry" as a finding. The actual field is &lt;code&gt;expired_fields&lt;/code&gt;, sitting right there in the same &lt;code&gt;INFO stats&lt;/code&gt; block. Grepping the raw output instead of asking for one named key by guesswork caught it. It's a good reminder that a &lt;code&gt;None&lt;/code&gt; from a stats API is not evidence of absence, it's evidence you guessed the wrong key.&lt;/p&gt;

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

&lt;p&gt;Start Valkey 9.1 and confirm the encoding transition directly:&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;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; valkey9 &lt;span class="nt"&gt;-p&lt;/span&gt; 6399:6379 valkey/valkey:9.1
docker &lt;span class="nb"&gt;exec &lt;/span&gt;valkey9 valkey-cli HSET h a 1
docker &lt;span class="nb"&gt;exec &lt;/span&gt;valkey9 valkey-cli OBJECT ENCODING h        &lt;span class="c"&gt;# listpack&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;valkey9 valkey-cli HEXPIRE h 100 FIELDS 1 a
docker &lt;span class="nb"&gt;exec &lt;/span&gt;valkey9 valkey-cli OBJECT ENCODING h        &lt;span class="c"&gt;# hashtable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The memory comparison, using &lt;code&gt;redis-py&lt;/code&gt; (&lt;code&gt;pip install redis&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6399&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode_responses&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;used_mem&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;memory&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;used_memory&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flushall&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;used_mem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="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="n"&gt;N&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="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;HSETEX&lt;/span&gt;&lt;span class="sh"&gt;"&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;h:ttl:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EX&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;600&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;FIELDS&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;1&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;tok&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;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&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="nf"&gt;execute&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&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;hash-per-item w/ field TTL:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;used_mem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes/key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flushall&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;used_mem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="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="n"&gt;N&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="nf"&gt;set&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;s:ttl:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&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="nf"&gt;execute&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&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;string + EXPIRE:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;used_mem&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes/key&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;On my run this printed 262.4 bytes/key for the hash version against 85.6 for the string version, matching the table above within normal run-to-run variance.&lt;/p&gt;

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

&lt;p&gt;If you are storing one expiring value per logical item, the string-plus-&lt;code&gt;EXPIRE&lt;/code&gt; pattern Valkey has always had is still cheaper than the new hash field TTL commands, because a lone TTL field forces the whole hash into &lt;code&gt;hashtable&lt;/code&gt; encoding. Reach for &lt;code&gt;HEXPIRE&lt;/code&gt;/&lt;code&gt;HSETEX&lt;/code&gt; when you already have a multi-field hash and only some of its fields need to expire; that is the case where the per-field overhead is a genuinely small 16-to-29 bytes and the feature earns its keep. Before you migrate a per-user token cache to the pattern shown in the release notes, run the &lt;code&gt;OBJECT ENCODING&lt;/code&gt; check above against your own field sizes and counts, because the answer depends entirely on how many fields already live in that hash.&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>SQLite 3.53's self-healing index only repairs the rows you write to</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Thu, 10 Sep 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/sqlite-353s-self-healing-index-only-repairs-the-rows-you-write-to-ni</link>
      <guid>https://dev.to/alexgeorgiev17/sqlite-353s-self-healing-index-only-repairs-the-rows-you-write-to-ni</guid>
      <description>&lt;p&gt;I asked a database for the row where a computed bucket equalled 500. It gave me row 309. The correct row, using the same formula, was 308. No error, no warning, just a wrong answer served from an index that no longer matched the function that built it.&lt;/p&gt;

&lt;p&gt;This is what SQLite calls a stale expression index, and it's been a known but rarely discussed failure mode for years. SQLite 3.53.0, released in April 2026, added a "self-healing" mechanism aimed at exactly this problem. I built one on purpose, broke it on an old SQLite build, then fixed it on the new one, to see what the fix actually does and does not cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an expression index goes stale
&lt;/h2&gt;

&lt;p&gt;An expression index stores the &lt;em&gt;computed&lt;/em&gt; value of an expression, not the raw column. &lt;code&gt;CREATE INDEX idx ON docs(lower(email))&lt;/code&gt; stores lowercased emails in the index tree. If the function behind that expression ever returns a different answer for the same input — a bug fix in a custom function, a library upgrade, or (SQLite's own documented case) a one-ULP shift in floating point conversion between versions — the stored index entries no longer match what the expression would compute today. The row is still there. The index just points at the wrong place, or the wrong bucket doesn't point anywhere.&lt;/p&gt;

&lt;p&gt;I couldn't easily reproduce SQLite's own internal float-conversion trigger without hunting for the exact input that shifts by one ULP between two specific point releases, so I reproduced the other documented cause instead: a custom SQL function whose output changes. I registered a scalar function &lt;code&gt;classify(x)&lt;/code&gt; that returns &lt;code&gt;floor(x*100)&lt;/code&gt;, built a table and an expression index on &lt;code&gt;classify(x)&lt;/code&gt;, then reopened the same database file with a "fixed" version of the function that returns &lt;code&gt;floor(x*100) + 1&lt;/code&gt; — standing in for an app-level bug fix that nobody thought to pair with a &lt;code&gt;REINDEX&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;classify_func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sqlite3_context&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlite3_value&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3_value_double&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;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g_variant&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;bucket&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="cm"&gt;/* the "fixed" version */&lt;/span&gt;
    &lt;span class="n"&gt;sqlite3_result_int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both versions are registered &lt;code&gt;SQLITE_DETERMINISTIC&lt;/code&gt;, which is exactly what SQLite's docs warn against doing carelessly: it lets the engine trust the index without re-checking.&lt;/p&gt;

&lt;p&gt;I built and ran two SQLite builds compiled from the official amalgamation, so I could isolate the version as the only variable: 3.51.0 (before self-healing existed) and 3.53.4, the latest release as of this test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong answer, on the old build
&lt;/h2&gt;

&lt;p&gt;With 1,000 rows and the index built under the old function, I reopened the database with the "fixed" function and asked for &lt;code&gt;classify(x) = 500&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;indexed (INDEXED BY idx_classify) ids for classify(x)=500: (id=309 x=5.004000)
full scan (NOT INDEXED) ids for classify(x)=500: (id=308 x=4.991000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different rows, same query, same data, same function. The index path used the b-tree key that was written when the old function built it; the scan path recomputed the expression fresh. Nothing in the indexed query's result told me it was wrong.&lt;/p&gt;

&lt;p&gt;Then I tried writing to the affected rows on SQLite 3.51.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;update_rc=11 err=database disk image is malformed (range 1..50)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;11&lt;/code&gt; is &lt;code&gt;SQLITE_CORRUPT&lt;/code&gt;. That is the honest, current behaviour of every SQLite release before 3.53: a stale expression index can turn an ordinary &lt;code&gt;UPDATE&lt;/code&gt; into a corruption error, on a database that isn't actually corrupt in any other sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 3.53.4 does instead
&lt;/h2&gt;

&lt;p&gt;Same database, same broken function pairing, but opened with the 3.53.4 build. The pre-write query showed the identical wrong answer (309 instead of 308) — self-healing does not run on reads, only on writes. Then I ran an &lt;code&gt;UPDATE&lt;/code&gt; touching id 300 through 320, a range that happens to include both 308 and 309:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;update_rc=0 err=none (range 300..320) time=0.0009
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error. Querying &lt;code&gt;classify(x) = 500&lt;/code&gt; again afterwards, both the indexed path and the full scan now agreed on row 308. That part of SQLite's claim held up: "no errors are raised, the application never knows that something was ever amiss" is literally what I observed.&lt;/p&gt;

&lt;p&gt;What I didn't expect is how narrow the fix is. Before that &lt;code&gt;UPDATE&lt;/code&gt;, an integrity check with the row limit raised to 5,000 counted every stale entry in the table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Stale index entries (of 1,000 rows)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before any write&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After &lt;code&gt;UPDATE&lt;/code&gt; on rows 300–320 (21 rows)&lt;/td&gt;
&lt;td&gt;979&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After &lt;code&gt;REINDEX EXPRESSIONS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Self-healing repairs exactly the rows a write statement touches, and nothing else. The other 979 rows stayed just as wrong as they were on 3.51.0 — they simply hadn't been written to. A read-heavy table with a stale expression index could sit there returning wrong answers indefinitely on 3.53, with no error and no write ever forcing a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  REINDEX EXPRESSIONS is the actual fix, and it's fast
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;REINDEX EXPRESSIONS&lt;/code&gt; is new in 3.53 too: it rebuilds only expression indexes, skipping ordinary column indexes on the same table. On 3.51.0 the statement doesn't parse as a special form 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;reindex_expr_rc=1 err=unable to identify the object to be reindexed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite 3.51 reads &lt;code&gt;EXPRESSIONS&lt;/code&gt; as the name of an index or table to reindex, finds nothing by that name, and fails with that message. On 3.53.4 it's a real command, and it healed all 979 remaining rows in my 1,000-row table instantly.&lt;/p&gt;

&lt;p&gt;To see whether "only touches expression indexes" is actually worth anything, I built a 500,000-row table with four indexes — one expression index on &lt;code&gt;classify(x)&lt;/code&gt;, three ordinary ones on other columns — and timed &lt;code&gt;REINDEX EXPRESSIONS&lt;/code&gt; against a full &lt;code&gt;REINDEX&lt;/code&gt; of the table, fastest of three runs each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Time (fastest of 3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;REINDEX EXPRESSIONS&lt;/code&gt; (1 of 4 indexes)&lt;/td&gt;
&lt;td&gt;0.198s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;REINDEX t&lt;/code&gt; (all 4 indexes)&lt;/td&gt;
&lt;td&gt;0.990s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;About five times faster, roughly proportional to touching one index instead of four. If a table has several plain indexes alongside one suspect expression index, this is the difference between a maintenance job that runs in the background and one that locks the whole table for a full rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  What self-healing costs you on every write
&lt;/h2&gt;

&lt;p&gt;Since 3.53 quietly rewrites stale entries as it goes, I wanted to know if that costs anything on writes that don't need it. I built two 200,000-row tables — one where the index was already stale relative to the current function, one where it matched from the start — then ran the identical single-statement &lt;code&gt;UPDATE&lt;/code&gt; touching every row, fastest of three runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table state&lt;/th&gt;
&lt;th&gt;Update time (fastest of 3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Index already consistent&lt;/td&gt;
&lt;td&gt;0.164s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index stale, every row healed&lt;/td&gt;
&lt;td&gt;0.239s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Healing added roughly 46% to that statement's time, or about 0.4 microseconds per row. That's a real cost, not a rounding error, though it's a one-time tax paid the first time each row is touched after the function changes — after that the row is clean and stays clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under concurrent writers, nothing new happens
&lt;/h2&gt;

&lt;p&gt;I opened five separate connections against the same stale database, each running an &lt;code&gt;UPDATE&lt;/code&gt; on an overlapping range of ids, with no &lt;code&gt;busy_timeout&lt;/code&gt; set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;update_rc=5 err=database is locked (range 801..2000)
update_rc=5 err=database is locked (range 1601..3000)
update_rc=5 err=database is locked (range 2401..4000)
update_rc=0 err=none (range 1..1000)
update_rc=0 err=none (range 3201..5000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of five got &lt;code&gt;SQLITE_BUSY&lt;/code&gt; immediately, two succeeded. That's just SQLite's ordinary single-writer lock, the same thing that happens with any concurrent write and no timeout configured. Self-healing didn't introduce a new failure mode here, and the two successful writes healed exactly the rows in their ranges — the remaining stale count matched what I'd expect from simple arithmetic on which ranges got through. I didn't find anything specific to self-healing under contention worth a longer write-up.&lt;/p&gt;

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

&lt;p&gt;My first pass at the "wrong answer" query used bucket 500 arbitrarily without checking my data's range, and separately I first tried bucket 50 — which was below the minimum value my generated &lt;code&gt;x&lt;/code&gt; column could ever produce, so both the indexed and scan queries correctly returned zero rows. That looked like "no staleness detected" and briefly convinced me my repro wasn't working, before I checked the actual range of &lt;code&gt;x*100&lt;/code&gt; in my dataset and picked a bucket inside it.&lt;/p&gt;

&lt;p&gt;The second mistake was quieter. I wrote a separate small program to run &lt;code&gt;PRAGMA integrity_check(5000)&lt;/code&gt; for a precise count, and it reported zero problems on a database I already knew was full of them. I'd forgotten to register the &lt;code&gt;classify&lt;/code&gt; function on that connection before running the check. SQLite apparently treats an index expression it can't evaluate as something to skip rather than something to flag, so the check silently passed over the one index I cared about. Once I registered the function on that connection too, the count came back as 1,000, matching everywhere else.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction (2026-09-11):&lt;/strong&gt; The paragraph above is wrong, a reader's reproduction on 3.53.4 caught it. With &lt;code&gt;classify&lt;/code&gt; unregistered, &lt;code&gt;PRAGMA integrity_check&lt;/code&gt; does not skip the index, &lt;code&gt;sqlite3_prepare_v2&lt;/code&gt; fails outright with &lt;code&gt;unknown function: classify()&lt;/code&gt;. My separate checker program never checked that return code, so the check never actually ran, and the zero problems I reported came from nothing being counted, not from SQLite skipping anything. A safeguard has to confirm the check actually executed, not just trust a clean result.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;This needs a C compiler and the official amalgamation source, no Docker 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="nb"&gt;mkdir &lt;/span&gt;sqlite-test &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;sqlite-test
curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; new.zip https://sqlite.org/2026/sqlite-amalgamation-3530400.zip
curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; old.zip https://sqlite.org/2025/sqlite-amalgamation-3510000.zip
unzip &lt;span class="nt"&gt;-q&lt;/span&gt; new.zip &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; unzip &lt;span class="nt"&gt;-q&lt;/span&gt; old.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save this as &lt;code&gt;harness.c&lt;/code&gt; (trimmed to the create/query/update paths used above):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;"sqlite3.h"&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdio.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdlib.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;string.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;g_variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;classify_func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sqlite3_context&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlite3_value&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3_value_double&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;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g_variant&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sqlite3_result_int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dbpath&lt;/span&gt; &lt;span class="o"&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&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="n"&gt;g_variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;atoi&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;atoi&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;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sqlite3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;sqlite3_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dbpath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;sqlite3_create_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"classify"&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="n"&gt;SQLITE_UTF8&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;SQLITE_DETERMINISTIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;classify_func&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strcmp&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="s"&gt;"create"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CREATE TABLE t(id INTEGER PRIMARY KEY, x REAL)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CREATE INDEX idx_classify ON t(classify(x))"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"BEGIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_stmt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_prepare_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"INSERT INTO t(x) VALUES (?)"&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;sqlite3_bind_double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mo"&gt;013&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;sqlite3_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;sqlite3_reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_finalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"COMMIT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strcmp&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="s"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;sqlite3_stmt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT id,x FROM t INDEXED BY idx_classify WHERE classify(x)=%d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_prepare_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sqlite3_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;SQLITE_ROW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"indexed: id=%d x=%f&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlite3_column_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sqlite3_column_double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&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="n"&gt;sqlite3_finalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT id,x FROM t NOT INDEXED WHERE classify(x)=%d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;sqlite3_prepare_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sqlite3_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;SQLITE_ROW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scan:    id=%d x=%f&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sqlite3_column_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sqlite3_column_double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&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="n"&gt;sqlite3_finalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strcmp&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="s"&gt;"update"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&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;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;atoi&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;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="n"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UPDATE t SET x=x WHERE id BETWEEN %d AND %d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"update_rc=%d err=%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;sqlite3_close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build both versions and reproduce the wrong answer, then the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcc &lt;span class="nt"&gt;-O2&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; sqlite-amalgamation-3510000 harness.c sqlite-amalgamation-3510000/sqlite3.c &lt;span class="se"&gt;\&lt;/span&gt;
    &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; &lt;span class="nt"&gt;-o&lt;/span&gt; harness_old
gcc &lt;span class="nt"&gt;-O2&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; sqlite-amalgamation-3530400 harness.c sqlite-amalgamation-3530400/sqlite3.c &lt;span class="se"&gt;\&lt;/span&gt;
    &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; &lt;span class="nt"&gt;-o&lt;/span&gt; harness_new

&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; test.db
./harness_old test.db create 0 1000      &lt;span class="c"&gt;# build the index with the "buggy" function&lt;/span&gt;
./harness_old test.db query 1 500        &lt;span class="c"&gt;# variant 1 = the "fixed" function: wrong answer&lt;/span&gt;
./harness_old test.db update 1 1 50      &lt;span class="c"&gt;# SQLITE_CORRUPT on the old build&lt;/span&gt;

&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; test.db
./harness_new test.db create 0 1000
./harness_new test.db update 1 300 320   &lt;span class="c"&gt;# succeeds silently on 3.53.4&lt;/span&gt;
./harness_new test.db query 1 500        &lt;span class="c"&gt;# now correct, because 308/309 got touched&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;If you have expression indexes on top of application-defined functions or extensions, treat a fix to that function the same way you'd treat a schema migration: run &lt;code&gt;REINDEX EXPRESSIONS&lt;/code&gt; right after deploying it. Don't rely on self-healing to catch up on its own — it only touches rows your application happens to write to, and a read-mostly table can carry silently wrong answers for as long as you let it.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;PRAGMA integrity_check&lt;/code&gt; on any database where you suspect this, but register every custom function the schema depends on before you do — mine reported zero problems the one time I forgot, on a database with a thousand stale rows. And if you're still on a version before 3.53, know that the current failure mode for a stale expression index isn't a wrong answer, it's &lt;code&gt;SQLITE_CORRUPT&lt;/code&gt; on write.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>database</category>
      <category>performance</category>
      <category>testing</category>
    </item>
    <item>
      <title>nginx silently rejects the new HTTP QUERY method</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Wed, 09 Sep 2026 21:11:00 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/nginxs-limitexcept-block-silently-rejects-the-new-http-query-method-1gcg</link>
      <guid>https://dev.to/alexgeorgiev17/nginxs-limitexcept-block-silently-rejects-the-new-http-query-method-1gcg</guid>
      <description>&lt;p&gt;RFC 10008 went to Proposed Standard in June. It adds QUERY, a new HTTP method. Safe and idempotent like GET, but it carries a body like POST. On paper, that's it. A new verb.&lt;/p&gt;

&lt;p&gt;I nearly didn't bother writing this up because of that. Then I read further into the RFC and found a line saying older proxies, frameworks and load balancer configs might not recognise the method yet. It doesn't say which ones. It doesn't say what "not recognise" even means in practice. Does it 404? 405? Does it just eat the body and treat it as GET? Nobody tells you, so I rented a box and found out myself.&lt;/p&gt;

&lt;p&gt;Most codebases I've touched have a &lt;code&gt;POST /search&lt;/code&gt; somewhere, and it's always for the same reason: GET can't carry a real filter object, and nobody fancies fighting a URL length limit over it. QUERY fixes that, in theory. Whether it works in practice depends on every layer between the client and your view agreeing to let the method through. That's not something the RFC can tell you. Only running it can.&lt;/p&gt;

&lt;p&gt;So that's what I did. One backend, three reverse proxies in front of it, one separate Django app on the side, and a droplet I could throw away the second I had numbers.&lt;/p&gt;

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

&lt;p&gt;A FastAPI backend on port 8001. nginx, Caddy and Traefik each fronting it on their own port. A separate Django project too, with two class-based views, because Django isn't built on Starlette and dispatches methods completely differently. All of it on one DigitalOcean Droplet in Frankfurt, fra1, s-2vcpu-4gb, Ubuntu 24.04. I killed the droplet the moment testing was done.&lt;/p&gt;

&lt;p&gt;Versions, in case you're checking this later: curl 8.5.0. FastAPI 0.141.1 on Starlette 1.6.0. Django 6.1.1. nginx 1.24.0. Caddy 2.11.4. Traefik 3.7.10.&lt;/p&gt;

&lt;h2&gt;
  
  
  curl already does this properly
&lt;/h2&gt;

&lt;p&gt;First question, before anything else: can the tooling even send a QUERY request with a body? I pointed curl at a bare netcat listener to see the raw bytes.&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;-s&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 2 &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"test"}'&lt;/span&gt; http://127.0.0.1:8000/search
&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;QUERY /search HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: curl/8.5.0
Accept: */*
Content-Type: application/json
Content-Length: 12

{"q":"test"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No special flag. No workaround. curl just sends whatever method you give it through &lt;code&gt;-X&lt;/code&gt;, body and all. It didn't need anything. That's one layer down.&lt;/p&gt;

&lt;h2&gt;
  
  
  FastAPI
&lt;/h2&gt;

&lt;p&gt;I gave it a route with &lt;code&gt;methods=["QUERY"]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.api_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;QUERY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;received_method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That worked. 200, body echoed back. Not surprising, since I'd told it to expect QUERY. What I actually wanted to know was what happens on a route I hadn't touched. So I sent the same request to &lt;code&gt;/docs&lt;/code&gt;, which only has GET on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/1.1 405 Method Not Allowed
allow: GET, HEAD
content-type: application/json

{"detail":"Method Not Allowed"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Correct &lt;code&gt;Allow&lt;/code&gt; header, listing GET and HEAD. Nothing broke, and I'll say that plainly since most of this post is about things that did break. But it also means QUERY doesn't just piggyback on your GET handler. You want ten routes to answer QUERY, that's ten edits. Not a flag, not a setting.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Django: I wrote a handler, and it still said no
&lt;/h2&gt;

&lt;p&gt;Django's class-based views dispatch by looking up a lowercased method name. &lt;code&gt;get&lt;/code&gt; for GET. &lt;code&gt;post&lt;/code&gt; for POST. So &lt;code&gt;query&lt;/code&gt; should work for QUERY the same way. I wrote one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SearchView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;View&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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;received_method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                              &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
&lt;/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;HTTP/1.1 405 Method Not Allowed
Allow: OPTIONS
Content-Length: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refused. Empty body. An &lt;code&gt;Allow&lt;/code&gt; header that doesn't even mention the method I just wrote a handler for, and only lists OPTIONS, because OPTIONS is the one method this class gets automatically and I hadn't implemented &lt;code&gt;get&lt;/code&gt; or &lt;code&gt;post&lt;/code&gt; on it either. Here's why it happened: &lt;code&gt;View.http_method_names&lt;/code&gt; is a hardcoded list, and Django checks the request against it before it ever looks at your class.&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;View&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;View&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_method_names&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;post&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;put&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;patch&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;delete&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;head&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;options&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;trace&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;No &lt;code&gt;query&lt;/code&gt; in there. Doesn't matter that I wrote the method. Django never gets that far. One line fixes it, but you have to know to write it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SearchViewFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;View&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;http_method_names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;View&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_method_names&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;received_method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                              &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
&lt;/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;HTTP/1.1 200 OK
Content-Type: application/json

{"received_method": "QUERY", "body": "{\"q\":\"hello\"}"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same handler. Same request. The only change is telling the class it's allowed to answer at all. This is the sharp edge of that RFC warning I mentioned. It doesn't crash. It just 405s, looking exactly like a typo in your URL, and the traceback won't point you anywhere near the real fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  nginx, and the config pattern that breaks it
&lt;/h2&gt;

&lt;p&gt;Plain &lt;code&gt;proxy_pass&lt;/code&gt;, nothing restricting methods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/1.1 200 OK
{"received_method":"QUERY","body":"{\"q\":\"hello\"}"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works fine. Then I added &lt;code&gt;limit_except&lt;/code&gt;, the block that turns up in a huge share of nginx hardening guides, restricting a location to only the methods it's supposed to need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;limit_except&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt; &lt;span class="s"&gt;HEAD&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8001&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/1.1 403 Forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Flat. No explanation. Never even reaches the backend. And to be fair to nginx: the default was fine two paragraphs ago. This isn't nginx's fault, it's a hardening snippet copied into configs for years, written back when GET, POST and HEAD covered every method a location would ever need. Whoever wrote it had no reason to think about a verb that didn't exist yet. If your config has &lt;code&gt;limit_except&lt;/code&gt; anywhere in it, go look at what's on that list right now.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Caddy and Traefik: nothing to report
&lt;/h2&gt;

&lt;p&gt;I expected at least one of these to have an opinion about a method it didn't recognise. Neither did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Caddy, default reverse_proxy, no config changes
HTTP/1.1 200 OK
Via: 1.1 Caddy
{"received_method":"QUERY","body":"{\"q\":\"hello\"}"}
&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;# Traefik, default file-provider router, no config changes
HTTP/1.1 200 OK
{"received_method":"QUERY","body":"{\"q\":\"hello\"}"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They just pass whatever the client sends. No allowlist to trip over. Nothing to configure. Caddy and Traefik are also the two newer tools of the three here, which fits the RFC's "older tooling" line better than I expected going in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under load, and in the logs
&lt;/h2&gt;

&lt;p&gt;Ten concurrent QUERY requests through nginx's plain proxy, all &lt;code&gt;200&lt;/code&gt;. No concurrency surprise there. The access log picked them up cleanly too, no config change needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1 - - [09/Sep/2026:19:23:13 +0000] "QUERY /search HTTP/1.1" 200 51 "-" "curl/8.5.0"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request line, status, size, all where you'd expect them. If you're watching this in production already, your log pipeline already sees it fine. The gap isn't observability. It's earlier, at the config and framework layer, before the request even gets that far.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong
&lt;/h2&gt;

&lt;p&gt;My first Traefik config wouldn't load. The error wasn't helpful: &lt;code&gt;yaml: line 4: found unknown escape character&lt;/code&gt;. I'd written the router rule as one long string passed straight through an SSH command, and the backtick in &lt;code&gt;PathPrefix(`/`)&lt;/code&gt; got mangled by an extra layer of shell escaping I hadn't planned for. Writing the same YAML through a quoted heredoc over &lt;code&gt;ssh ... bash -s&lt;/code&gt;, instead of one inline string, fixed it straight away. That one was on me, not Traefik.&lt;/p&gt;

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

&lt;p&gt;Five checks, that's all of this. Every command below is exactly what I ran, against a backend already listening on port 8001.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. curl sends QUERY with a body natively&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8001/search

&lt;span class="c"&gt;# 2. Explicit FastAPI route works; unregistered route doesn't&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8001/search
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8001/docs

&lt;span class="c"&gt;# 3. Django: default View rejects it, extended http_method_names accepts it&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8002/search/
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8002/search-fixed/

&lt;span class="c"&gt;# 4. nginx: plain proxy_pass works, limit_except blocks it&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8010/search   &lt;span class="c"&gt;# plain&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8011/search   &lt;span class="c"&gt;# limit_except&lt;/span&gt;

&lt;span class="c"&gt;# 5. Caddy and Traefik, both unmodified&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8020/search
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hello"}'&lt;/span&gt; http://127.0.0.1:8030/search
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Django fix is that one &lt;code&gt;http_method_names&lt;/code&gt; line, plus the &lt;code&gt;query&lt;/code&gt; method itself. The nginx fix is either drop &lt;code&gt;limit_except&lt;/code&gt;, or add &lt;code&gt;QUERY&lt;/code&gt; to its list by hand.&lt;/p&gt;

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

&lt;p&gt;Don't test this on a clean install of anything. I did, here, and it made the whole thing look easier than it will be on a real system. Grep your actual nginx config for &lt;code&gt;limit_except&lt;/code&gt; first. That one line is what decided whether QUERY got anywhere near my backend at all. On Django, check &lt;code&gt;http_method_names&lt;/code&gt; on the actual views you'd be changing, not some throwaway subclass.&lt;/p&gt;

&lt;p&gt;Going in, I expected the framework side to be the messy one. I came out thinking the proxy layer is worse, just because it's older, more copied from tutorial to tutorial, and less likely to get a second look before this breaks on someone. curl got QUERY right before I'd written a single line of my own code. A five year old nginx snippet didn't.&lt;/p&gt;

&lt;p&gt;That's the whole shape of it. The method itself is sound, and that &lt;code&gt;POST /search&lt;/code&gt; I mentioned at the start really can become a QUERY without changing what it does. It just can't skip the audit of everything sitting in front of it first.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>django</category>
      <category>fastapi</category>
      <category>http</category>
    </item>
    <item>
      <title>What is your daily routine?</title>
      <dc:creator>Alex Georgiev</dc:creator>
      <pubDate>Mon, 10 May 2021 13:32:47 +0000</pubDate>
      <link>https://dev.to/alexgeorgiev17/what-is-your-daily-routine-44f2</link>
      <guid>https://dev.to/alexgeorgiev17/what-is-your-daily-routine-44f2</guid>
      <description>&lt;p&gt;I would like to hear what is everyone doing during WFH (Working from Home). Have you established some goals for each day, for example, to exercise or to read a book, take small breaks each hour?&lt;/p&gt;

&lt;p&gt;Do you have a to-do list that you follow each day of the week or you just try to stay productive but do not follow a routine?&lt;/p&gt;

</description>
      <category>watercooler</category>
      <category>discuss</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
