<?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: Vahid Aghajani</title>
    <description>The latest articles on DEV Community by Vahid Aghajani (@vahid_aghajani_60ce9dbec9).</description>
    <link>https://dev.to/vahid_aghajani_60ce9dbec9</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%2F4015358%2F35ccb2f9-355f-4af6-a004-19ae755a9d8c.png</url>
      <title>DEV Community: Vahid Aghajani</title>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vahid_aghajani_60ce9dbec9"/>
    <language>en</language>
    <item>
      <title>Design a Rate Limiter: The Four-Point Answer, and the Trap</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 01 Sep 2026 07:53:27 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/design-a-rate-limiter-the-four-point-answer-and-the-trap-3m52</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/design-a-rate-limiter-the-four-point-answer-and-the-trap-3m52</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtube.com/shorts/2IzBYh2kOIo" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/design-a-rate-limiter-the-four-point-answer-and-the-trap?id=187" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Design a rate limiter."&lt;/p&gt;

&lt;p&gt;It is one of the most common system design interview questions, and it has an unusually reliable failure mode. The candidate hears it as an &lt;em&gt;algorithms&lt;/em&gt; question, names one — token bucket, leaky bucket, fixed window, sliding window log — sketches the mechanism, and stops.&lt;/p&gt;

&lt;p&gt;Everything they said was correct. It also answered the easy half.&lt;/p&gt;

&lt;p&gt;Naming the algorithm tells the interviewer you have read the same article everyone else read. What they are actually listening for starts one question later: &lt;strong&gt;where does the count live when you are running more than one server?&lt;/strong&gt; The algorithm is a function. The counter is state, and state is where system design lives.&lt;/p&gt;

&lt;p&gt;Here is the four-point answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Count against the API key or the user id, not the IP
&lt;/h2&gt;

&lt;p&gt;Before any mechanism, decide what you are counting &lt;em&gt;against&lt;/em&gt;. This is a choice with a wrong answer, not a definition.&lt;/p&gt;

&lt;p&gt;The tempting answer is the IP address, because it is always there and it needs no authentication. The problem is that an IP address is not a person. One IP is routinely one office, one university campus, one coffee shop, one mobile carrier's NAT pool — hundreds or thousands of unrelated people sharing a single address. Rate limit on it and every one of them shares a single budget. The first heavy user of the morning exhausts the quota for the entire building.&lt;/p&gt;

&lt;p&gt;Meanwhile, the abuser you built this for is the one party in the story for whom addresses are cheap and disposable. They rotate through a proxy pool and never touch your ceiling.&lt;/p&gt;

&lt;p&gt;So you punish the innocent and miss the abuser — a limiter that is somehow both too strict and too loose, which is a strange thing to ship.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&amp;nbsp;&lt;/th&gt;
      &lt;th&gt;IP address&lt;/th&gt;
      &lt;th&gt;API key / user id&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;What it identifies&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A network path, at this moment&lt;/td&gt;
      &lt;td&gt;An account — the thing you actually meter&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Who shares one&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;An office, a campus, a carrier NAT pool — thousands of strangers&lt;/td&gt;
      &lt;td&gt;One customer, by construction&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cost to rotate&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Cents, via any proxy pool&lt;/td&gt;
      &lt;td&gt;A new signup, and you can see it happen&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Available before auth&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Stable across networks&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;No — wifi to cellular changes it&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Failure mode&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Throttles the innocent, misses the abuser&lt;/td&gt;
      &lt;td&gt;Needs an identity to exist first&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Count against the identity: the API key, the user id, the tenant. That is stable across networks, it is the thing your product actually meters, and it is the thing a customer would recognise on an invoice.&lt;/p&gt;

&lt;p&gt;There is one honest exception. &lt;strong&gt;Anonymous, unauthenticated traffic&lt;/strong&gt; — a public signup endpoint, a login form, an unauthenticated search — has no identity to count against yet. There, the IP is the best signal available and an IP limit is the right call. Say that out loud in the interview; knowing when the weaker key is the correct key is worth more than rejecting it outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Enforce it at the gateway, not inside every service
&lt;/h2&gt;

&lt;p&gt;Put the limiter at the front door — the API gateway, the edge proxy, the ingress — and not inside each service behind it.&lt;/p&gt;

&lt;p&gt;Two reasons, and they are different in kind.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;operational&lt;/strong&gt; one: one front door is one place where limits are configured, one place where they are changed, one place to look when a customer says they are being throttled. Implement the check in each service and you have N implementations of the same rule, which will drift, and a limit change becomes N deploys.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;economic&lt;/strong&gt; one is stronger. A request rejected at the door never gets past the door. It does not open a database connection, does not enqueue a job, does not hit your billing code, does not consume a worker slot. That is the entire point of rate limiting under load: the traffic you refuse must be &lt;em&gt;cheap to refuse&lt;/em&gt;. A limiter that sits deep in the stack — say, as a decorator on a service method — has already paid for connection setup, deserialisation and authentication before it decides the answer is no. Under the exact conditions you built it for, it costs you the most.&lt;/p&gt;

&lt;p&gt;There is a second-order version of the same mistake: a request that fails the check in service C has already done real work in A and B.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The counter is shared state
&lt;/h2&gt;

&lt;p&gt;This is the point most answers miss, and it is where the question is actually decided.&lt;/p&gt;

&lt;p&gt;Say you keep the count in a dictionary in the API process. It is fast, it needs no extra infrastructure, and it works perfectly in development, where you run one process.&lt;/p&gt;

&lt;p&gt;Then you run three.&lt;/p&gt;

&lt;p&gt;You wrote "100 requests per minute" in the spec. What you have built is &lt;strong&gt;100 requests per minute per instance&lt;/strong&gt;. Three instances is 300. Ten is 1000. And the number is not even fixed — a load balancer spreading a user's requests across the fleet means each instance sees roughly a third of that user's traffic and never trips, so in practice the ceiling is higher still and depends on how the balancer happens to route.&lt;/p&gt;

&lt;p&gt;Then the autoscaler joins in. Now the effective limit &lt;strong&gt;moves with your traffic&lt;/strong&gt;: busy hour, more instances, higher real limit — precisely the opposite of what a limiter is for. The system is most permissive at the moment it should be strictest, and nothing in your monitoring will say so, because every individual instance is dutifully enforcing 100.&lt;/p&gt;

&lt;p&gt;The fix is not a cleverer algorithm. It is &lt;strong&gt;one store, one atomic increment&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One store: a single place — Redis is the usual choice — that every instance talks to, so there is exactly one number per key per window.&lt;/p&gt;

&lt;p&gt;One atomic increment: not read, then decide, then write. Those are three steps, and two requests arriving together can both read 99, both decide they are allowed, and both write 100. The check and the claim have to be a single indivisible operation. Whether that is &lt;code&gt;INCR&lt;/code&gt; with an expiry, a conditional decrement, or a small server-side script is an implementation detail; the invariant is what earns the point.&lt;/p&gt;

&lt;p&gt;That store is now on the hot path of every single request, so say the consequences out loud too: it needs to be fast, it needs to be close, and you need an answer for what happens when it is unreachable. Fail open and you have no limiter during exactly the incident that produced the outage. Fail closed and a limiter outage is a full outage. Choosing deliberately, and being able to say which way you chose and why, is the senior answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Answer with a 429, a Retry-After, and the quota left
&lt;/h2&gt;

&lt;p&gt;The last point is the contract, and it is the one candidates skip because it feels like trivia.&lt;/p&gt;

&lt;p&gt;Reject with &lt;strong&gt;429 Too Many Requests&lt;/strong&gt;. Include &lt;strong&gt;Retry-After&lt;/strong&gt; with the number of seconds until the window resets. Include the remaining quota — &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt;, or whatever your API already uses — on the successful responses too, so a well-behaved client can see the wall coming before it hits it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;429&lt;/span&gt; &lt;span class="ne"&gt;Too Many Requests&lt;/span&gt;
&lt;span class="na"&gt;Retry-After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12&lt;/span&gt;
&lt;span class="na"&gt;X-RateLimit-Remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason this matters is not politeness. It is that the client now has &lt;strong&gt;a number to wait on instead of a guess&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A limiter that returns a bare 500, or a 403, or a 200 with an error body, teaches every client integrating against you to invent its own retry timing — and the timings they invent are uniformly worse than the one you already know, because you are the only party who knows when the window resets. You are handing out a fact you have and they do not. Withholding it costs you the retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same question, in front of a model
&lt;/h2&gt;

&lt;p&gt;If you have ever been handed an LLM API key, you have already met all four points wearing different names — and one extra twist that makes this the sharpest live example of the pattern.&lt;/p&gt;

&lt;p&gt;Look at how a model provider states its limits. Not "100 requests per minute" but &lt;strong&gt;RPM &lt;em&gt;and&lt;/em&gt; TPM&lt;/strong&gt; — requests per minute and &lt;em&gt;tokens&lt;/em&gt; per minute, enforced together. That second dimension breaks an assumption the classic answer quietly makes: that every request costs the same. Here they do not. One request is forty tokens; the next drags a 100,000-token document behind it.&lt;/p&gt;

&lt;p&gt;So the count is no longer &lt;code&gt;+1&lt;/code&gt;. It is &lt;code&gt;+cost&lt;/code&gt;, and you do not know the cost until you have counted the prompt — and you do not know the &lt;em&gt;full&lt;/em&gt; cost until the model has finished generating, because output tokens count too. Real limiters handle this by &lt;strong&gt;reserving an estimate up front and reconciling on completion&lt;/strong&gt;: increment by prompt tokens plus a max-output allowance, then correct the counter down when the stream ends. That is still one store and one atomic operation. It is just an operation that adds a number rather than one.&lt;/p&gt;

&lt;p&gt;The other three points transfer unchanged, and get &lt;em&gt;more&lt;/em&gt; important rather than less:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What you count against&lt;/strong&gt; — the tenant, not the IP, because one of your customers is a chat product with ten thousand users behind one egress address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where you enforce it&lt;/strong&gt; — at the gateway in front of the model, because a rejected inference request must never reach the GPU. This is the economic argument at its most extreme: a request admitted by mistake does not cost you a database connection, it occupies a slot on a machine that costs dollars an hour, for seconds at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reply&lt;/strong&gt; — 429 with a &lt;code&gt;Retry-After&lt;/code&gt;. Every major provider sends one, and every serious client library reads it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if you are serving your &lt;em&gt;own&lt;/em&gt; model rather than calling someone else's, the scarce resource shifts again: what actually runs out is not requests per minute but &lt;strong&gt;concurrent sequences in the batch and blocks in the KV cache&lt;/strong&gt;. A limiter in front of an inference server is really admission control for GPU memory, and the honest version of it counts what the GPU is short of rather than what is easy to count.&lt;/p&gt;

&lt;p&gt;Which is the same lesson as point 3, one level up: the interesting part was never the algorithm. It was working out what the number means and where it is kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Everyone answers "token bucket". The interviewer is waiting to hear where the count lives.&lt;/p&gt;

&lt;p&gt;That is the whole question, compressed. The algorithm is a small, well-documented function that you could look up in a minute. The counter is distributed mutable state on the hot path of every request — which is a system design problem, which is why this is a system design interview.&lt;/p&gt;

&lt;p&gt;If you have time for one more sentence after the four points, make it this one: the algorithm decides &lt;em&gt;whether&lt;/em&gt; to allow a request, and the storage decides whether that decision is &lt;em&gt;true&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict — the whole answer in four lines
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;key&lt;/strong&gt; → count per user or API key, not per IP; IP only for anonymous traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gateway&lt;/strong&gt; → one front door, so a rejected request costs nothing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;counter&lt;/strong&gt; → one shared store, one atomic increment, because in-process counts multiply by your instance count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;429&lt;/strong&gt; → plus Retry-After and the remaining quota, so the client waits on a number instead of a guess&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Name an algorithm if you like — token bucket is a fine choice and takes five seconds to justify. Then spend the rest of your answer on the four points above, because that is the part the interviewer cannot look up on your behalf.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the reply contract — point 4&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IETF, &lt;em&gt;RFC 6585 §4 — Additional HTTP Status Codes: 429 Too Many Requests&lt;/em&gt; (IETF, 2012) — the normative definition of the status code: the server indicates the user has sent too many requests in a given amount of time, and the response may include a &lt;code&gt;Retry-After&lt;/code&gt;. Worth quoting in an interview because the spec explicitly declines to define &lt;em&gt;how&lt;/em&gt; the server counts, which is exactly the storage decision this article argues you are being tested on: &lt;a href="https://www.rfc-editor.org/rfc/rfc6585#section-4" rel="noopener noreferrer"&gt;rfc-editor.org/rfc/rfc6585#section-4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;IETF, &lt;em&gt;RFC 9110 §10.2.3 — HTTP Semantics: the Retry-After header field&lt;/em&gt; (IETF, 2022) — the support for "a number to wait on, not a guess": &lt;code&gt;Retry-After&lt;/code&gt; carries either a delay in seconds or an HTTP-date after which the client may retry, and nothing else: &lt;a href="https://www.rfc-editor.org/rfc/rfc9110#field.retry-after" rel="noopener noreferrer"&gt;rfc-editor.org/rfc/rfc9110#field.retry-after&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;IETF, &lt;em&gt;draft-ietf-httpapi-ratelimit-headers — RateLimit header fields for HTTP&lt;/em&gt; (IETF, in progress) — the ongoing standardisation of the "quota remaining" half of point 4, and the reason the &lt;code&gt;X-RateLimit-*&lt;/code&gt; headers above are a de-facto convention rather than a standard. Note that it is a draft, not a published RFC: &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/" rel="noopener noreferrer"&gt;datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the shared counter — point 3&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis documentation, &lt;em&gt;INCR — "Pattern: Rate limiter"&lt;/em&gt; — the concrete mechanism behind "one store, one atomic increment", from the store this design would actually use: a counter per key per window, incremented atomically and expired with the window. It also documents the exact race the wording above is chosen to avoid — the gap between the &lt;code&gt;INCR&lt;/code&gt; and the &lt;code&gt;EXPIRE&lt;/code&gt;: &lt;a href="https://redis.io/docs/latest/commands/incr/" rel="noopener noreferrer"&gt;redis.io/docs/latest/commands/incr&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On what to count and where to enforce it — points 1 and 2&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Betsy Beyer, Chris Jones, Jennifer Petoff and Niall Richard Murphy (eds.), &lt;em&gt;Site Reliability Engineering&lt;/em&gt;, chapter 21, "Handling Overload" (O'Reilly / Google, 2016) — Google's practice of metering per customer against a quota rather than per source address, and the argument that a rejected request is not free: it still costs CPU and memory, so it must be refused as early and as cheaply as possible. That is the economic case for the front door.&lt;/li&gt;
&lt;li&gt;Paul Tarjan, &lt;em&gt;Scaling your API with rate limiters&lt;/em&gt; (Stripe Engineering blog, 2017) — a production account that lines up with points 1, 2 and 4: limits applied per API key at the edge rather than inside application code, backed by a shared Redis counter, returning 429 with enough information for the caller to act: &lt;a href="https://stripe.com/blog/rate-limiters" rel="noopener noreferrer"&gt;stripe.com/blog/rate-limiters&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/2IzBYh2kOIo" rel="noopener noreferrer"&gt;Design a rate limiter — the four-point answer, and the trap&lt;/a&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>interview</category>
    </item>
    <item>
      <title>MCP vs API: Why Your Server Needs a /mcp Endpoint (and What You Actually Write)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 31 Aug 2026 15:25:57 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/mcp-vs-api-why-your-server-needs-a-mcp-endpoint-and-what-you-actually-write-4mk6</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/mcp-vs-api-why-your-server-needs-a-mcp-endpoint-and-what-you-actually-write-4mk6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/9Jf1zm2MDOA" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/mcp-vs-api-why-your-server-needs-a-mcp-endpoint-and-what-you-actually-write?id=186" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Same server. Same database. Same functions.
&lt;/h2&gt;

&lt;p&gt;Your service already has an API. It has had one for years. It is documented, it is authenticated, it is in production, and it works.&lt;/p&gt;

&lt;p&gt;So why is everyone suddenly standing up a &lt;code&gt;/mcp&lt;/code&gt; endpoint right next to it?&lt;/p&gt;

&lt;p&gt;Not because the old one is broken. Behind both doors is the same server, the same database, the same Python functions. Nothing underneath changes. The difference is one sentence long:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The API is written for a developer. The MCP endpoint is written for a model.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything else in this article is a consequence of that sentence — including the one part of the job that no library will do for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a plain endpoint is not enough
&lt;/h2&gt;

&lt;p&gt;Think about how your API actually got called.&lt;/p&gt;

&lt;p&gt;Somewhere, a human being opened your documentation. They read that the path is &lt;code&gt;/v1/invoices&lt;/code&gt;, that it takes a &lt;code&gt;customer_id&lt;/code&gt; and a &lt;code&gt;from&lt;/code&gt; and a &lt;code&gt;to&lt;/code&gt;, that the dates are ISO-8601 and that the response is paginated. Then they went back to their editor and hard-coded that knowledge into a client.&lt;/p&gt;

&lt;p&gt;All of the understanding happened &lt;strong&gt;outside the wire&lt;/strong&gt;. The endpoint itself never explained anything. Ask it what it is for and it has no answer — there is no request you can send it that means &lt;em&gt;"describe yourself."&lt;/em&gt; It was never designed to have one, because the reader was always going to be a person with a browser.&lt;/p&gt;

&lt;p&gt;A model does not have a browser, and it was not in the room when your docs were read. If the only thing on the wire is &lt;code&gt;POST /v1/invoices&lt;/code&gt;, the model is guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP changes is the first move
&lt;/h2&gt;

&lt;p&gt;Here is the actual shift, and it is smaller than it sounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With an API&lt;/strong&gt;, the first thing that happens on the wire is a call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With MCP&lt;/strong&gt;, the first thing that happens on the wire is a &lt;em&gt;question&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before invoking anything, the client asks the server: &lt;strong&gt;what can you do?&lt;/strong&gt; The server answers with a list. Only then does the model pick something from that list and call it.&lt;/p&gt;

&lt;p&gt;Discovery first, invocation second. That ordering is the protocol's whole reason to exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. client → server   tools/list      "what can you do?"
2. server → client   [ 8 tools, each with a description and a schema ]
3. client → server   tools/call      "do this one, with these arguments"
4. server → client   the result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 1 and 2 are the part your REST API has never had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing every REST developer notices first
&lt;/h2&gt;

&lt;p&gt;Open the traffic and something looks wrong: &lt;strong&gt;there is no resource path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your API puts the noun in the URL and the verb in the HTTP method — &lt;code&gt;GET /invoices/42&lt;/code&gt;, &lt;code&gt;DELETE /invoices/42&lt;/code&gt;. MCP does neither. There is one path, &lt;code&gt;/mcp&lt;/code&gt;, and every single message is a &lt;code&gt;POST&lt;/code&gt; to it.&lt;/p&gt;

&lt;p&gt;That is because MCP speaks &lt;strong&gt;JSON-RPC 2.0&lt;/strong&gt;. The operation is not in the URL and not in the method — it is in the &lt;strong&gt;body&lt;/strong&gt;, in a field called &lt;code&gt;method&lt;/code&gt;:&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;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/mcp&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Authorization:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Bearer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;token&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Content-Type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;application/json&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;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&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="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tools/list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reply is a menu — and this is the interesting part. Each entry carries a name, a plain-English description of what it is for, and a schema in which &lt;strong&gt;every parameter has its own description&lt;/strong&gt;:&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;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&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="nl"&gt;"result"&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;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"find_invoices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Find a customer's invoices for a given year. Use this when someone asks about billing history, unpaid bills, or what a customer was charged."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"inputSchema"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"customer"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Customer name or account ID, e.g. 'ACME Ltd' or 'cus_8812'"&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;"year"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Four-digit calendar year, e.g. 2026"&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;span class="nl"&gt;"required"&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="s2"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is documentation, delivered as &lt;strong&gt;data&lt;/strong&gt;, over the same wire the call goes out on. Nobody had to read anything in a browser.&lt;/p&gt;

&lt;p&gt;Calling goes through the same door. Only the &lt;code&gt;method&lt;/code&gt; changes:&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;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tools/call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"find_invoices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACME Ltd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2026&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;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;And it is authenticated exactly the way your API is — a bearer token on every request. This is not a new security model. It is your security model, on a new endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  You never write that JSON by hand
&lt;/h2&gt;

&lt;p&gt;Look at that schema again and the obvious objection is: &lt;em&gt;nobody is maintaining that.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Correct. You do not write it. &lt;strong&gt;FastMCP derives it from code you were already writing:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Customer name or account ID, e.g. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ACME Ltd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; or &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cus_8812&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Four-digit calendar year, e.g. 2026&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Find a customer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s invoices for a given year.

    Use this when someone asks about billing history, unpaid bills,
    or what a customer was charged.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;billing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three mappings, and that is the whole trick:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;What you write in Python&lt;/th&gt;
      &lt;th&gt;What the model receives&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Function name &lt;code&gt;find_invoices&lt;/code&gt;
&lt;/td&gt;
      &lt;td&gt;The tool &lt;code&gt;name&lt;/code&gt;
&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The docstring&lt;/td&gt;
      &lt;td&gt;The tool &lt;code&gt;description&lt;/code&gt; — what it is for and when to reach for it&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Type hints + &lt;code&gt;Field(description=...)&lt;/code&gt;
&lt;/td&gt;
      &lt;td&gt;The &lt;code&gt;inputSchema&lt;/code&gt;, one described parameter at a time&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Which has a consequence worth sitting with for a second.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your docstring is no longer a comment. It is a prompt.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is no longer read by a teammate skimming the file. It is read by the thing deciding whether to call your function at all, and with what. A vague docstring is now a runtime failure mode, not a code-review nit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is not generated: the tools themselves
&lt;/h2&gt;

&lt;p&gt;Here is where most first MCP servers go wrong, and it is the reason this article exists.&lt;/p&gt;

&lt;p&gt;Because FastMCP generates the schema, it is tempting to conclude that the whole job is generated — point something at your OpenAPI spec, get an MCP server out, ship it. You will get a server. You will not get a usable one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The schema is generated. The tool design is not.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;MCP is &lt;strong&gt;not a thin 1:1 layer over your existing endpoints.&lt;/strong&gt; You write a &lt;strong&gt;new tool per capability&lt;/strong&gt;, and the good ones are shaped like &lt;strong&gt;tasks&lt;/strong&gt;, not like resources.&lt;/p&gt;

&lt;p&gt;That distinction is concrete. Your REST API is decomposed the way a database is: &lt;code&gt;/customers&lt;/code&gt;, &lt;code&gt;/invoices&lt;/code&gt;, &lt;code&gt;/line-items&lt;/code&gt;, each with filters, each returning a page. Answering &lt;em&gt;"what did ACME pay us in 2026?"&lt;/em&gt; takes three calls and some glue — the human client author wrote that glue once and forgot about it.&lt;/p&gt;

&lt;p&gt;A tool has no glue author. So the tool is the whole task:&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;Auto-generated from the spec&lt;/th&gt;
      &lt;th&gt;Written by hand&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Unit of design&lt;/td&gt;
      &lt;td&gt;One tool per endpoint / per resource&lt;/td&gt;
      &lt;td&gt;One tool per &lt;strong&gt;task a user actually asks for&lt;/strong&gt;
&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Typical count&lt;/td&gt;
      &lt;td&gt;60+&lt;/td&gt;
      &lt;td&gt;8&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Answering "what did ACME pay in 2026?"&lt;/td&gt;
      &lt;td&gt;3 chained calls, IDs threaded by the model&lt;/td&gt;
      &lt;td&gt;1 call: &lt;code&gt;find_invoices(customer, year)&lt;/code&gt;
&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Descriptions&lt;/td&gt;
      &lt;td&gt;Inherited from a spec written for humans, or empty&lt;/td&gt;
      &lt;td&gt;Written for the decision the model has to make&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Parameters&lt;/td&gt;
      &lt;td&gt;Every filter the endpoint supports, most of them noise&lt;/td&gt;
      &lt;td&gt;Only what the task needs&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Failure mode&lt;/td&gt;
      &lt;td&gt;Model picks the wrong one of six similar tools, or gives up&lt;/td&gt;
      &lt;td&gt;Model picks correctly, because the choice is obvious&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Point a generator at your OpenAPI spec and you get &lt;strong&gt;60 tools nobody can use&lt;/strong&gt;. Write &lt;strong&gt;8 by hand&lt;/strong&gt;, with real docstrings, and the model uses them correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bites harder than it looks (the AI-engineering angle)
&lt;/h2&gt;

&lt;p&gt;If you have only built request/response systems, the cost of a bad tool surface is not obvious. It is not a latency problem. It is a &lt;strong&gt;context and decision problem&lt;/strong&gt;, and it shows up in three places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every tool is permanently in the prompt.&lt;/strong&gt; The tool list is not fetched when needed — it is serialised into the model's context on &lt;em&gt;every single turn&lt;/em&gt; of the conversation. Sixty tools with full schemas is thousands of tokens spent before the user has said anything, on every message, forever. Eight task-shaped tools cost a fraction of that, and leave the budget for the actual work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every extra tool is another chance to choose wrong.&lt;/strong&gt; Selection accuracy degrades as near-duplicates pile up. Six endpoints that all list something, with descriptions inherited from a spec written for a human who already knew which one they wanted, is a menu designed to be misread. This is the same reason a chained workflow is fragile: three dependent calls means three chances to thread the wrong ID, and one wrong ID is a confidently wrong answer rather than an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The description is the interface.&lt;/strong&gt; In a normal API, the contract is the signature and the docs are advisory. Here the prose &lt;em&gt;is&lt;/em&gt; the routing logic — a tool whose description does not say &lt;strong&gt;when to use it&lt;/strong&gt; will be called at the wrong moment, no matter how correct its implementation is. Write descriptions that answer the model's actual question ("is this the one?"), not the ones that restate the function name.&lt;/p&gt;

&lt;p&gt;Which gives you a useful design test. For each tool, ask: &lt;strong&gt;is this a thing a user would ask for in a sentence?&lt;/strong&gt; &lt;code&gt;find_invoices(customer, year)&lt;/code&gt; passes. &lt;code&gt;list_line_items(invoice_id, page, per_page, sort)&lt;/code&gt; does not — it is an implementation detail that leaked onto the menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Adding MCP is not rewriting your product, and it is not flipping a switch either.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep everything below the surface: your queries, your business logic, your auth, your database. None of it moves. The REST API stays exactly where it is, serving the clients it already serves — MCP does not replace it, it sits beside it.&lt;/p&gt;

&lt;p&gt;What is genuinely new is a &lt;strong&gt;tool surface&lt;/strong&gt;, and that surface is a design artifact you author. The protocol gives you discovery. FastMCP gives you the schema. The judgment about &lt;em&gt;which capabilities exist and what shape they have&lt;/em&gt; is the part that is still yours, and it is the part that decides whether the thing works.&lt;/p&gt;

&lt;p&gt;The short version, if you take one line away: &lt;strong&gt;the schema is generated for you; the tool design is not.&lt;/strong&gt; Write eight tools shaped like the questions people ask, give each a docstring you would be happy to have read aloud as an instruction, and you are done.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the protocol itself — discovery, and the JSON-RPC envelope&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Model Context Protocol, &lt;em&gt;Specification&lt;/em&gt; — the normative definition of &lt;code&gt;tools/list&lt;/code&gt; and &lt;code&gt;tools/call&lt;/code&gt;, the tool object with its &lt;code&gt;description&lt;/code&gt; and &lt;code&gt;inputSchema&lt;/code&gt;, and the transport and authorization sections behind the bearer-token point above: &lt;a href="https://modelcontextprotocol.io/specification" rel="noopener noreferrer"&gt;modelcontextprotocol.io/specification&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;JSON-RPC Working Group, &lt;em&gt;JSON-RPC 2.0 Specification&lt;/em&gt; (2010) — the envelope MCP is built on: why the operation lives in a &lt;code&gt;method&lt;/code&gt; field inside the body rather than in a URL or an HTTP verb, and how &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;params&lt;/code&gt;, &lt;code&gt;result&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; fit together: &lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;jsonrpc.org/specification&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On why a REST API looks so different — the noun in the URL&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Roy T. Fielding, &lt;em&gt;Architectural Styles and the Design of Network-based Software Architectures&lt;/em&gt; (PhD dissertation, UC Irvine, 2000), ch. 5 — the resource/identifier/uniform-interface model that puts the noun in the path and the verb in the method, which is exactly the convention MCP declines to use: &lt;a href="https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm" rel="noopener noreferrer"&gt;ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenAPI Initiative, &lt;em&gt;OpenAPI Specification&lt;/em&gt; — what an endpoint-shaped description of a service actually contains (paths, operations, parameters), and therefore what a generator pointed at one can and cannot know about the tasks a user wants to perform: &lt;a href="https://spec.openapis.org/oas/latest.html" rel="noopener noreferrer"&gt;spec.openapis.org/oas/latest.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On generating the schema from code&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;FastMCP documentation&lt;/em&gt; — the &lt;code&gt;@mcp.tool&lt;/code&gt; decorator and the derivation rules used above: function name to tool name, docstring to description, type hints to &lt;code&gt;inputSchema&lt;/code&gt;: &lt;a href="https://gofastmcp.com" rel="noopener noreferrer"&gt;gofastmcp.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Pydantic documentation, &lt;em&gt;Fields&lt;/em&gt; and &lt;em&gt;JSON Schema&lt;/em&gt; — how &lt;code&gt;Field(description=...)&lt;/code&gt; and annotated types become the per-parameter descriptions the model reads, which is where most of the usable signal in a tool schema comes from: &lt;a href="https://docs.pydantic.dev/latest/concepts/fields/" rel="noopener noreferrer"&gt;docs.pydantic.dev/latest/concepts/fields/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the part that is not generated — designing the tools&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anthropic, &lt;em&gt;Writing effective tools for agents&lt;/em&gt; (Anthropic Engineering, 2025) — the case for consolidating several low-level endpoints into one task-shaped tool, for spending real effort on tool descriptions, and for evaluating a tool surface rather than assuming it: &lt;a href="https://www.anthropic.com/engineering/writing-tools-for-agents" rel="noopener noreferrer"&gt;anthropic.com/engineering/writing-tools-for-agents&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the short:&lt;/strong&gt; &lt;a href="https://youtu.be/9Jf1zm2MDOA" rel="noopener noreferrer"&gt;MCP vs API — Why Your Server Needs a /mcp Endpoint&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep going:&lt;/strong&gt; the rest of the free AI-engineering course lives at &lt;a href="https://software-engineer-blog.com/ai" rel="noopener noreferrer"&gt;software-engineer-blog.com/ai&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>programming</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>RTO vs RPO: The Two Numbers That Decide What Disaster Recovery Costs</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 31 Aug 2026 07:34:53 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/rto-vs-rpo-the-two-numbers-that-decide-what-disaster-recovery-costs-5afn</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/rto-vs-rpo-the-two-numbers-that-decide-what-disaster-recovery-costs-5afn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/koRSzvlIX1Q" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/rto-vs-rpo-the-two-numbers-that-decide-what-disaster-recovery-costs?id=185" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two letters apart. Both measured in minutes. Bought with completely different money.&lt;/p&gt;

&lt;p&gt;That is why teams argue about disaster recovery for a week without noticing they are talking about two separate things. This article takes the two numbers apart, and then prices both of them against one small shop.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tuesday, twelve minutes past three
&lt;/h2&gt;

&lt;p&gt;The server that holds every order for an online shop is gone.&lt;/p&gt;

&lt;p&gt;The alert fires. Somebody wakes up. By twenty past three they have found the most recent saved copy of the data. That copy was taken at three, on the hour. So they build a new server, and they put that copy onto it. At nineteen minutes past four, the shop can take orders again.&lt;/p&gt;

&lt;p&gt;Now count what actually happened.&lt;/p&gt;

&lt;p&gt;The shop was shut for &lt;strong&gt;sixty-seven minutes&lt;/strong&gt;. And &lt;strong&gt;twelve minutes of orders&lt;/strong&gt; — the ones taken between three o'clock and the moment it died — do not exist anywhere any more.&lt;/p&gt;

&lt;p&gt;Two different things went wrong.&lt;/p&gt;

&lt;p&gt;For the next week, the team argued about how to make sure it never happened again. And in that whole week, nobody ever said which of the two numbers they were trying to make smaller. So which one was it? The sixty-seven minutes the shop was shut, or the twelve minutes of orders that vanished?&lt;/p&gt;




&lt;h2&gt;
  
  
  What this article covers, and what it does not
&lt;/h2&gt;

&lt;p&gt;Three things are covered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What the two numbers actually are. One is how long you may be &lt;strong&gt;down&lt;/strong&gt;. The other is how much data you may &lt;strong&gt;lose&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;What each one is bought with, because they are not bought with the same money.&lt;/li&gt;
&lt;li&gt;How the standard tiers are priced, and where that price stops being reasonable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three things are not covered. This is not how backups work on the inside — there are &lt;strong&gt;no restore steps here&lt;/strong&gt;, and that is a subject for its own article. This is not about which site serves your traffic or how failover flips between two sites, which is covered elsewhere on this site. And this is not one product: &lt;strong&gt;no tool and no cloud provider is named anywhere below.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Two dials, not one
&lt;/h2&gt;

&lt;p&gt;Here is the confusion this kills. People hear "disaster recovery" and think there is one dial, and that turning it up makes everything better.&lt;/p&gt;

&lt;p&gt;There are two dials. A shop that catches fire is the easiest way to see them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First question. How long is the shop shut?&lt;/strong&gt; The fire is out, but the doors still do not open. Every hour shut is customers who go somewhere else. You make that shorter by renting a second shop, already fitted out and ready. And a second shop costs rent every single month, whether or not there is ever a fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second question. How many pages of the order book burned?&lt;/strong&gt; The shop can open again, but some orders simply do not exist. You lost every order taken since the last time somebody carried a page across the road to the safe. You make that shorter by copying more often. And copying after every single order slows down every single order you ever take.&lt;/p&gt;

&lt;p&gt;Now notice how independent those two are. You could reopen in an hour with a whole day of orders missing. You could lose nothing at all and stay shut for a week. Both are possible, because these are two different numbers.&lt;/p&gt;




&lt;h2&gt;
  
  
  One line of time, two arrows
&lt;/h2&gt;

&lt;p&gt;Draw the life of the failure as a line of time. Mark the moment it broke. Everything to the left already happened. Everything to the right has not happened yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look backwards&lt;/strong&gt; from the break, to the last moment your data was safely saved somewhere else. That distance is the &lt;strong&gt;recovery point objective&lt;/strong&gt;, or RPO. It is the data you are willing to lose. On Tuesday it was twelve minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look forwards&lt;/strong&gt; from the break, to the moment a customer could place an order again. That distance is the &lt;strong&gt;recovery time objective&lt;/strong&gt;, or RTO. It is the time you are willing to be down. On Tuesday it was sixty-seven minutes.&lt;/p&gt;

&lt;p&gt;Same instant. Two arrows. They point in opposite directions.&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;RPO&lt;/th&gt;
&lt;th&gt;RTO&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Which way it points&lt;/td&gt;
&lt;td&gt;backwards, into the data&lt;/td&gt;
&lt;td&gt;forwards, into the clock&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What it measures&lt;/td&gt;
&lt;td&gt;work that vanished&lt;/td&gt;
&lt;td&gt;time the doors were shut&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What sets it&lt;/td&gt;
&lt;td&gt;how often you copy&lt;/td&gt;
&lt;td&gt;how fast you can be serving again&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;On Tuesday&lt;/td&gt;
&lt;td&gt;12 minutes&lt;/td&gt;
&lt;td&gt;67 minutes&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both are measured in minutes, and that is exactly why people mix them up. Every section below is this same line with the two marks moved.&lt;/p&gt;




&lt;h2&gt;
  
  
  RPO: the promise is the worst case
&lt;/h2&gt;

&lt;p&gt;Suppose you save a copy of the data every fifteen minutes. What have you actually promised?&lt;/p&gt;

&lt;p&gt;Here is the whole thing, in a few lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# rpo.py -- what does "we copy every fifteen minutes" really promise?
&lt;/span&gt;&lt;span class="n"&gt;COPY_EVERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;      &lt;span class="c1"&gt;# seconds between one saved copy and the next
&lt;/span&gt;&lt;span class="n"&gt;ORDERS_PER_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;       &lt;span class="c1"&gt;# this shop, on an ordinary day
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_copy_at&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;failed_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_copy_at&lt;/span&gt;    &lt;span class="c1"&gt;# everything in the gap is gone
&lt;/span&gt;
&lt;span class="c1"&gt;# The failure does not check your schedule. It lands anywhere in the gap.
&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failed_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;899&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_copy_at&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="c1"&gt;# a second before the next copy
&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failed_at&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;last_copy_at&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="c1"&gt;# a second after the last one
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# 899 1  -&amp;gt; the whole gap, or almost nothing
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ORDERS_PER_MIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# 749.2  -&amp;gt; orders that never existed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything written between the last saved copy and the failure is gone. That is the definition, and it is the only thing that matters here.&lt;/p&gt;

&lt;p&gt;Now, the failure does not check your schedule before it happens. It can land anywhere inside that gap. If it lands one second before the next copy was due, you lose fourteen minutes and fifty-nine seconds of work. If it lands one second after a copy, you lose one second. Both are possible on the same schedule.&lt;/p&gt;

&lt;p&gt;And here is the trap. &lt;strong&gt;The number you promise the business is the worst case, never the average.&lt;/strong&gt; A copy every fifteen minutes means fifteen minutes.&lt;/p&gt;

&lt;p&gt;This shop takes fifty orders a minute, and an order is worth thirty-eight euros. So the same schedule, said honestly, looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;How often you copy&lt;/th&gt;
&lt;th&gt;Worst case&lt;/th&gt;
&lt;th&gt;Orders that never existed&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Once a night&lt;/td&gt;
&lt;td&gt;23 h 59 min 59 s&lt;/td&gt;
&lt;td&gt;71,999&lt;/td&gt;
&lt;td&gt;EUR 2,735,968&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Every 15 minutes&lt;/td&gt;
&lt;td&gt;14 min 59 s&lt;/td&gt;
&lt;td&gt;749&lt;/td&gt;
&lt;td&gt;EUR 28,468&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;As each order is taken, about 2 s behind&lt;/td&gt;
&lt;td&gt;2 seconds&lt;/td&gt;
&lt;td&gt;1.67&lt;/td&gt;
&lt;td&gt;EUR 63&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at what changed between those three rows. Not the quality of the copies. Not the storage. Not the restore. &lt;strong&gt;The only thing that changed is how often.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the only lever RPO has, and every step of it costs you something on the way in. Which is why arguing about backup software rarely moves this number at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  RTO is a sum, and the restore is the small term
&lt;/h2&gt;

&lt;p&gt;Now the other number, and it has a trap of its own.&lt;/p&gt;

&lt;p&gt;Ask a team what their RTO is, and most of them answer with how long the restore takes. Watch what that leaves out. &lt;strong&gt;The clock does not start when you start working. It starts when it breaks.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# rto.py -- the clock starts when it breaks, not when you start working.
&lt;/span&gt;&lt;span class="n"&gt;detect&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# the alert fires, and a human actually sees it
&lt;/span&gt;&lt;span class="n"&gt;decide&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# somebody decides this is a disaster, not a blip
&lt;/span&gt;&lt;span class="n"&gt;provision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# new machines exist, and they are reachable
&lt;/span&gt;&lt;span class="n"&gt;restore&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# the data is put back onto them
&lt;/span&gt;&lt;span class="n"&gt;verify&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# somebody checks it is really the right data
&lt;/span&gt;&lt;span class="n"&gt;cut_over&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# traffic is pointed at the new place
&lt;/span&gt;
&lt;span class="n"&gt;RTO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;detect&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;decide&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;provision&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;restore&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cut_over&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RTO&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# 67  -&amp;gt; the real number, in minutes
&lt;/span&gt;
&lt;span class="c1"&gt;# the one term everybody budgets for, on its own:
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;restore&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 26  -&amp;gt; not even half of the sixty seven
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixty-seven minutes in total. And the one term everybody budgets for, the restore, is twenty-six of them. That is 38.8 percent. Not even half.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Minutes&lt;/th&gt;
&lt;th&gt;Can money make it shorter?&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Somebody actually sees the alert&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;no — people&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Somebody decides this is a real disaster&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;no — people&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;New machines exist and are reachable&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;partly&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The data is put back&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Somebody checks it is the right data&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;no — process&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Traffic is pointed at the new place&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;partly&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;67&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is why that split matters. The twenty-six is the part you can buy your way out of, with faster disks and a bigger pipe. The other &lt;strong&gt;forty-one minutes&lt;/strong&gt; are people and process. No hardware makes any of that shorter.&lt;/p&gt;

&lt;p&gt;So a team that spends its whole recovery budget on a faster restore &lt;strong&gt;cannot get its RTO below forty-one minutes&lt;/strong&gt;, no matter what else it spends. And it usually does not know that.&lt;/p&gt;

&lt;p&gt;If you want the number below forty-one, you have to stop restoring, and start already having a second copy running.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three tiers, and why you do not choose them
&lt;/h2&gt;

&lt;p&gt;Once both numbers are written down, something useful happens. The strategy stops being a choice.&lt;/p&gt;

&lt;p&gt;There are three tiers, and they sit at fixed points on those two numbers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;RTO&lt;/th&gt;
&lt;th&gt;RPO&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;A saved copy, and a rebuild&lt;/td&gt;
&lt;td&gt;hours&lt;/td&gt;
&lt;td&gt;however often you copy&lt;/td&gt;
&lt;td&gt;storage, and almost nothing else&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;A second site, kept warm&lt;/td&gt;
&lt;td&gt;minutes&lt;/td&gt;
&lt;td&gt;however far behind the copying runs&lt;/td&gt;
&lt;td&gt;a second bill, every month&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Two live sites, every write waits for both&lt;/td&gt;
&lt;td&gt;seconds&lt;/td&gt;
&lt;td&gt;near zero&lt;/td&gt;
&lt;td&gt;that second bill, plus something on every request&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cheaper and slower at the top. Faster and far more expensive at the bottom.&lt;/p&gt;

&lt;p&gt;For this shop, at fifty orders a minute, the difference between the rows is orders that were never placed at all:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Time down&lt;/th&gt;
&lt;th&gt;Orders never placed&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;A saved copy, and a rebuild&lt;/td&gt;
&lt;td&gt;67 minutes&lt;/td&gt;
&lt;td&gt;3,350&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;A second site, kept warm&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Two live sites&lt;/td&gt;
&lt;td&gt;20 seconds&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You do not shop for these three. You write down your two numbers, and exactly one row is left.&lt;/p&gt;




&lt;h2&gt;
  
  
  What RTO is bought with: capacity that sits there
&lt;/h2&gt;

&lt;p&gt;Moving RTO down means having capacity that is &lt;strong&gt;already running&lt;/strong&gt; when the failure happens. Not capacity you can create quickly. Capacity that is already there.&lt;/p&gt;

&lt;p&gt;A second site kept warm is machines that are running, patched, watched and paid for on every ordinary day.&lt;/p&gt;

&lt;p&gt;Say the production environment costs four thousand two hundred euros a month. A full warm twin doubles that. &lt;strong&gt;Fifty thousand four hundred euros a year.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And here is the shape of that purchase. The bill arrives twelve times a year, every year. The outage it exists for might arrive once. Or never.&lt;/p&gt;

&lt;p&gt;That is not an argument against buying it. It is an argument for knowing exactly what you are buying.&lt;/p&gt;




&lt;h2&gt;
  
  
  What RPO is bought with: waiting, on every write
&lt;/h2&gt;

&lt;p&gt;Now the other mark. This is where it gets interesting, because RPO is not bought with money. It is bought with waiting.&lt;/p&gt;

&lt;p&gt;Here is the same order, saved two ways.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# write.py -- the same order, saved two ways. Only the waiting differs.
&lt;/span&gt;&lt;span class="n"&gt;RTT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;               &lt;span class="c1"&gt;# a round trip to the second place, far away
&lt;/span&gt;&lt;span class="n"&gt;WRITES_PER_ORDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;      &lt;span class="c1"&gt;# one checkout touches six rows
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;      &lt;span class="c1"&gt;# RPO is the lag: about two seconds of orders
&lt;/span&gt;    &lt;span class="nf"&gt;write_here&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# the customer waits for this, and nothing else
&lt;/span&gt;    &lt;span class="nf"&gt;send_to_other_place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# this happens after the customer is gone
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;       &lt;span class="c1"&gt;# RPO is zero: nothing is ever lost
&lt;/span&gt;    &lt;span class="nf"&gt;write_here&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;wait_for_other_place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the customer waits for this too, every time
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RTT_MS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;WRITES_PER_ORDER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 72 -&amp;gt; milliseconds added to every order
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the first version, you write the order here, and send it to the other place afterwards. The customer waits for the first write and nothing else. RPO is whatever the sending falls behind by. Call it two seconds.&lt;/p&gt;

&lt;p&gt;In the second version, you write it here, and then you wait for the other place to say yes before you tell the customer their order went through. Now RPO is zero. Nothing is ever lost.&lt;/p&gt;

&lt;p&gt;But look at who is waiting. &lt;strong&gt;The customer is. Every time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the other place is far enough away that a round trip takes twelve milliseconds, and one checkout touches six rows, that is &lt;strong&gt;seventy-two milliseconds added to every order. Forever.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Seventy-two milliseconds does not sound like much. So multiply it out.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Orders a year, at 50 a minute&lt;/td&gt;
&lt;td&gt;26,280,000&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Added to each one&lt;/td&gt;
&lt;td&gt;72 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Added up, per year&lt;/td&gt;
&lt;td&gt;1,892,160 s = 525.6 hours = 21.9 days&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What it protects&lt;/td&gt;
&lt;td&gt;1.67 orders — EUR 63 — on the one bad day&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Almost twenty-two whole days of human waiting, added up, every single year. To buy back sixty-three euros of orders on a day that may or may not ever come.&lt;/p&gt;

&lt;p&gt;That is what driving RPO all the way to zero actually costs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The step that is worth it, and the step right next to it
&lt;/h2&gt;

&lt;p&gt;Now put the money side by side. This shop loses one thousand nine hundred euros for every minute it is down — fifty orders a minute, thirty-eight euros an order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one. From sixty-seven minutes down to five.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That costs one more full environment: fifty thousand four hundred euros a year. It saves sixty-two minutes of downtime, which is &lt;strong&gt;one hundred and seventeen thousand, eight hundred euros per outage&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Break-even is &lt;strong&gt;0.43 outages a year&lt;/strong&gt;. Have one outage every two years, and it has already paid for itself. That is an easy yes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two. From five minutes down to twenty seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That costs another environment, and now every write waits for the far side. It saves four minutes and forty seconds, which is &lt;strong&gt;eight thousand, eight hundred and sixty-seven euros per outage&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Break-even is &lt;strong&gt;5.7 outages a year&lt;/strong&gt;. And you pay the five hundred and twenty-five hours of customer waiting either way.&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;Step one: 67 min → 5 min&lt;/th&gt;
&lt;th&gt;Step two: 5 min → 20 s&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Saved per outage&lt;/td&gt;
&lt;td&gt;EUR 117,800&lt;/td&gt;
&lt;td&gt;EUR 8,867&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Costs&lt;/td&gt;
&lt;td&gt;EUR 50,400 a year&lt;/td&gt;
&lt;td&gt;another EUR 50,400 a year&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Plus&lt;/td&gt;
&lt;td&gt;nothing on the request path&lt;/td&gt;
&lt;td&gt;525.6 hours of waiting a year&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Break-even&lt;/td&gt;
&lt;td&gt;0.43 outages a year&lt;/td&gt;
&lt;td&gt;5.7 outages a year&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Verdict&lt;/td&gt;
&lt;td&gt;an easy yes&lt;/td&gt;
&lt;td&gt;almost certainly no&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same ladder. Same shop. Same arithmetic. &lt;strong&gt;The step that is obviously worth it and the step that is obviously not are right next to each other.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the shape to remember. Both marks move a long way for money you can put in a budget. It is the last small step — pushing either mark all the way onto the failure itself — where the price stops being money and starts being waiting on every request you will ever serve.&lt;/p&gt;




&lt;h2&gt;
  
  
  How you actually pick
&lt;/h2&gt;

&lt;p&gt;You ask the business two questions, and you write the answers down before anybody says the word "backup".&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;How long may we be shut before it really hurts?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How much work may vanish before it really hurts?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those two answers are your RTO and your RPO. Everything after that is arithmetic, and the arithmetic is genuinely this small.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pick.py -- you do not pick a strategy. You pick two numbers.
# Ask the business first, and write both answers down before anything else.
&lt;/span&gt;&lt;span class="n"&gt;rto_minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;      &lt;span class="c1"&gt;# how long may we be shut before it really hurts?
&lt;/span&gt;&lt;span class="n"&gt;rpo_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;      &lt;span class="c1"&gt;# how much work may vanish before it really hurts?
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rto_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rpo_seconds&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rto_minutes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rpo_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a saved copy, and a rebuild&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;        &lt;span class="c1"&gt;# cheapest. Hours.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rto_minutes&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="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rpo_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;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;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a second site, kept warm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;           &lt;span class="c1"&gt;# minutes
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;two live sites, and every write waits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# seconds, and it hurts
&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;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rto_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rpo_seconds&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# a second site, kept warm
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two numbers came first. The tier came out of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three questions you can answer this afternoon
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First.&lt;/strong&gt; If the main copy of your data disappeared right now, how many minutes of work would be gone? Not on average. In the worst case. If nobody can say the number, you do not have an RPO — you have a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second.&lt;/strong&gt; Add up your six terms: seeing the alert, deciding it is real, getting machines, putting the data back, checking it, switching traffic over. What is your floor once you remove the restore? If that floor is already above what you promised, buying a faster restore cannot save you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third.&lt;/strong&gt; Who agreed to these two numbers? If the answer is "the engineers", they are estimates. They become objectives when the business says them out loud.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&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;RTO&lt;/th&gt;
&lt;th&gt;RPO&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;The question&lt;/td&gt;
&lt;td&gt;How long may we be down?&lt;/td&gt;
&lt;td&gt;How much work may vanish?&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Measured&lt;/td&gt;
&lt;td&gt;forwards from the break&lt;/td&gt;
&lt;td&gt;backwards from the break&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Set by&lt;/td&gt;
&lt;td&gt;a sum of six terms, only one of which is the restore&lt;/td&gt;
&lt;td&gt;how often you copy — nothing else&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Bought with&lt;/td&gt;
&lt;td&gt;capacity running on every ordinary day&lt;/td&gt;
&lt;td&gt;a wait added to every write, forever&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;You pay&lt;/td&gt;
&lt;td&gt;whether or not anything ever breaks&lt;/td&gt;
&lt;td&gt;on every request you will ever serve&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Fails quietly when&lt;/td&gt;
&lt;td&gt;the budget all goes to a faster restore&lt;/td&gt;
&lt;td&gt;the schedule is quoted as an average&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;RTO is how long you may be down&lt;/strong&gt;, and you buy it with machines that sit there costing money while nothing is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RPO is how much work may vanish&lt;/strong&gt;, and you buy it with a wait added to every write, forever.&lt;/p&gt;

&lt;p&gt;Write both numbers down before anybody says the words "backup", "standby" or "second region". After that, the tier is the only thing left to read off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You do not pick a disaster recovery strategy. You pick two numbers, and the strategy falls out of them.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the two definitions&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NIST Special Publication 800-34 Rev. 1, &lt;em&gt;Contingency Planning Guide for Federal Information Systems&lt;/em&gt; (NIST, 2010) — the formal definitions both terms come from: RTO as the maximum time a resource may be unavailable before the impact is unacceptable, and RPO as the point in time to which data can be recovered, which in practice is how much data loss the organisation can tolerate: &lt;a href="https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-34r1.pdf" rel="noopener noreferrer"&gt;nvlpubs.nist.gov&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On writing the two numbers down with the business&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Laine Campbell and Charity Majors, &lt;em&gt;Database Reliability Engineering&lt;/em&gt; (O'Reilly, 2017), ch. 2 "Service-Level Management" and ch. 7 "Backup and Recovery" — the same two numbers, under the names availability and durability, treated as service-level indicators agreed in advance. Durability is defined there in exactly this shape: "in the event of a system failure, no more than the past two seconds of data can be lost." Chapter 7 then opens the recovery-strategy discussion by sending you back to those objectives first.&lt;/li&gt;
&lt;li&gt;Betsy Beyer, Niall Richard Murphy, David K. Rensin, Kent Kawahara and Stephen Thorne (eds.), &lt;em&gt;The Site Reliability Workbook&lt;/em&gt; (O'Reilly, 2018), ch. 2 "Implementing SLOs" — how a reliability target is chosen with the people who own the product rather than by the team that will engineer to it, and why the rationale behind the number gets written down with it: &lt;a href="https://sre.google/workbook/implementing-slos/" rel="noopener noreferrer"&gt;sre.google/workbook/implementing-slos&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the restore being the small term&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Betsy Beyer, Chris Jones, Jennifer Petoff and Niall Richard Murphy (eds.), &lt;em&gt;Site Reliability Engineering&lt;/em&gt; (O'Reilly, 2016), ch. 26 "Data Integrity: What You Read Is What You Wrote" — "No one really &lt;em&gt;wants&lt;/em&gt; to make backups; what people &lt;em&gt;really&lt;/em&gt; want are &lt;em&gt;restores&lt;/em&gt;", and the requirement that recovery finish "well within the uptime needs of a service", which is the RTO by another name: &lt;a href="https://sre.google/sre-book/data-integrity/" rel="noopener noreferrer"&gt;sre.google/sre-book/data-integrity&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On what the last step of RPO costs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (O'Reilly, 2017), ch. 5 "Replication", section "Synchronous Versus Asynchronous Replication" — waiting for the second copy to acknowledge is what removes the loss window, and it is also what puts an unbounded wait on the write path, which is why almost all replication is asynchronous in practice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the last rung costing more than the outage&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Betsy Beyer et al. (eds.), &lt;em&gt;Site Reliability Engineering&lt;/em&gt; (O'Reilly, 2016), ch. 3 "Embracing Risk" — reliability is not maximised, it is targeted: "an incremental improvement in reliability may cost 100x more than the previous increment", and the target is treated as both a minimum and a maximum: &lt;a href="https://sre.google/sre-book/embracing-risk/" rel="noopener noreferrer"&gt;sre.google/sre-book/embracing-risk&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the full episode:&lt;/strong&gt; &lt;a href="https://youtu.be/koRSzvlIX1Q" rel="noopener noreferrer"&gt;RTO vs RPO on YouTube&lt;/a&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cache Invalidation: The Delete Ran, and the Page Is Still Wrong</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sun, 30 Aug 2026 14:56:48 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/cache-invalidation-the-delete-ran-and-the-page-is-still-wrong-55g2</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/cache-invalidation-the-delete-ran-and-the-page-is-still-wrong-55g2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/r2kGBLfmyv0" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/cache-invalidation-the-delete-ran-and-the-page-is-still-wrong?id=184" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The ticket that makes no sense
&lt;/h2&gt;

&lt;p&gt;A user changes her email address. The form says &lt;strong&gt;saved&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;She reloads the page. The old address is still there.&lt;/p&gt;

&lt;p&gt;You go and look, because you wrote this code and you know it is fine. The database has the new value. You check the logs: the &lt;code&gt;UPDATE&lt;/code&gt; returned ok. The cache delete right after it returned ok too. There is no exception, no timeout, no retry, no failed job sitting in a queue. Every single call in that request succeeded.&lt;/p&gt;

&lt;p&gt;And the cache is still holding the old email, with &lt;strong&gt;five minutes&lt;/strong&gt; left on its timer.&lt;/p&gt;

&lt;p&gt;Nothing failed. Nobody forgot anything. So what actually happened?&lt;/p&gt;




&lt;h2&gt;
  
  
  What this article covers, and what it does not
&lt;/h2&gt;

&lt;p&gt;Three things are covered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why an invalidation that &lt;strong&gt;provably ran&lt;/strong&gt; can leave a stale value behind — and why the delete was not too late, it was too &lt;strong&gt;early&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The three moves that fix it, in order, each one measured rather than asserted.&lt;/li&gt;
&lt;li&gt;The honest limit: the class of cached answers you cannot invalidate at all, and what the workaround costs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five things are deliberately not covered, because each one is its own subject and gets confused with this one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is not the dual-write problem.&lt;/strong&gt; That is the case where the second write never happened — the database committed and the cache call died. Here the cache call ran and returned successfully. If your failure mode is a lost second write, you want an outbox, not this article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not the write path.&lt;/strong&gt; &lt;em&gt;Write-through&lt;/em&gt; and &lt;em&gt;write-back&lt;/em&gt; are about whether writes go through the cache at all. Everything below assumes &lt;strong&gt;cache-aside&lt;/strong&gt;: your application code reads the cache, falls back to the database, and fills the cache itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not the stampede.&lt;/strong&gt; Invalidating a hot key sends every waiting reader to the database at the same instant. That is a real and separate cost, and it is a real and separate article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not eviction.&lt;/strong&gt; LRU and LFU decide what to throw away when memory runs out. Eviction is about &lt;strong&gt;space&lt;/strong&gt;. Invalidation is about &lt;strong&gt;truth&lt;/strong&gt;. They both remove keys and that is the entire similarity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not HTTP caching.&lt;/strong&gt; No browser caches, no &lt;code&gt;ETag&lt;/code&gt;, no &lt;code&gt;max-age&lt;/code&gt;, no CDN purge. Everything here happens on your side of the wire, inside your own service.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  One system, drawn once
&lt;/h2&gt;

&lt;p&gt;There are exactly three things, and everything below is a mutation of this picture.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;service&lt;/strong&gt;. Beside it a &lt;strong&gt;cache&lt;/strong&gt; — say one Redis. Under it a &lt;strong&gt;database&lt;/strong&gt; — say one Postgres primary. The service talks to both. The cache and the database &lt;strong&gt;never talk to each other&lt;/strong&gt;. Nothing keeps them in step except the code you write.&lt;/p&gt;

&lt;p&gt;The read path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# every read goes through here. ex=300 is five minutes.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hit&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;return&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;          &lt;span class="c1"&gt;# a hit. No database call.
&lt;/span&gt;    &lt;span class="n"&gt;row&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="nf"&gt;query_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;SELECT * FROM users WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
    &lt;span class="n"&gt;cache&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&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;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# a miss. Fill it back.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the write path — the one in the ticket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the write path. Two calls, and both of them succeed.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_email&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="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE users SET email = %s WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;saved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Order matters. Database first, cache second.
# Delete before the commit and a reader can refill the
# key from the old row before the new one is even there.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at those two functions. They are correct. That is the point of the article.&lt;/p&gt;




&lt;h2&gt;
  
  
  The race: the delete was too early
&lt;/h2&gt;

&lt;p&gt;Put a second person in the picture. Not a second writer — a &lt;strong&gt;reader&lt;/strong&gt;, doing nothing unusual.&lt;/p&gt;

&lt;p&gt;Follow one clock:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A read arrives. Cache &lt;strong&gt;miss&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;That reader runs &lt;code&gt;SELECT&lt;/code&gt; and gets the &lt;strong&gt;old&lt;/strong&gt; row. It is now holding that value in a local variable, on its way to &lt;code&gt;cache.set&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The writer commits &lt;code&gt;UPDATE&lt;/code&gt;. The database now holds the &lt;strong&gt;new&lt;/strong&gt; value.&lt;/li&gt;
&lt;li&gt;The writer runs &lt;code&gt;DELETE user:42&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The reader — a couple of milliseconds late — finally runs &lt;code&gt;cache.set(key, old_row, ex=300)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The database says new. The cache says old. And the cache has a &lt;strong&gt;full five-minute timer&lt;/strong&gt; ahead of it, because step 5 set a fresh one.&lt;/p&gt;

&lt;p&gt;Here is the part that surprises people, measured on a real Redis 7.4.9 rather than reasoned about. In step 4, the delete returns:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;4. writer DEL user:42 -&amp;gt; DEL returned 0
   (key existed before = 0, exists after = 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Zero.&lt;/strong&gt; The delete removed nothing, and it removed nothing because &lt;em&gt;there was nothing there yet&lt;/em&gt;. The stale value was still in flight inside another thread. You cannot delete a value that has not been written.&lt;/p&gt;

&lt;p&gt;Say that out loud, because it is the whole idea: &lt;strong&gt;the invalidation was not too late. It was too early.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And it is not a fluke of the cold cache. A second run was built where a different reader had already cached the old value, so the delete had a real victim and returned &lt;code&gt;1&lt;/code&gt;. Same ending: database new, cache old, five minutes on the clock. A delete that provably removed a real key is still undone by one slow reader.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often, honestly
&lt;/h3&gt;

&lt;p&gt;The tempting thing to do here is put a percentage on the screen. It would be wrong, because there is no single rate. The rate is a ratio:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;how long a reader takes between reading the row and writing it to the cache, over how often writes arrive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Timing every step of 5,000 runs, that predicate predicted the actual outcome &lt;strong&gt;99.8–100%&lt;/strong&gt; of the time — the mechanism is verified, not assumed. But the rate moved enormously with database commit latency:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Reader gap (row read → cache set)&lt;/th&gt;
&lt;th&gt;With a ~3.3 ms fsync commit&lt;/th&gt;
&lt;th&gt;With a ~0.02 ms commit&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;0 / 500 (0.00%)&lt;/td&gt;
&lt;td&gt;1 / 500 (0.20%)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2 ms&lt;/td&gt;
&lt;td&gt;0 / 500 (0.00%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56 / 500 (11.20%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;td&gt;31 / 500 (6.20%)&lt;/td&gt;
&lt;td&gt;122 / 500 (24.40%)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;10 ms&lt;/td&gt;
&lt;td&gt;148 / 500 (29.60%)&lt;/td&gt;
&lt;td&gt;264 / 500 (52.80%)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;20 ms&lt;/td&gt;
&lt;td&gt;400 / 500 (80.00%)&lt;/td&gt;
&lt;td&gt;416 / 500 (83.20%)&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Below a 3 ms gap with slow commits it is literally &lt;code&gt;0/500&lt;/code&gt;, because the writer physically cannot finish before the reader has already filled the cache. Make your database faster and the window opens. This is not a bug you can benchmark your way out of.&lt;/p&gt;

&lt;h3&gt;
  
  
  The case that actually hurts
&lt;/h3&gt;

&lt;p&gt;Under continuous writes to the same row this heals itself — the next writer's delete clears the poisoned key. Measured under eight readers and one writer hammering the same row, the cache held a wrong value at &lt;strong&gt;0.43%&lt;/strong&gt; of sampled instants, and &lt;strong&gt;0.48%&lt;/strong&gt; of hits were wrong: about 68 wrong answers in six seconds, in short bursts.&lt;/p&gt;

&lt;p&gt;The dangerous case is the opposite one. &lt;strong&gt;A row nobody writes again.&lt;/strong&gt; A profile edited once. A price changed once. A feature flag flipped once. There is no next writer to heal it, so the wrong value sits there for the entire remaining TTL, answering every read.&lt;/p&gt;

&lt;p&gt;That is the common case, and it is the expensive one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Move 1: delete, do not update
&lt;/h2&gt;

&lt;p&gt;The first instinct after seeing this is to make the write path &lt;em&gt;more&lt;/em&gt; authoritative: I already know the new value, so let me put it straight into the cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tempting, and wrong: it writes a value you computed
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change_email_bad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;row&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="nf"&gt;update_and_return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cache&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;row&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;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# what you actually want: it writes no value at all
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change_email_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_email&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="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE users SET email = %s WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the first one is worse: two writers can commit in the order A then B, and have their &lt;strong&gt;cache&lt;/strong&gt; writes arrive in the order B then A. The database ends holding B, which is right. The cache ends holding A — a value that nobody wrote last — and it holds it for a full TTL.&lt;/p&gt;

&lt;p&gt;Two concurrent writers, 1,500 runs each way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;Cache order ≠ commit order&lt;/th&gt;
&lt;th&gt;Cache ≠ database at the end&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;write, then &lt;code&gt;SET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;590 / 1500 (39.33%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;589 / 1500 (39.27%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;write, then &lt;code&gt;DELETE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;573 / 1500 (38.20%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 1500 (0.00%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reorder happens just as often either way — 39% versus 38%, that is the same physics. Under &lt;code&gt;SET&lt;/code&gt;, essentially &lt;strong&gt;every&lt;/strong&gt; reorder produced a wrong cache. Under &lt;code&gt;DELETE&lt;/code&gt;, &lt;strong&gt;none&lt;/strong&gt; did, because a delete carries no value to get out of order. It says &lt;em&gt;"whatever is there is now suspect"&lt;/em&gt;, and the next reader re-reads the truth.&lt;/p&gt;

&lt;p&gt;Two honest qualifications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs you a miss.&lt;/strong&gt; That miss is priced:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Hit&lt;/th&gt;
&lt;th&gt;Miss (get + select + set)&lt;/th&gt;
&lt;th&gt;Extra cost of the miss&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Local database, no network&lt;/td&gt;
&lt;td&gt;91.9 µs&lt;/td&gt;
&lt;td&gt;176.4 µs&lt;/td&gt;
&lt;td&gt;+84.6 µs&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;With a 5 ms database round trip&lt;/td&gt;
&lt;td&gt;77.5 µs&lt;/td&gt;
&lt;td&gt;6.91 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+6.84 ms, once&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven milliseconds, one time. Compare that against what the &lt;code&gt;SET&lt;/code&gt; policy buys you in the next section: five figures of wrong answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it does not fix everything.&lt;/strong&gt; &lt;code&gt;DELETE&lt;/code&gt; removes the writer-versus-writer reorder completely. It does &lt;strong&gt;not&lt;/strong&gt; remove the read-path race from the section above — that one is still there, and it is what versioned keys and a sane TTL are for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Move 2: put a version in the name
&lt;/h2&gt;

&lt;p&gt;The second problem is not correctness, it is bookkeeping. One user does not have one cached key. She has a profile, her settings, five pages of feed, her friends list, her badges, her unread count, her avatar. Eleven keys, in this example.&lt;/p&gt;

&lt;p&gt;When her row changes, which of those do you delete?&lt;/p&gt;

&lt;p&gt;You could hunt. Measured on a 200,000-key cache:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;How you find them&lt;/th&gt;
&lt;th&gt;Wall time&lt;/th&gt;
&lt;th&gt;Round trips&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;All 11 in one variadic &lt;code&gt;DEL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.16 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;you must know all 11 names&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;11 named &lt;code&gt;DEL&lt;/code&gt;s, one at a time&lt;/td&gt;
&lt;td&gt;1.93 ms&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;you must know all 11 names&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;KEYS user:42:*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12.4 ms&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;blocks the whole server&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;SCAN MATCH user:42:* COUNT=1000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;58.0 ms&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;safe, but 200 trips&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;KEYS *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;299.7 ms&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;blocks the whole server&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason &lt;code&gt;KEYS&lt;/code&gt; is disqualified is not its own runtime. Redis executes commands on a single thread, so while &lt;code&gt;KEYS *&lt;/code&gt; runs, &lt;em&gt;everyone else waits&lt;/em&gt;. Latency of an unrelated &lt;code&gt;GET&lt;/code&gt; from a second client, measured during it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Server state&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p99&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;idle&lt;/td&gt;
&lt;td&gt;0.079 ms&lt;/td&gt;
&lt;td&gt;0.177 ms&lt;/td&gt;
&lt;td&gt;0.279 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;while &lt;code&gt;KEYS *&lt;/code&gt; runs&lt;/td&gt;
&lt;td&gt;0.721 ms&lt;/td&gt;
&lt;td&gt;246.9 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;516.2 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An unrelated &lt;code&gt;GET&lt;/code&gt; got &lt;strong&gt;1,851× worse at the tail&lt;/strong&gt;. &lt;code&gt;SCAN&lt;/code&gt; exists precisely so you never do that.&lt;/p&gt;

&lt;p&gt;But look again at the top row of the hunt table. &lt;strong&gt;If you genuinely know all eleven names, one variadic &lt;code&gt;DEL&lt;/code&gt; does it in 0.16 ms&lt;/strong&gt; — faster than any alternative here. So the cost of the hunt was never round trips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost of the hunt is knowing the names.&lt;/strong&gt; They are scattered across a codebase, built by string concatenation in six different modules, and the bug is always the twelfth key somebody added last month and forgot to add to the list. The list &lt;em&gt;is&lt;/em&gt; the bug.&lt;/p&gt;

&lt;p&gt;So stop keeping a list. Put a version in the name instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the key carries a version number for that one user
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;key_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:ver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:%s:v%s:%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# invalidating every derived key for one user
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invalidate_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:ver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# One call. Every old key is now unreachable, because
# nothing composes those names any more. You never had
# to know how many there were, or what they were called.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;INCR&lt;/code&gt;, 0.701 ms, one round trip. Keys a &lt;code&gt;v8&lt;/code&gt; reader can still reach: &lt;strong&gt;0 of 11&lt;/strong&gt;. Not because they were deleted — because nothing constructs those names any more.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest cost: orphans
&lt;/h3&gt;

&lt;p&gt;Nothing about this is free, and the bill is memory.&lt;/p&gt;

&lt;p&gt;After the bump, all &lt;strong&gt;11 of 11&lt;/strong&gt; old keys are still resident, with &lt;strong&gt;3,600 seconds&lt;/strong&gt; of TTL left on them. Nothing reclaims them early. They held 16,552 bytes for one user — about 1,505 bytes per key. Scale that: &lt;strong&gt;100,000 users bumping once is roughly 1.7 GB&lt;/strong&gt; of unreachable data sitting in your cache until it expires.&lt;/p&gt;

&lt;p&gt;That is only survivable under one of two conditions: &lt;strong&gt;every versioned key has a TTL&lt;/strong&gt;, or your &lt;code&gt;maxmemory-policy&lt;/code&gt; is an LRU/LFU variant that will evict them. Redis defaults to &lt;code&gt;noeviction&lt;/code&gt;, which means an orphan pile eventually stops accepting writes rather than making room.&lt;/p&gt;

&lt;p&gt;Versioning trades &lt;strong&gt;memory you cannot reach&lt;/strong&gt; for &lt;strong&gt;invalidation you cannot get wrong&lt;/strong&gt;. That is usually a good trade. It is not a free one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Move 3: the timer is a backstop, not a plan
&lt;/h2&gt;

&lt;p&gt;Every cache line you write has an &lt;code&gt;ex=&lt;/code&gt;, and it is the most misunderstood argument in the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the TTL is not the plan. It is the bound.
&lt;/span&gt;&lt;span class="n"&gt;cache&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&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;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# what that one argument actually promises:
#   it does NOT make the copy correct
#   it DOES cap how long a wrong copy can live
#   so pick it from what a stale answer costs you,
#   not from how much memory you happen to have
&lt;/span&gt;
&lt;span class="c1"&gt;# and this line says: be wrong forever
&lt;/span&gt;&lt;span class="n"&gt;cache&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A TTL has never once made a cached value correct. All it does is put a ceiling on how long a wrong one is allowed to keep answering. So price it that way. One incident, one poisoned key, fifty reads a second:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;TTL&lt;/th&gt;
&lt;th&gt;Wrong answers served&lt;/th&gt;
&lt;th&gt;Self-heals?&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;251 (measured)&lt;/td&gt;
&lt;td&gt;yes, after 5.02 s&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;10 s&lt;/td&gt;
&lt;td&gt;500 (measured)&lt;/td&gt;
&lt;td&gt;yes, after 10.00 s&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;1,500 (measured)&lt;/td&gt;
&lt;td&gt;yes, after 30.00 s&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;~15,000 (extrapolated)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;~180,000 (extrapolated)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;unbounded&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The measured rate came out at &lt;strong&gt;50.07 wrong answers per second of TTL&lt;/strong&gt; at fifty reads a second — linear enough that the extrapolations are safe. And the rate is what makes the number mean anything: "fifteen thousand" is only true &lt;em&gt;at fifty reads a second&lt;/em&gt;, on a row nobody writes again.&lt;/p&gt;

&lt;p&gt;Which reframes the question you should be asking. Not "how long can I cache this for?" but &lt;strong&gt;"how many wrong answers is this row worth?"&lt;/strong&gt; A stale marketing headline: thousands, fine. A stale account balance, permission check, or price: the honest answer might be zero, and that is a signal not to cache that read at all.&lt;/p&gt;

&lt;p&gt;The last line in that snippet — &lt;code&gt;cache.set(key, row)&lt;/code&gt; with no expiry — is a promise to be wrong forever if anything ever goes sideways. It is worth grepping for.&lt;/p&gt;




&lt;h2&gt;
  
  
  The limit: you cannot invalidate what you cannot name
&lt;/h2&gt;

&lt;p&gt;Everything above assumes the cached value has a name derived from the thing that changed. &lt;code&gt;user:42:profile&lt;/code&gt; obviously belongs to user 42.&lt;/p&gt;

&lt;p&gt;Now cache a search result. The key is a hash of the query — &lt;code&gt;search:results:&amp;lt;sha1&amp;gt;&lt;/code&gt; — and the value happens to contain twenty user records. Or cache &lt;code&gt;leaderboard:top10&lt;/code&gt;, computed from every score in the system.&lt;/p&gt;

&lt;p&gt;User 42 changes her name. &lt;strong&gt;Which keys do you delete?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no answer. The key name is a function of the &lt;em&gt;query&lt;/em&gt;, not of user 42. &lt;code&gt;leaderboard:top10&lt;/code&gt; does not mention a user at all. Every classic move above is unavailable, because all of them start with a name.&lt;/p&gt;

&lt;p&gt;The only way back is to write the mapping down as you fill the cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# when you cache a COMPUTED answer, write down which
# rows it was built from. That list is the only way
# back from a changed row to the keys holding it.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cache_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cache&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="n"&gt;query_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sadd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag:user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;query_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# when a row changes, read its list and delete them
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invalidate_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag:user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&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;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;smembers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over 5,000 cached search pages, finding the 40 keys that embed user 42:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Found&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Round trips&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Brute force: scan, fetch and parse all 5,000 values&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;40.8 ms&lt;/td&gt;
&lt;td&gt;10 scans + 5,000 values parsed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Tag set: &lt;code&gt;SMEMBERS tag:user:42&lt;/code&gt; then delete&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.860 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;47× faster&lt;/strong&gt;, and the gap widens as the keyspace grows, because the tag lookup does not care how many keys exist.&lt;/p&gt;

&lt;p&gt;And here is what it costs, which is the part usually left out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;20× write amplification.&lt;/strong&gt; Building the index for those 5,000 pages took 100,000 &lt;code&gt;SADD&lt;/code&gt; calls — one per embedded entity, on every single cache fill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;+130% memory.&lt;/strong&gt; 2.58 MB of cached pages became 5.93 MB. The reverse index more than doubled the workload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dangling members forever.&lt;/strong&gt; After invalidating user 42, the neighbouring &lt;code&gt;tag:user:43&lt;/code&gt; set still listed two keys that no longer exist. Nothing cleans them. Tag sets grow without bound unless you also keep a &lt;code&gt;key → tags&lt;/code&gt; reverse map (doubling the write amplification again) or put a TTL on the tag sets themselves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tags turn an impossible problem into a &lt;strong&gt;solvable but expensive&lt;/strong&gt; one, and they introduce a second garbage-collection problem of their own. That is the trade. Make it deliberately, on the handful of computed keys that actually need it — not as a default for everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same problem, wearing an AI hat
&lt;/h2&gt;

&lt;p&gt;If you are serving an LLM application, you already have three caches and probably have not named them as caches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The semantic cache is the unnameable-key problem, exactly.&lt;/strong&gt; You store an answer under an embedding of the question so that a similar question can reuse it. Now a document changes, or a price changes, or a policy changes. Which cached answers were built from that fact? The key is a vector derived from the &lt;em&gt;question&lt;/em&gt; — it says nothing about which sources produced the answer. There is no name to delete. This is the search-result case from the section above, and the way out is the same: record which retrieved chunks fed each cached answer, tag by source document, and accept the write amplification. If you cannot afford that bookkeeping, the honest fallback is a short TTL, priced by the table above — &lt;em&gt;how many wrong answers is this document worth?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The RAG index is derived data with a version.&lt;/strong&gt; Re-embedding after a chunker change, an embedding-model swap, or a bulk document update is the version bump from move 2 — a new index name, atomically switched, rather than a hunt for which vectors are stale. The orphan bill is the same too: the old index stays resident and costs money until something deletes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the write-then-delete rule survives intact.&lt;/strong&gt; Two pipelines that re-embed the same document concurrently can have their index writes land in the opposite order to their source commits, leaving the index holding a vector nobody wrote last. Invalidating the entry and letting the next read re-embed removes that class of bug for the price of one miss — and in this setting the miss is a real embedding call, so it is worth measuring rather than assuming.&lt;/p&gt;

&lt;p&gt;The provider-side prompt cache is the one you do not control: it has its own timer and its own key derivation, so treat it as a bound you were given, not a plan you made.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;The delete ran. That was never the problem.&lt;/p&gt;

&lt;p&gt;Invalidation fails because of &lt;strong&gt;ordering&lt;/strong&gt;, not omission: a reader that read before the write and wrote after it, holding a stale value in a variable across the exact moment the delete fired. The delete was too early, not too late.&lt;/p&gt;

&lt;p&gt;So, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete, never update.&lt;/strong&gt; 589 divergences in 1,500 runs the other way, zero this way. It costs one miss — about 7 ms once, against a five-figure count of wrong answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put a version in the name&lt;/strong&gt; when a row has many derived keys. Not because it is faster — a variadic &lt;code&gt;DEL&lt;/code&gt; beats it — but because you never needed the list of names, and the list was always the bug. Budget for the orphans, and give every versioned key a TTL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the TTL as a bound&lt;/strong&gt;, and choose it from what a wrong answer costs rather than from how much memory you have. A cache line with no &lt;code&gt;ex=&lt;/code&gt; is a promise to be wrong forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And then the limit: &lt;strong&gt;you cannot invalidate what you cannot name.&lt;/strong&gt; For computed answers — search results, leaderboards, semantically cached LLM responses — either write down the mapping and pay for it, or admit you are relying on a timer and price that timer honestly.&lt;/p&gt;

&lt;p&gt;The two-hard-things joke gets repeated because it is funny. The real reason invalidation is hard is duller and more useful: a cache is a second copy of the truth, kept in step by nothing but your own code, on a clock that two independent parties are writing to at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the pattern itself&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft, &lt;em&gt;Cache-Aside pattern&lt;/em&gt;, Azure Architecture Center — the pattern every code sample above assumes: the application, not the cache, is responsible for loading data on a miss and for removing it when the underlying data changes, with the consistency caveats stated plainly: &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside" rel="noopener noreferrer"&gt;learn.microsoft.com/azure/architecture/patterns/cache-aside&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On deleting rather than updating, and on the read-path race&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rajesh Nishtala et al., &lt;em&gt;Scaling Memcache at Facebook&lt;/em&gt; (USENIX NSDI, 2013) — the production argument for deleting cached data instead of updating it (deletes are idempotent, so concurrent invalidations cannot reorder into a wrong value), and the "stale set" race in which a reader holding an old value writes it back after the invalidation — the exact failure this article opens on, which is why the paper introduces leases to close it: &lt;a href="https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf" rel="noopener noreferrer"&gt;usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On finding the keys, and why &lt;code&gt;KEYS&lt;/code&gt; is disqualified&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis, &lt;em&gt;&lt;code&gt;SCAN&lt;/code&gt; command reference&lt;/em&gt; — the cursor-based iteration guarantees, and the explicit warning that &lt;code&gt;KEYS&lt;/code&gt; may block the server for a long time on a large keyspace, which is what the tail-latency measurement above shows in practice: &lt;a href="https://redis.io/docs/latest/commands/scan/" rel="noopener noreferrer"&gt;redis.io/docs/latest/commands/scan&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On what a TTL actually promises&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis, &lt;em&gt;&lt;code&gt;EXPIRE&lt;/code&gt; command reference and key expiration semantics&lt;/em&gt; — how expiry is stored with the key, how it is cleared by a write that replaces the value, and the lazy plus active expiration model that decides when the key really goes away: &lt;a href="https://redis.io/docs/latest/commands/expire/" rel="noopener noreferrer"&gt;redis.io/docs/latest/commands/expire&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the orphans left behind by versioned keys&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis, &lt;em&gt;Key eviction and &lt;code&gt;maxmemory-policy&lt;/code&gt;&lt;/em&gt; — the available policies and the fact that the default is &lt;code&gt;noeviction&lt;/code&gt;, which is why unreachable versioned keys are only reclaimed by their own TTL and can otherwise fill the instance until writes start failing: &lt;a href="https://redis.io/docs/latest/develop/reference/eviction/" rel="noopener noreferrer"&gt;redis.io/docs/latest/develop/reference/eviction&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On caches as derived data — the "cannot name it" limit&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (O'Reilly, 2017), ch. 11 "Stream Processing", sections on change data capture and derived data — a cache is a derived view of a system of record, and keeping derived views correct by ad-hoc writes scattered through application code is precisely what produces the inconsistencies above; a single ordered stream of changes is the alternative framing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the full episode:&lt;/strong&gt; &lt;a href="https://youtu.be/r2kGBLfmyv0" rel="noopener noreferrer"&gt;Cache Invalidation — Explained in Detail&lt;/a&gt; · &lt;a href="https://youtube.com/shorts/g98sZl_esGE" rel="noopener noreferrer"&gt;the short version&lt;/a&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>redis</category>
    </item>
    <item>
      <title>Continuous Integration vs Continuous Delivery: The Two Halves Almost Nobody Separates</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sat, 29 Aug 2026 19:56:36 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/continuous-integration-vs-continuous-delivery-the-two-halves-almost-nobody-separates-13jf</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/continuous-integration-vs-continuous-delivery-the-two-halves-almost-nobody-separates-13jf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/J0JcRX0jokM" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/continuous-integration-vs-continuous-delivery-the-two-halves-almost-nobody-separates?id=183" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Almost everybody writes these two words as one word. CI/CD. It gets said like the name of a product you install.&lt;/p&gt;

&lt;p&gt;It is not one thing. It is two rules. They are about two different objects, and they point in opposite directions. Most teams follow the first one and stop there, and they do not notice they stopped, because the two words have been glued together for so long.&lt;/p&gt;

&lt;p&gt;This article takes them apart.&lt;/p&gt;




&lt;h2&gt;
  
  
  Merge day
&lt;/h2&gt;

&lt;p&gt;Start with the thing that made you look this up.&lt;/p&gt;

&lt;p&gt;Six developers. One codebase. Six branches open at the same time. A &lt;strong&gt;branch&lt;/strong&gt; is your own private copy of the code, where you work without disturbing anyone else. Yours opened on a Monday. You are building one feature, and it touches eighteen files.&lt;/p&gt;

&lt;p&gt;While you work, the other five keep working too. Each of them lands one change on the shared &lt;strong&gt;trunk&lt;/strong&gt; — the one main copy of the code that everybody shares — every day. You do not see any of it.&lt;/p&gt;

&lt;p&gt;After one day, five changes have landed underneath you. That is fine.&lt;/p&gt;

&lt;p&gt;After six weeks, one hundred and fifty changes have landed underneath you. Your branch has not moved. The ground it was standing on has.&lt;/p&gt;

&lt;p&gt;Then comes merge day. Nobody put merge day in the plan. Nobody estimated it. But it is a week of work, it happens every single time, and the team has quietly decided that this is simply what software feels like.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this article covers, and what it does not
&lt;/h2&gt;

&lt;p&gt;Three things are covered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What continuous integration actually is, and why it is a rule about &lt;strong&gt;people&lt;/strong&gt; rather than a piece of software.&lt;/li&gt;
&lt;li&gt;What continuous delivery actually is, and why it is a rule about &lt;strong&gt;one object&lt;/strong&gt;, called the artifact.&lt;/li&gt;
&lt;li&gt;The third word, &lt;strong&gt;deployment&lt;/strong&gt;, which is not the same as delivery, and which is where most of the arguments come from.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three things are not covered. &lt;strong&gt;No tool is named anywhere in this article.&lt;/strong&gt; Not one. How a release is rolled out to users is a different subject, covered elsewhere on this site. And this is not advice about how to write tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  The workshop
&lt;/h2&gt;

&lt;p&gt;The nearest confusion is this one. People think CI/CD is a single thing. A file in the repository. A list of steps that a machine runs. So they install it, and then they say they have it.&lt;/p&gt;

&lt;p&gt;Picture a workshop instead.&lt;/p&gt;

&lt;p&gt;On the left there is one bench, with one wooden frame being built on it. Everybody who makes a piece brings it to that bench &lt;strong&gt;today&lt;/strong&gt;, and fits it into the frame &lt;strong&gt;today&lt;/strong&gt;. If two pieces do not fit each other, you find that out today, while each piece is still one cut away from fitting.&lt;/p&gt;

&lt;p&gt;On the right there is a loading dock. There is one crate. It is packed, sealed, stamped, and standing at the door. A lorry is waiting outside. Whether the crate leaves is a separate decision, made by a person.&lt;/p&gt;

&lt;p&gt;The bench is continuous integration. The crate is continuous delivery. Two different rules, about two different objects.&lt;/p&gt;




&lt;h2&gt;
  
  
  One instant, two halves
&lt;/h2&gt;

&lt;p&gt;Here is the whole article in one picture.&lt;/p&gt;

&lt;p&gt;Draw the life of one change as a line of time. Mark one instant on that line. That instant is the &lt;strong&gt;green build&lt;/strong&gt;: the moment your change has joined everybody else's work, the machine has built it, the tests have run, and everything passed.&lt;/p&gt;

&lt;p&gt;Everything to the &lt;strong&gt;left&lt;/strong&gt; of that instant is continuous integration. It looks backwards, at the merge. Its question is: &lt;em&gt;how old is the oldest piece of work that has not joined the trunk yet?&lt;/em&gt; A good answer is measured in hours.&lt;/p&gt;

&lt;p&gt;Everything to the &lt;strong&gt;right&lt;/strong&gt; of that instant is continuous delivery. It looks forwards, at the artifact. Its question is: &lt;em&gt;how much stands between this build and a real user?&lt;/em&gt; A good answer is one button.&lt;/p&gt;

&lt;p&gt;Two halves. One instant between them. Pointing in opposite directions. Every section below is this same picture with the marks moved.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a long branch actually costs
&lt;/h2&gt;

&lt;p&gt;The cost of a long branch is not the cost most people assume. It is worth making concrete, because the arithmetic says something surprising.&lt;/p&gt;

&lt;p&gt;Here is a small model of the team.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# drift.py -- what a branch costs is not its size. It is its age.
&lt;/span&gt;&lt;span class="n"&gt;TEAM&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;      &lt;span class="c1"&gt;# developers on this one codebase
&lt;/span&gt;&lt;span class="n"&gt;REPO_FILES&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;   &lt;span class="c1"&gt;# files that anybody might touch
&lt;/span&gt;&lt;span class="n"&gt;FILES_I_TOUCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;     &lt;span class="c1"&gt;# what my one branch actually edits
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days_open&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# every OTHER developer lands one change a day while my branch sits still
&lt;/span&gt;    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEAM&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;*&lt;/span&gt; &lt;span class="n"&gt;days_open&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chance_of_a_conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days_open&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;miss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPO_FILES&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;FILES_I_TOUCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;REPO_FILES&lt;/span&gt;   &lt;span class="c1"&gt;# one edit misses me
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;miss&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days_open&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;# each change edits 2 files
&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;drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="nf"&gt;chance_of_a_conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="c1"&gt;# 5 changes    -&amp;gt; 14 percent
&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;drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;chance_of_a_conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# 150 changes  -&amp;gt; 99 percent
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Drift&lt;/strong&gt; is the number of changes that landed on the shared trunk while my branch sat still. Five other developers, one change each per day, so drift is five changes per day.&lt;/p&gt;

&lt;p&gt;The chance of a conflict is the chance that at least one of those landed changes touched at least one of my eighteen files. Each landed change edits two files, so I take the chance of one edit missing me, and raise it to that power.&lt;/p&gt;

&lt;p&gt;Run it. Every figure below came out of that script, and each one was cross-checked with a 200,000-run simulation that shuffles the edits at random:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;How long the branch stayed open&lt;/th&gt;
&lt;th&gt;Changes that landed underneath it&lt;/th&gt;
&lt;th&gt;Chance of a conflict&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;1 working day&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;14.0%&lt;/strong&gt; &lt;em&gt;(simulated: 14.1%)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;1 working week&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;6 working weeks&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;98.9%&lt;/strong&gt; &lt;em&gt;(simulated: 98.9%)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the first and last rows next to each other. A branch that is one working day old has a &lt;strong&gt;14%&lt;/strong&gt; chance of hitting a conflict when it merges. Most days, nothing happens. You merge, and you carry on with your afternoon.&lt;/p&gt;

&lt;p&gt;A branch that is six weeks old has a &lt;strong&gt;98.9%&lt;/strong&gt; chance. Not most of the time. Essentially always.&lt;/p&gt;

&lt;p&gt;And here is the honest third number. &lt;strong&gt;Nothing about the work changed between those two rows.&lt;/strong&gt; The same eighteen files. The same feature. The same developer. The only thing that changed is that the branch was allowed to live thirty times longer.&lt;/p&gt;

&lt;p&gt;Look at the script again and notice what is missing. &lt;code&gt;FILES_I_TOUCH&lt;/code&gt; is fixed at eighteen in both runs. &lt;strong&gt;The size of the branch never appears in the calculation. Only its age.&lt;/strong&gt; A branch is a debt, and the interest on it is time.&lt;/p&gt;

&lt;p&gt;That is what continuous integration attacks. Not your tests, and not your tooling. The age of the oldest thing that has not been joined yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Continuous integration, written out
&lt;/h2&gt;

&lt;p&gt;So here is continuous integration in full. Notice that nothing in it is a product you can buy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# merge.py -- continuous integration, with nothing bought and nothing installed.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;integrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;my_change&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;trunk&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trunk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# 1. what everyone else already landed
&lt;/span&gt;    &lt;span class="n"&gt;joined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;my_change&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# 2. join my work to it -- TODAY
&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;builds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;               &lt;span class="c1"&gt;# 3. does the whole thing still compile?
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the build broke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;tests_pass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;           &lt;span class="c1"&gt;# 4. does it still do what it did?
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the tests broke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trunk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# 5. now everyone builds on my work too
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step by step. Take what everyone else has already landed on the trunk. Join my work to it — not next month, today. Build it: does the whole thing still compile, with my change inside it? If it does not, stop, and say so out loud. Run the tests: does the whole thing still do what it did before? If it does not, stop, and say so out loud.&lt;/p&gt;

&lt;p&gt;And if both of those passed, push it back to the trunk. From this moment, everybody else is building on top of my work as well.&lt;/p&gt;

&lt;p&gt;That is all of it. Really it is two words. &lt;strong&gt;Every&lt;/strong&gt; change, and &lt;strong&gt;today&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A machine that runs your tests but lets branches live for six weeks is not doing this. It is just running your tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that gets skipped: CI is a rule about people
&lt;/h2&gt;

&lt;p&gt;Let us say this plainly, because it is the part that gets skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous integration is a rule about how people work. The machine is only there to enforce it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which means a team can have every green tick in the world and still not be doing it. If the tests run on every proposed change, but those proposals stay open for three weeks, then the joining still happens once every three weeks. The machine is measuring something. It is not fixing anything.&lt;/p&gt;

&lt;p&gt;A green tick on a three-week-old branch proves that the branch was fine three weeks ago, against a trunk that no longer exists.&lt;/p&gt;

&lt;p&gt;And it is &lt;strong&gt;cheap to install, but expensive to keep&lt;/strong&gt;. Installing it takes an afternoon. Keeping it means the test suite has to stay fast, and it has to stay honest.&lt;/p&gt;

&lt;p&gt;The moment people stop believing a red result, you have made things worse than having no tests at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The arithmetic of a suite nobody believes
&lt;/h2&gt;

&lt;p&gt;That last sentence sounds like an opinion. It is arithmetic.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;flaky&lt;/strong&gt; test is one that sometimes fails even though the code is perfectly fine. Say you have four hundred tests. Say each one, on its own, fails wrongly about two times in every thousand runs.&lt;/p&gt;

&lt;p&gt;Two in a thousand sounds like nothing at all. But a build is only green when &lt;strong&gt;all four hundred&lt;/strong&gt; pass. Multiply it out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Wrong failures per test, per 1,000 runs&lt;/th&gt;
&lt;th&gt;Chance a perfectly good build shows red&lt;/th&gt;
&lt;th&gt;In other words&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 good build in every 5.5&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;33.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 good build in every 3&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 good build in every 1.8&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At two in a thousand, &lt;strong&gt;more than half of your perfectly good builds show red.&lt;/strong&gt; So more than half the time, red means nothing.&lt;/p&gt;

&lt;p&gt;And then the number that actually costs you: &lt;strong&gt;zero&lt;/strong&gt;. That is how many people will look carefully at a red build, once they have learned that red usually means nothing. They press the button that runs it again.&lt;/p&gt;

&lt;p&gt;Now a real failure and a fake failure look exactly the same. Your suite has stopped being a signal. It is still running, it is still costing machine time, and it is no longer telling anybody anything.&lt;/p&gt;

&lt;p&gt;This is why "we installed it" is not the same as "we have it". Continuous integration is a habit that a machine happens to enforce, and habits decay.&lt;/p&gt;




&lt;h2&gt;
  
  
  Crossing the line: the artifact
&lt;/h2&gt;

&lt;p&gt;Now cross to the right hand side of that instant, and follow one green build all the way out.&lt;/p&gt;

&lt;p&gt;Here is the green build. The change is on the trunk, it compiled, the tests passed. Continuous integration has done its whole job, and &lt;strong&gt;it stops right here&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Continuous delivery starts here. The first thing it does is turn that build into one object. One package, with one identity, built exactly once. That object is called the &lt;strong&gt;artifact&lt;/strong&gt;, and from this moment it is the only thing that moves.&lt;/p&gt;

&lt;p&gt;The same artifact is then pushed into real environments. Not a rebuild. The same bytes, with different &lt;strong&gt;configuration&lt;/strong&gt; — addresses, secrets, sizes — handed to it from outside.&lt;/p&gt;

&lt;p&gt;And at the end there is production, with one step in front of it. In continuous delivery, that step is a button, and a person presses it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build once, promote the same bytes
&lt;/h2&gt;

&lt;p&gt;Here is what that means in code. This is the rule that carries the whole idea.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# build.py -- the artifact is built ONCE. Only the configuration changes.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_artifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# one build, one identity. This object is the only thing that ever ships.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;package&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commit&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;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# address, secret, size -- from OUTSIDE
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# the same bytes. Nothing is rebuilt.
&lt;/span&gt;
&lt;span class="n"&gt;art&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_artifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a91f4c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging&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;pre-production&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;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;art&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;# 1 build, 3 deploys, identical bytes
&lt;/span&gt;
&lt;span class="c1"&gt;# Rebuild once per environment instead and you get 3 builds -- which is
# 2 chances for what reaches production to differ from what you tested.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build the artifact from one commit. Package it, and give it an identity — a &lt;strong&gt;digest&lt;/strong&gt;, which is a short fingerprint of the contents, so you can point at exactly this thing and no other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Promoting&lt;/strong&gt; is a separate step, and this is the important line. Promoting takes the artifact and an environment. It loads that environment's configuration from outside the artifact. Then it deploys the artifact, unchanged.&lt;/p&gt;

&lt;p&gt;So one build goes to staging, then to pre-production, then to production. Three deployments, one set of bytes. &lt;strong&gt;The thing your users get is the exact thing your tests ran against.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now do it the other way. Build again for each environment. Three builds, which means two chances for what reaches production to be quietly different from what you tested. Nobody chooses that on purpose. It happens because rebuilding is the easy thing to write.&lt;/p&gt;




&lt;h2&gt;
  
  
  What continuous delivery demands
&lt;/h2&gt;

&lt;p&gt;Continuous delivery is not free, and the price is not money. It is a list of things that have to already be true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Required:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One artifact for every green build, and nothing else ever ships.&lt;/li&gt;
&lt;li&gt;Configuration comes from outside that artifact, always.&lt;/li&gt;
&lt;li&gt;Database changes that work with both the old and the new version of the code, because for a few minutes both of them are running.&lt;/li&gt;
&lt;li&gt;A rollback you have actually used, on a real day. Not a paragraph in a document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;And here is what has to go:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Servers that somebody edits by hand.&lt;/li&gt;
&lt;li&gt;A separate build for each environment.&lt;/li&gt;
&lt;li&gt;A long-lived release branch.&lt;/li&gt;
&lt;li&gt;A freeze week before every release.&lt;/li&gt;
&lt;li&gt;A page of manual steps written down in a document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without that list, continuous delivery is only a faster way to ship the same bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  One boundary, so you know what this is not
&lt;/h2&gt;

&lt;p&gt;There is a whole subject sitting next to this one, and it is worth naming so you can see the edge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How a release is rolled out&lt;/strong&gt; is that other subject, and it is covered elsewhere on this site: two full production stacks with the traffic flipped between them, a small share of users getting the new version first, and requests already in flight being allowed to finish before the old version stops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shipping code switched off and turning it on later&lt;/strong&gt; is a third subject, also covered elsewhere, and it is not the same as either half here.&lt;/p&gt;

&lt;p&gt;All of them are real, and all of them matter. This article is about something before all of them: how often work is joined together, whether every green build becomes one artifact, and how far that artifact travels on its own.&lt;/p&gt;

&lt;p&gt;The pipeline, and the artifact. Not the rollout. &lt;strong&gt;This article stops at the artifact.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The third D
&lt;/h2&gt;

&lt;p&gt;And now the third word, which is where almost every argument about CI/CD actually comes from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous delivery&lt;/strong&gt; means every green build is &lt;em&gt;ready&lt;/em&gt; to go to production. It has been built once, it has been through the environments, and it is sitting at the door. A person decides when it goes. The team can ship ten times a day if they want to, and they can also decide not to ship anything for a week. &lt;strong&gt;Ready is the promise. Released is a choice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous deployment&lt;/strong&gt; removes the person. Green means it goes. Nobody presses anything.&lt;/p&gt;

&lt;p&gt;Both of them are written CD, which is exactly why people argue past each other. One person is defending a discipline, the other is defending an automation, and they both think the letters mean the same thing.&lt;/p&gt;

&lt;p&gt;They are also not the same difficulty at all. Delivery needs discipline. Deployment needs everything delivery needs, &lt;strong&gt;plus&lt;/strong&gt; a way to notice a bad release and undo it without a human — because there is no longer a human in the loop to notice.&lt;/p&gt;




&lt;h2&gt;
  
  
  The difference is often one line
&lt;/h2&gt;

&lt;p&gt;Inside a real pipeline, the difference between those two is often exactly one setting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# promote.py -- continuous DELIVERY and continuous DEPLOYMENT, in one file.
&lt;/span&gt;
&lt;span class="n"&gt;REQUIRE_A_HUMAN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;      &lt;span class="c1"&gt;# True: delivery.   False: deployment.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_green_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;smoke_tests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging is unhappy -- nothing goes anywhere&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;REQUIRE_A_HUMAN&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;wait_for_the_button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# DELIVERY: ready, not released
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# DEPLOYMENT: it ships itself
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the top there is one setting: require a human, true or false.&lt;/p&gt;

&lt;p&gt;On a green build, promote the artifact to staging, and run the quick checks there. If staging is unhappy, stop, and nothing goes anywhere.&lt;/p&gt;

&lt;p&gt;And now the branch. If require-a-human is &lt;strong&gt;true&lt;/strong&gt;, you wait for the button. The artifact is finished, tested, and standing still. That is continuous delivery: ready, but not released.&lt;/p&gt;

&lt;p&gt;If require-a-human is &lt;strong&gt;false&lt;/strong&gt;, the same artifact goes to production right there, on the same green build, with nobody watching. That is continuous deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous delivery ends at a button that nobody is obliged to press. Continuous deployment deletes the button.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Note what did &lt;em&gt;not&lt;/em&gt; change between the two paths: the artifact, the promotion, the checks. Everything above that setting is the same work. That is why you cannot skip delivery on the way to deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the size of a release matters, on a bad day
&lt;/h2&gt;

&lt;p&gt;There is one more reason to care, and it only shows up when something goes wrong.&lt;/p&gt;

&lt;p&gt;Suppose your six developers finish one change each per day, and you release once a quarter. That release contains &lt;strong&gt;378 changes&lt;/strong&gt;. Then something breaks in production. You now have 378 suspects. Finding the one, by repeatedly cutting the list in half, takes about &lt;strong&gt;9 rounds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now release once a day instead. That release contains &lt;strong&gt;6 changes&lt;/strong&gt;. Six suspects. About &lt;strong&gt;3 rounds&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Release rhythm&lt;/th&gt;
&lt;th&gt;Changes in one release&lt;/th&gt;
&lt;th&gt;Rounds of halving to find the culprit&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Once a quarter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;378&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~9&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Once a day&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Difference&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;63×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3×&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same team. The same code. The same bugs. The only thing that changed is how much work was allowed to pile up between one release and the next.&lt;/p&gt;

&lt;p&gt;This is not just arithmetic on a page, either. The multi-year &lt;em&gt;Accelerate&lt;/em&gt; research found the same direction in real organisations: teams that release in small batches, often, also recover faster and fail less. Small batches are not a nicety on top of the pipeline. They are most of what the pipeline buys you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same two halves, when part of what you ship is a model
&lt;/h2&gt;

&lt;p&gt;If your system includes a trained model, both halves still apply, and one of them gets harder.&lt;/p&gt;

&lt;p&gt;The left half does not change at all. Prompts, retrieval settings, evaluation scripts and serving code are all just code on a trunk, and a branch holding them is exactly as expensive with age as any other branch.&lt;/p&gt;

&lt;p&gt;The right half is where people quietly break the rule. The artifact stops being one object. The code is packaged once, but the model weights are fetched at start-up from wherever the latest ones happen to live, and the prompt text is edited in a console by hand. Now three things ship on three different schedules, and none of them has one identity. When output quality drops on a Tuesday, you cannot say what changed, because "what changed" was never a single thing you could point at.&lt;/p&gt;

&lt;p&gt;The fix is the same sentence as before, applied honestly: &lt;strong&gt;one artifact per green build.&lt;/strong&gt; Pin the weights by digest, put the prompt in the package, and let configuration — endpoints, sizes, keys — come from outside. Then a bad Tuesday has one identity attached to it, and rolling back means going back to one object rather than reconstructing three.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three questions about your own team
&lt;/h2&gt;

&lt;p&gt;You can answer these this afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First.&lt;/strong&gt; How old is the oldest piece of work that has not been joined to the trunk yet? If you answer in weeks, you do not have continuous integration, no matter what runs on your proposed changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second.&lt;/strong&gt; Does every green build produce exactly one artifact, which is never rebuilt on its way out? If not, you do not have continuous delivery yet — because there is nothing being delivered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third.&lt;/strong&gt; What is the last step before production? If it is a button, that is delivery. If there is no step at all, that is deployment. If it is a person following instructions written in a document, then it is neither of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&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;Continuous integration&lt;/th&gt;
&lt;th&gt;Continuous delivery&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;It is a rule about&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;merge&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;artifact&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;It looks&lt;/td&gt;
&lt;td&gt;backwards, at joining work&lt;/td&gt;
&lt;td&gt;forwards, at shipping it&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Its question&lt;/td&gt;
&lt;td&gt;How old is the oldest unjoined work?&lt;/td&gt;
&lt;td&gt;How much stands between this build and a user?&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;A good answer&lt;/td&gt;
&lt;td&gt;hours&lt;/td&gt;
&lt;td&gt;one button&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What it costs&lt;/td&gt;
&lt;td&gt;cheap to install, expensive to keep honest&lt;/td&gt;
&lt;td&gt;discipline: config outside, two-way migrations, a used rollback&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;How it fails quietly&lt;/td&gt;
&lt;td&gt;green ticks on three-week-old branches&lt;/td&gt;
&lt;td&gt;a rebuild per environment&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Continuous integration is a rule about the merge.&lt;/strong&gt; Everybody's work joins the same trunk today, and every join is built and tested, so a break is found while the change is still small enough to understand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous delivery is a rule about the artifact.&lt;/strong&gt; Every green build becomes one package, built once, that travels the environments unchanged, until it is standing one press away from production.&lt;/p&gt;

&lt;p&gt;And the D that everybody argues about is a third thing. Delivery means always ready. Deployment means the person is gone as well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can have the first without the second, and almost everybody does.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On branch age and merge day&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paul Hammant and contributors, &lt;em&gt;Trunk Based Development&lt;/em&gt; (trunkbaseddevelopment.com) — the short-lived-branch rule and why branch lifetime, not branch size, is the thing to manage: &lt;a href="https://trunkbaseddevelopment.com/" rel="noopener noreferrer"&gt;trunkbaseddevelopment.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On continuous integration as a rule about people&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kent Beck, &lt;em&gt;Extreme Programming Explained: Embrace Change&lt;/em&gt; (Addison-Wesley, 1999; 2nd ed. 2004) — the origin of "integrate continuously" as a team practice rather than a piece of infrastructure.&lt;/li&gt;
&lt;li&gt;Martin Fowler, &lt;em&gt;Continuous Integration&lt;/em&gt; (martinfowler.com, 2006) — the canonical definition, including "everyone commits to mainline every day": &lt;a href="https://martinfowler.com/articles/continuousIntegration.html" rel="noopener noreferrer"&gt;martinfowler.com/articles/continuousIntegration.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On a suite people stop believing&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Fowler, &lt;em&gt;Eradicating Non-Determinism in Tests&lt;/em&gt; (martinfowler.com, 2011) — why a suite that fails at random destroys the trust that makes it worth running at all: &lt;a href="https://martinfowler.com/articles/nonDeterminism.html" rel="noopener noreferrer"&gt;martinfowler.com/articles/nonDeterminism.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the artifact, and build-once-promote&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jez Humble and David Farley, &lt;em&gt;Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation&lt;/em&gt; (Addison-Wesley, 2010) — the deployment pipeline, building binaries exactly once, and keeping configuration outside the artifact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the third D: delivery versus deployment&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Fowler, &lt;em&gt;ContinuousDelivery&lt;/em&gt; and &lt;em&gt;Continuous Delivery vs Continuous Deployment&lt;/em&gt; (martinfowler.com) — the ready-versus-released distinction stated directly: &lt;a href="https://martinfowler.com/bliki/ContinuousDelivery.html" rel="noopener noreferrer"&gt;martinfowler.com/bliki/ContinuousDelivery.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On batch size and release rhythm&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nicole Forsgren, Jez Humble and Gene Kim, &lt;em&gt;Accelerate: The Science of Lean Software and DevOps&lt;/em&gt; (IT Revolution, 2018) — the measured link between small batches, deployment frequency, lead time and recovery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the full episode:&lt;/strong&gt; &lt;a href="https://youtu.be/J0JcRX0jokM" rel="noopener noreferrer"&gt;Continuous Integration vs Continuous Delivery on YouTube&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Service Boundaries: Where Does the Line Between Two Services Actually Go?</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:10:11 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/service-boundaries-where-does-the-line-between-two-services-actually-go-3akh</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/service-boundaries-where-does-the-line-between-two-services-actually-go-3akh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/e-sjhV7CymE" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/service-boundaries-where-does-the-line-between-two-services-actually-go?id=182" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have decided to split. That part is not in question here — this article takes it as given and asks only the next question, the one that actually costs money: &lt;strong&gt;where does the line go?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The split almost everyone draws first is by &lt;strong&gt;noun&lt;/strong&gt; — one service per thing the business talks about. Order, user, product, pricing. Or by the &lt;strong&gt;org chart&lt;/strong&gt; — one service per team. Both are visible on day one, on a whiteboard, before anyone has written a line of code. That is exactly why they get drawn, and it is also why neither of them is a boundary.&lt;/p&gt;

&lt;p&gt;Six months later you have four services, and one page load needs all four.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tell: a chain of synchronous calls
&lt;/h2&gt;

&lt;p&gt;Here is the shape you are looking for. &lt;code&gt;order_svc&lt;/code&gt; owns the orders table and nothing else on the page. So it asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# order_svc - GET /orders/{oid}
# it owns the orders table. it owns nothing else on this page.
&lt;/span&gt;
&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/orders/{oid}&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;get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;row&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="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM orders WHERE id=?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# the customer name is not ours. ask the user service, and wait.
&lt;/span&gt;    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;USER_SVC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/users/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&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="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# the title is not ours either - and this one runs PER LINE ITEM.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_SVC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/products/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;it&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="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;# ...and product_svc, to answer that, asks pricing_svc itself.
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;full_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;priced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that loop again. &lt;strong&gt;The fan-out is not a fixed number — it grows with the data on the page.&lt;/strong&gt; &lt;code&gt;product_svc&lt;/code&gt; is asked once &lt;em&gt;per line item&lt;/em&gt;, and each of those asks &lt;code&gt;pricing_svc&lt;/code&gt;. On the rig used for the episode, a one-line-item order produced &lt;strong&gt;3&lt;/strong&gt; outbound calls; the two-line-item demo order produced &lt;strong&gt;5&lt;/strong&gt;. The formula is &lt;code&gt;1 + 2 × line_items&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first version of this argument said "three services, three calls" and drew a single product fetch. Both were wrong. Any article that says three services means three calls is counting boxes on a diagram, not counting requests.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;5 and 6 are both correct numbers that mean different things.&lt;/strong&gt; The &lt;code&gt;X-Hops&lt;/code&gt; header reports &lt;strong&gt;5 outbound&lt;/strong&gt; calls. The rig's total &lt;em&gt;inbound&lt;/em&gt; count is &lt;strong&gt;6&lt;/strong&gt;, because the page load into &lt;code&gt;order_svc&lt;/code&gt; is itself a request that can fail. Which one you use matters a great deal in a moment.&lt;/p&gt;

&lt;p&gt;That is not four services. It is one distributed monolith: the latency of four, the failure modes of four, and the independence of none.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the chain actually costs
&lt;/h2&gt;

&lt;p&gt;Every figure below was measured on a real four-service rig, 2,000 requests per build, and the naive, collapsed, and modular builds return &lt;strong&gt;byte-for-byte identical responses&lt;/strong&gt; (&lt;code&gt;diff&lt;/code&gt; exit 0). Same feature, three architectures.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;What was measured&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Naive chain (4 processes, HTTP) — mean / p95&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;10.12 ms&lt;/strong&gt; / 12.21 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Cost of each extra network hop&lt;/td&gt;
&lt;td&gt;~&lt;strong&gt;1.80 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Collapsed (1 process, 1 database) — mean / p95&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.11 ms&lt;/strong&gt; / 1.18 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Speed-up, identical response bytes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;9.09×&lt;/strong&gt; (9.01 ms/req saved)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Actual work — every DB read, all four services&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;140 µs = 1.38%&lt;/strong&gt; of the request&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;
&lt;code&gt;order_svc&lt;/code&gt; blocked on a socket&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;87.96%&lt;/strong&gt; of the request&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Outbound calls — 1-item order / 2-item order&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3&lt;/strong&gt; / &lt;strong&gt;5&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Boundary &lt;em&gt;in-process&lt;/em&gt; vs no boundary&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+30 µs (+2.7%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Boundary &lt;em&gt;over the network&lt;/em&gt; vs no boundary&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+9.01 ms (+809%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two lines in that table are the whole argument. The real work — every database read in all four services — is &lt;strong&gt;1.38%&lt;/strong&gt; of the request. &lt;code&gt;order_svc&lt;/code&gt; spends &lt;strong&gt;88%&lt;/strong&gt; of its life blocked on a socket waiting for a service to answer a question it could have answered itself.&lt;/p&gt;

&lt;p&gt;And a boundary is not intrinsically expensive. The same wall, drawn &lt;em&gt;inside one process&lt;/em&gt;, costs &lt;strong&gt;30 microseconds&lt;/strong&gt;. Drawn &lt;em&gt;across a network&lt;/em&gt;, it costs &lt;strong&gt;9 milliseconds&lt;/strong&gt; — three hundred times more, for the same separation of concerns. You are not paying for the boundary. You are paying for the transport you chose to put it on.&lt;/p&gt;




&lt;h2&gt;
  
  
  The availability bill: right rule, wrong exponent
&lt;/h2&gt;

&lt;p&gt;Everyone knows the multiplication rule. Four services at 99.9% each, in a chain where all must succeed:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0.999⁴ = 99.6006%&lt;/code&gt; → &lt;strong&gt;172.5 minutes of downtime per month&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That figure is arithmetically correct — a 1,000,000-trial Monte Carlo matched it. It is also the wrong number, because the read path does not involve four things that must succeed. It involves &lt;strong&gt;six&lt;/strong&gt;: the request into &lt;code&gt;order_svc&lt;/code&gt;, plus the five calls it sets off.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0.999⁶ = 99.4015%&lt;/code&gt; → &lt;strong&gt;258.6 minutes per month&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is an &lt;strong&gt;86-minute-a-month gap&lt;/strong&gt;, and it is hidden by counting boxes on a diagram instead of counting what has to answer. Availability is raised to the number of things that must &lt;strong&gt;succeed&lt;/strong&gt;, not the number of boxes you drew.&lt;/p&gt;

&lt;p&gt;One honest note, because the temptation to overclaim here is strong: the measured rig matched theory within one standard error once a rig artefact was removed (a &lt;code&gt;sqlite3&lt;/code&gt; connection shared across the threadpool produced phantom 404s and 500s — 36% of all observed failures, all of them pushing the rate down). Measurement did not beat the theory. The interesting result is the &lt;strong&gt;exponent&lt;/strong&gt;, not a discrepancy.&lt;/p&gt;




&lt;h2&gt;
  
  
  The deploy story is worse than you have been told
&lt;/h2&gt;

&lt;p&gt;Every article on this subject says: deploy them in the right order. Measured on a one-field rename across the boundary — &lt;code&gt;name&lt;/code&gt; → &lt;code&gt;full_name&lt;/code&gt; in &lt;code&gt;user_svc&lt;/code&gt;, which &lt;code&gt;order_svc&lt;/code&gt; reads — &lt;strong&gt;both orders fail&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deploy &lt;code&gt;user_svc&lt;/code&gt; first → &lt;code&gt;order_svc&lt;/code&gt; raises &lt;code&gt;KeyError: 'name'&lt;/code&gt; → &lt;strong&gt;HTTP 500&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Deploy &lt;code&gt;order_svc&lt;/code&gt; first → &lt;code&gt;KeyError: 'full_name'&lt;/code&gt; → &lt;strong&gt;HTTP 500&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no ordering that works, because the two versions are never simultaneously correct. The only thing that works is a &lt;strong&gt;three-deploy expand/contract sequence&lt;/strong&gt;: emit both fields, migrate the reader, then drop the old field. The seeded history on the rig shows exactly that shape — a commit reading &lt;code&gt;rename name -&amp;gt; full_name (expand: emit both)&lt;/code&gt; and, later, &lt;code&gt;(contract: drop name)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the real cost of that boundary is not "coordination". It is &lt;strong&gt;three releases to rename one field&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  So what actually decides a boundary?
&lt;/h2&gt;

&lt;p&gt;Two things, and neither of them is a noun.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Who owns the data
&lt;/h3&gt;

&lt;p&gt;Exactly one service may &lt;strong&gt;write&lt;/strong&gt; a given fact. Everyone else asks. The interesting move is not making the service smaller — it is deciding, per fact, whose fact it is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the fix is not a smaller service. it is deciding who owns each fact.
&lt;/span&gt;
&lt;span class="c1"&gt;# WRONG - the order page asks the product service what the price is.
#         that is the price TODAY. the customer paid LAST MONTH.
&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_SVC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/products/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# RIGHT - the price charged is not a product fact at all.
#         it is a fact about THIS order, and it never changes again.
&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO orders (user_id, product_id, price_paid, title_at_sale)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; VALUES (?, ?, ?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price_now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title_now&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# reading that order back now touches one table, in one process.
# the call to pricing did not get faster. it stopped existing.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last comment is the point. &lt;strong&gt;The price the customer paid is a fact about the order, not about the product.&lt;/strong&gt; Once you see that, the call to &lt;code&gt;pricing_svc&lt;/code&gt; on the read path is not slow — it is &lt;em&gt;wrong&lt;/em&gt;, and it does not need optimising, it needs deleting. A correct ownership decision does not make a call faster. It removes the call.&lt;/p&gt;

&lt;p&gt;This is also, incidentally, a correctness fix and not only a performance one: asking the product service for "the price" on an order page returns the price &lt;strong&gt;today&lt;/strong&gt;, for an order placed last month.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. What changes together
&lt;/h3&gt;

&lt;p&gt;Two things that are always released in the same commit are one thing wearing two names. You do not have to guess at this — your version control has been recording the answer for years.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git log --name-only --pretty=format:%H | python3 cochange.py

  pair                            commits touching BOTH      share
  order_svc   + user_svc                    6               75.0%
  product_svc + pricing_svc                 1               25.0%
  order_svc   + product_svc                 0                0.0%
  order_svc   + pricing_svc                 0                0.0%

# 75%, and every user_svc commit touched order_svc too: one unit of change.
# 0% does NOT automatically mean the wall is earning its keep -
#   check whether that pair is on the READ PATH before you trust it.
#   both zero pairs here are on it, and they were resolved differently.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;order_svc&lt;/code&gt; and &lt;code&gt;user_svc&lt;/code&gt; co-change &lt;strong&gt;75%&lt;/strong&gt; of the time, and every single &lt;code&gt;user_svc&lt;/code&gt; commit also touched &lt;code&gt;order_svc&lt;/code&gt;. That is not two services. That is one unit of change with a network cable running through the middle of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two mechanisms disagree, and you should say so
&lt;/h3&gt;

&lt;p&gt;Look at the table again. Co-change says nothing at all about &lt;code&gt;order_svc + product_svc&lt;/code&gt; — &lt;strong&gt;0%&lt;/strong&gt;. So the co-change test does &lt;em&gt;not&lt;/em&gt; argue for merging order and product; the &lt;strong&gt;read-path fan-out&lt;/strong&gt; does. Two mechanisms, pointing different ways, on the same pair.&lt;/p&gt;

&lt;p&gt;Most writing on this topic presents the rules as if they always agree. They do not. When yours disagree, name which one you are following and why. Here: order and product get merged on fan-out grounds, and order and pricing stay apart despite an identical 0% because pricing comes off the read path entirely once &lt;code&gt;price_paid&lt;/code&gt; is owned by the order.&lt;/p&gt;




&lt;h2&gt;
  
  
  The line, moved
&lt;/h2&gt;

&lt;p&gt;Merge &lt;strong&gt;order + user + product&lt;/strong&gt; into one deployable — three modules, one process, one database. Keep &lt;strong&gt;pricing&lt;/strong&gt; a real service: different team, different release day, and crucially &lt;strong&gt;it is not on the read path at all&lt;/strong&gt; any more, because the order stores &lt;code&gt;price_paid&lt;/code&gt; and &lt;code&gt;title_at_sale&lt;/code&gt; at the time of sale. Its only remaining edge is a one-way, nightly, asynchronous one.&lt;/p&gt;

&lt;p&gt;Losing the network does not mean losing the wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ONE process. ONE deployable. the boundary is still real.
&lt;/span&gt;
&lt;span class="c1"&gt;# shop/users/api.py - the only way in. no table name ever leaves this file.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&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;_row_to_user&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="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT id, name FROM users WHERE id=?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# shop/orders/service.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop.users.api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_user&lt;/span&gt;       &lt;span class="c1"&gt;# a function, not a URL
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;order_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# our own table
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_paid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;# our own column
&lt;/span&gt;
&lt;span class="c1"&gt;# and the wall is enforced, not merely agreed:
#   a sqlite authorizer refuses any statement from shop.orders that
#   names the users table. a violation raises. it does not lint.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last three lines are what separates this from wishful thinking. A module boundary that lives in a code-review convention is not a boundary; it is a hope. A SQLite authorizer that &lt;strong&gt;raises&lt;/strong&gt; when &lt;code&gt;shop.orders&lt;/code&gt; names the &lt;code&gt;users&lt;/code&gt; table is a boundary, and it costs 30 microseconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three architectures, one feature
&lt;/h3&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;Four services (by noun)&lt;/th&gt;
      &lt;th&gt;One deployable, no wall&lt;/th&gt;
      &lt;th&gt;One deployable, modules&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Mean latency&lt;/td&gt;
&lt;td&gt;10.12 ms&lt;/td&gt;
&lt;td&gt;1.11 ms&lt;/td&gt;
&lt;td&gt;1.14 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Cost of the boundary&lt;/td&gt;
&lt;td&gt;+9.01 ms (+809%)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;+30 µs (+2.7%)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Things that must succeed&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Availability at 99.9% each&lt;/td&gt;
&lt;td&gt;99.4015%&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Rename one field across the line&lt;/td&gt;
&lt;td&gt;3 deploys (expand/contract)&lt;/td&gt;
&lt;td&gt;1 commit&lt;/td&gt;
&lt;td&gt;1 commit&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Wall enforced by&lt;/td&gt;
&lt;td&gt;the network&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;an authorizer that raises&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Moving the line later&lt;/td&gt;
&lt;td&gt;data migration&lt;/td&gt;
&lt;td&gt;refactor&lt;/td&gt;
&lt;td&gt;refactor&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The costs, stated plainly
&lt;/h3&gt;

&lt;p&gt;This is not free, and the honest version says so. You end up with &lt;strong&gt;fewer and larger services&lt;/strong&gt; than the diagram wanted, which will feel like a regression to anyone who counts services. Moving a line later is a &lt;strong&gt;data migration&lt;/strong&gt;, not a refactor — merging two databases is real work. And a big in-process module still needs someone to defend the wall, because the compiler will happily let you reach across it.&lt;/p&gt;

&lt;p&gt;Which is exactly why the reversible move is to draw the line &lt;strong&gt;inside one deployable first&lt;/strong&gt;: one module, one function as the only way in, and the wall enforced mechanically. If the line turns out to be in the wrong place, you move it with a refactor instead of a migration. If it turns out to be right, promoting it to a process later is a small, well-understood step — and you will have real co-change data by then to prove it.&lt;/p&gt;

&lt;p&gt;Keeping two databases in step is its own problem, with its own name and its own article. Do not sign up for it by accident on a whiteboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same two tests, on an LLM-serving stack
&lt;/h2&gt;

&lt;p&gt;None of this is specific to shopping carts, and the AI stack is where the noun-split is currently being repeated most enthusiastically. A typical serving diagram has a gateway, an orchestrator, a retriever, a reranker, a model server, and an eval service — six boxes, one per noun, drawn on day one.&lt;/p&gt;

&lt;p&gt;Run the two tests on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changes together?&lt;/strong&gt; In practice the prompt template, the few-shot examples, and the output parser change in the same commit, essentially always. Change the prompt and the parser breaks; tighten the schema and the prompt has to say so. That is a co-change rate near 100% — one unit of change. Split it into a "prompt service" and a "parsing service" and you have bought yourself three deploys to add a field, for exactly the reason &lt;code&gt;name&lt;/code&gt; → &lt;code&gt;full_name&lt;/code&gt; needed three.&lt;/p&gt;

&lt;p&gt;The chunker and the embedder are the same story from the other side: change the embedding model and every vector in the index is invalid. They ship together or the index is quietly wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who owns the data?&lt;/strong&gt; The vector index has exactly one legitimate writer — whichever service owns the ingest path. Everyone else reads. And the &lt;code&gt;price_paid&lt;/code&gt; lesson transfers directly: when you log a trace, store the &lt;strong&gt;prompt text, the model version, and the retrieved chunk ids as they were at call time&lt;/strong&gt;, on the trace row. They are facts about &lt;em&gt;that call&lt;/em&gt;, not about the retriever's current state. A trace that has to ask the retrieval service what it &lt;em&gt;would&lt;/em&gt; return today cannot be replayed, and your eval numbers will drift underneath you the same way a re-fetched price does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the fan-out?&lt;/strong&gt; A request that goes gateway → orchestrator → retriever → reranker → model server synchronously has the same six-exponent availability bill as the shop did, and every hop lands directly on &lt;strong&gt;time-to-first-token&lt;/strong&gt; — the number your users actually feel. The 88%-blocked-on-a-socket figure is, if anything, kinder than what you will measure here, because a rerank hop is not 1.8 ms.&lt;/p&gt;

&lt;p&gt;And which box genuinely earns its own process? &lt;strong&gt;The model server.&lt;/strong&gt; Not because it is a different noun, but because it fails all three of the reasons the others failed: it scales on a completely different axis (GPU memory, batch size, KV cache), it releases on a different cadence, and it does not co-change with the prompt at all. That is what an earned boundary looks like — different data owner, different unit of change, different scaling axis. Not a different word on a whiteboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A boundary you have to call synchronously on every request is not a boundary. It is a network hop you added to a function call.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The point of the line was never tidiness or a tidy diagram. It was &lt;strong&gt;independent deployability&lt;/strong&gt; — can these two things ship apart, on different days, by different people, with nobody coordinating? If the answer is no, you do not have two services. You have one system, and the line you drew is a cost you are paying for nothing.&lt;/p&gt;

&lt;p&gt;So before the next split, ask the two questions that do not appear on the whiteboard: &lt;strong&gt;who owns this fact&lt;/strong&gt;, and &lt;strong&gt;what changes with it&lt;/strong&gt;. Then draw the line inside one deployable, enforce it with something that raises rather than something that comments, and let it earn its process later.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The premise — you have decided to split, and now the line has to go somewhere&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sam Newman, &lt;em&gt;Building Microservices&lt;/em&gt; (O'Reilly, 2nd ed. 2021) — independent deployability as the defining property of a service, and the case that a boundary you cannot ship apart is not one.&lt;/li&gt;
&lt;li&gt;Martin Fowler, &lt;a href="https://martinfowler.com/bliki/MicroservicePremium.html" rel="noopener noreferrer"&gt;&lt;em&gt;MicroservicePremium&lt;/em&gt;&lt;/a&gt; (martinfowler.com, 2015) — names the fixed cost every distributed boundary charges before it delivers any benefit, which is the ledger this whole article is keeping.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The tell — a synchronous chain across four services&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Fowler, &lt;a href="https://martinfowler.com/bliki/FirstLaw.html" rel="noopener noreferrer"&gt;&lt;em&gt;First Law of Distributed Object Design&lt;/em&gt;&lt;/a&gt; (martinfowler.com, 2003) — "don't distribute your objects": the original statement that a remote call is not a local call with extra latency, it is a different thing.&lt;/li&gt;
&lt;li&gt;Michael T. Nygard, &lt;em&gt;Release It!&lt;/em&gt; (Pragmatic Bookshelf, 2nd ed. 2018) — integration points as the leading source of instability, and how a synchronous dependency turns one service's bad day into everyone's.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The availability arithmetic&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Betsy Beyer et al., eds., &lt;a href="https://sre.google/sre-book/embracing-risk/" rel="noopener noreferrer"&gt;&lt;em&gt;Site Reliability Engineering&lt;/em&gt;&lt;/a&gt; (O'Reilly, 2016), ch. "Embracing Risk" — availability targets, error budgets, and why a service's own target has to account for everything it depends on to answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Decider 1 — who owns the data&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chris Richardson, &lt;a href="https://microservices.io/patterns/data/database-per-service.html" rel="noopener noreferrer"&gt;&lt;em&gt;Pattern: Database per Service&lt;/em&gt;&lt;/a&gt; (microservices.io) — one writer per fact, stated as a pattern with its trade-offs, including the queries it makes hard.&lt;/li&gt;
&lt;li&gt;Eric Evans, &lt;em&gt;Domain-Driven Design&lt;/em&gt; (Addison-Wesley, 2003) — bounded context: the same word means different things to different parts of the business, which is why "price" belongs to the order and not to the product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Decider 2 — what changes together&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;David L. Parnas, &lt;a href="https://dl.acm.org/doi/10.1145/361598.361623" rel="noopener noreferrer"&gt;&lt;em&gt;On the Criteria To Be Used in Decomposing Systems into Modules&lt;/em&gt;&lt;/a&gt; (Communications of the ACM, 1972) — decompose by what is likely to change, not by processing steps; the ancestor of the co-change test.&lt;/li&gt;
&lt;li&gt;Adam Tornhill, &lt;em&gt;Your Code as a Crime Scene&lt;/em&gt; (Pragmatic Bookshelf, 2015) — mining version-control history for change coupling, i.e. how to get the &lt;code&gt;git log&lt;/code&gt; table above out of your own repository.&lt;/li&gt;
&lt;li&gt;Melvin E. Conway, &lt;a href="https://www.melconway.com/Home/Committees_Paper.html" rel="noopener noreferrer"&gt;&lt;em&gt;How Do Committees Invent?&lt;/em&gt;&lt;/a&gt; (Datamation, 1968) — the org-chart split, and why it keeps getting drawn whether or not it is the right line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The three-deploy rename&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Danilo Sato, &lt;a href="https://martinfowler.com/bliki/ParallelChange.html" rel="noopener noreferrer"&gt;&lt;em&gt;ParallelChange&lt;/em&gt;&lt;/a&gt; (martinfowler.com, 2014) — expand / migrate / contract, the only sequence that survives a rename across a boundary when neither deploy order works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you'd expect is missing, say so in the comments and I'll add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; the &lt;a href="https://youtube.com/shorts/tXDdgNx96uc" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; draws the four-service shop and names the two deciders; the &lt;a href="https://youtu.be/e-sjhV7CymE" rel="noopener noreferrer"&gt;full episode&lt;/a&gt; runs the ownership fix, the co-change analysis, and the in-process module wall on a rig that really executes.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Validating Input at the Edge: Why Four Checks in Four Places Are Worse Than One Border</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Fri, 28 Aug 2026 14:10:08 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/validating-input-at-the-edge-why-four-checks-in-four-places-are-worse-than-one-border-1h72</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/validating-input-at-the-edge-why-four-checks-in-four-places-are-worse-than-one-border-1h72</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/hSDufaDKZPI" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/validating-input-at-the-edge-why-four-checks-in-four-places-are-worse-than-one-border?id=181" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Somebody signs up. Their browser sends one JSON body to your endpoint. And here is the thing almost every tutorial skips over:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your program does not receive an object. It receives bytes.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;raw&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="c1"&gt;# type(raw) -&amp;gt; &amp;lt;class 'bytes'&amp;gt;
# repr(raw) -&amp;gt; b'{"email":"ada@example.com","age":"42","role":"admn"}'
&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# type(doc) -&amp;gt; &amp;lt;class 'dict'&amp;gt;
# doc -&amp;gt; {'email': 'ada@example.com', 'age': '42', 'role': 'admn'}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;json.loads&lt;/code&gt; upgrades bytes to a &lt;code&gt;dict&lt;/code&gt;. That is a real upgrade, and it is also the &lt;em&gt;last&lt;/em&gt; one you get for free. A &lt;code&gt;dict&lt;/code&gt; carries &lt;strong&gt;zero&lt;/strong&gt; guarantees. &lt;code&gt;age&lt;/code&gt; is still the string &lt;code&gt;'42'&lt;/code&gt;, not a number. &lt;code&gt;role&lt;/code&gt; is still the typo &lt;code&gt;'admn'&lt;/code&gt;. Nothing between the socket and your handler has an opinion about whether any of this makes sense.&lt;/p&gt;

&lt;p&gt;So the question is not &lt;em&gt;whether&lt;/em&gt; something validates that data. Something always does. The question is &lt;strong&gt;how many things&lt;/strong&gt;, and &lt;strong&gt;where&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The naive version: four places, four rules
&lt;/h2&gt;

&lt;p&gt;Here is what happens by default, and it happens because every single step of it is reasonable in isolation. The request reaches your handler. The handler is careful, so it checks the email is not empty. It passes the dictionary on to a service function — which does not trust its caller, so it checks for an at-sign. The service calls the database layer, which checks the length before the write. And the template that renders the confirmation trims the whitespace, because whitespace looks bad.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# handler
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;detail&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;invalid input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;

&lt;span class="c1"&gt;# service
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;

&lt;span class="c1"&gt;# db layer
&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;email&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;320&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;

&lt;span class="c1"&gt;# template
&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four places. Four &lt;strong&gt;slightly different&lt;/strong&gt; rules for one field. Nobody wrote it that way on purpose — it accreted, one careful commit at a time.&lt;/p&gt;

&lt;p&gt;And nothing in the system knows the four rules disagree. The day somebody loosens one of them, the other three say nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What that actually costs
&lt;/h3&gt;

&lt;p&gt;I ran it. Not as a thought experiment — as two real FastAPI apps with a SQLite database behind them, and a set of measurement scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First result: a silent split-brain, on a request that succeeded.&lt;/strong&gt; POST an email with spaces around it — &lt;code&gt;"  ada@example.com  "&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;naive status  : 200
naive response: {"email":"ada@example.com", ...}
naive DB row  : {'email': '  ada@example.com  ', ...}
response email == stored email ? False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 200. Nothing failed. Nothing was logged. And the row saved in the database is &lt;strong&gt;not&lt;/strong&gt; the value returned to the user, because layer three and layer four disagree about what an email is. Two truths about the same user, created in the same request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second result: the crashes.&lt;/strong&gt; Four bad bodies at the naive server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{'emial': ..., 'age': '42'}          -&amp;gt; 400  {"detail":"invalid input"}
{'email': ..., 'age': '42'}          -&amp;gt; 500  TypeError: unsupported operand type(s) for //: 'str' and 'int'
{'email': ..., (no age)}             -&amp;gt; 500  KeyError: 'age'
{'email': ..., 'age': None}          -&amp;gt; 500  TypeError: ... 'NoneType' and 'int'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Three of the four returned a 500.&lt;/strong&gt; And the fourth — the one with the typo'd field name — returned a 400 that says &lt;code&gt;invalid input&lt;/code&gt; and &lt;strong&gt;names nothing&lt;/strong&gt;, so the client has no idea which of the three fields it got wrong.&lt;/p&gt;

&lt;p&gt;Worth being precise here, because it is tempting to tell this story with the typo body crashing: it does not. The handler's truthiness check fires first and short-circuits into the 400. The 500 needs a &lt;em&gt;valid&lt;/em&gt; email plus a bad &lt;code&gt;age&lt;/code&gt;. That is the honest version, and it is the stronger one — the naive app either falls over or answers uselessly, and which of the two you get depends on the order in which the checks happen to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third result: the enum nobody enforced.&lt;/strong&gt; &lt;code&gt;LEGAL_ROLES&lt;/code&gt; is declared at the top of the naive app and never used. So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST {"email": "ada@example.com", "age": 42, "role": "admn"}
status: 200
rows now in the DB: [{'email': 'ada@example.com', 'role': 'admn', ...}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP 200, and &lt;code&gt;'admn'&lt;/code&gt; is now a row in your database. Forever. That constant sitting unused at the top of the file is exactly how it happens for real.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mechanism: a border that converts
&lt;/h2&gt;

&lt;p&gt;The fix is one move, and the important part is that &lt;strong&gt;it is not a check&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Validate &lt;strong&gt;once&lt;/strong&gt;, where the data arrives, and nowhere after that. One model, at the door:&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;SignupIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict&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;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EmailStr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&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;254&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&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;editor&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;viewer&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;billing&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;support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/signup&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;signup&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="n"&gt;SignupIn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;      &lt;span class="c1"&gt;# not Request. SignupIn.
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the part people miss, and it is the whole idea:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The border does not hand you back &lt;code&gt;True&lt;/code&gt; or &lt;code&gt;False&lt;/code&gt;. It hands you back a typed value&lt;/strong&gt; — an object that could not have been constructed if the data were wrong. If the body cannot become a &lt;code&gt;SignupIn&lt;/code&gt;, the function body never runs at all.&lt;/p&gt;

&lt;p&gt;That is the difference between a check and a border. A check leaves you holding the same untrusted dictionary you had before, plus a boolean you now have to remember to respect. A border changes what you are holding. After it, &lt;code&gt;body.age&lt;/code&gt; is an &lt;code&gt;int&lt;/code&gt; because there is no reachable universe in which it is not.&lt;/p&gt;

&lt;p&gt;And the consequence shows up as a measurement. Counting defensive lines in the two apps with &lt;code&gt;grep -cE 'is None|if not '&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;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;Naive app&lt;/th&gt;
&lt;th&gt;Bordered app&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Lines of code&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;37&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Defensive &lt;code&gt;is None&lt;/code&gt; / &lt;code&gt;if not&lt;/code&gt; checks&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Validation sites for &lt;code&gt;email&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;5xx from the four bad bodies&lt;/td&gt;
&lt;td&gt;3 of 4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Problems named per round trip&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Thirteen defensive checks became zero. Not because anyone was braver about skipping them — because after the border there is nothing left for them to catch.&lt;/p&gt;

&lt;p&gt;That gives you the diagnostic that makes this idea worth internalising: &lt;strong&gt;every &lt;code&gt;if x is None&lt;/code&gt; deep in your business logic is evidence that the border leaked.&lt;/strong&gt; It is not defensive programming, it is a bug report about your boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  The four rules the border follows
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Say what you accept, not what you reject
&lt;/h3&gt;

&lt;p&gt;An allow-list, not a block-list. A block-list is a list of the attacks and mistakes you already thought of, which by construction excludes the ones that will actually reach you. &lt;code&gt;Literal["admin", "editor", ...]&lt;/code&gt; enumerates the five legal roles; everything else is not a role, and you never have to enumerate the infinite set of things that are not roles.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Reject, do not coerce
&lt;/h3&gt;

&lt;p&gt;This is the rule most codebases get backwards, because the default is convenient. Same input, one config flag apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input: {'age': '42'}   type(input['age']): &amp;lt;class 'str'&amp;gt;

LAX    -&amp;gt; .age = 42, type &amp;lt;class 'int'&amp;gt;
STRICT -&amp;gt; ValidationError:
          Input should be a valid integer [type=int_type, input_value='42', input_type=str]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lax mode silently turns the &lt;em&gt;text&lt;/em&gt; &lt;code&gt;"42"&lt;/code&gt; into the &lt;em&gt;number&lt;/em&gt; &lt;code&gt;42&lt;/code&gt;. That feels helpful right up until the client that sent a string was sending a string because of a bug, and you have just laundered the bug into your database as a plausible-looking integer. Rejecting is what lets you find out.&lt;/p&gt;

&lt;p&gt;One precision point the video states loosely and the code makes exact: &lt;strong&gt;&lt;code&gt;strict=True&lt;/code&gt; controls type coercion, not value normalisation.&lt;/strong&gt; A field &lt;em&gt;type&lt;/em&gt; can still transform the value it accepts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;strict=True, EmailStr:
  '  ada@example.com  ' -&amp;gt; 'ada@example.com'
  'Ada@EXAMPLE.COM'     -&amp;gt; 'Ada@example.com'   # domain lowercased, local part not
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strict means it will not accept a &lt;code&gt;str&lt;/code&gt; where you declared an &lt;code&gt;int&lt;/code&gt;. It is not a promise that the bytes pass through untouched. And that normalisation is a feature here, not a leak: it is why the bordered app returned and stored the &lt;em&gt;same&lt;/em&gt; trimmed address, while the naive app stored the padded one. The border normalised once, in one place.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Name the field that failed
&lt;/h3&gt;

&lt;p&gt;Compare the two answers to one bad body, &lt;code&gt;{"email": "not-an-email", "age": 7, "role": "wizard"}&lt;/code&gt;:&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;NAIVE&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"invalid input"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;BORDERED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"loc"&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="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value is not a valid email address: An email address must have an @-sign."&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="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"greater_than_equal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"loc"&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="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"age"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Input should be greater than or equal to 13"&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="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"literal_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"loc"&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="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Input should be 'admin', 'editor', 'viewer', 'billing' or 'support'"&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;strong&gt;Three problems named in one round trip, instead of one.&lt;/strong&gt; That is a client that fixes its request once instead of three times.&lt;/p&gt;

&lt;p&gt;Be careful about how far you push this claim, though. The border reports every problem &lt;em&gt;at that layer&lt;/em&gt;. Pydantic still short-circuits per field, and a model validator that runs after field validation does not run at all if a field already failed. "Three problems in one round trip instead of one" is measured and true. "All problems, always" is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. HTTP is not the only edge
&lt;/h3&gt;

&lt;p&gt;The most useful demonstration is the one where you cannot blame the internet. A producer in this same process puts a message on a &lt;code&gt;queue.Queue&lt;/code&gt;; a consumer reads it back and revalidates with the same model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;producer drops a field   -&amp;gt; role    / Field required [type=missing]
producer changes a type  -&amp;gt; user_id / Input should be a valid integer [type=int_type]
producer adds a field    -&amp;gt; tier    / Extra inputs are not permitted [type=extra_forbidden]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our own code wrote that message and our own code serialised it, and the consumer still caught a schema change, a type change, and a field nobody agreed to. Anywhere data crosses from a system you do not control &lt;em&gt;right now&lt;/em&gt; — a queue, a webhook, a config file, a CSV, another team's service, last year's version of your own producer — is an edge.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it costs you, honestly
&lt;/h2&gt;

&lt;p&gt;A border is a trade, not a free win. Four costs, all of them measured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shape is now written in two places, and it drifts immediately.&lt;/strong&gt; Your model is one definition of a user; your table is another. In the test schema they disagreed in both directions within minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model: email max_length=254     vs  DB: CHECK(length(email) &amp;lt;= 320)
  -&amp;gt; a 272-char email: MODEL rejects (string_too_long), DB stores it happily

model: age ge=13                vs  DB: CHECK(age &amp;gt;= 18)
  -&amp;gt; age=15: MODEL accepts, then
     sqlite3.IntegrityError: CHECK constraint failed: age &amp;gt;= 18

model: role Literal[...]        vs  DB: no CHECK on role at all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "one model at the edge means the shape is defined in one place" is not true. It is defined in fewer places, which is the actual win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A strict border breaks callers — loudly, which is the point, but it still breaks them.&lt;/strong&gt; Add a required field and every existing client fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V2 with a REQUIRED field: country / Field required [type=missing]
V2 with a DEFAULT       : accepted, country='CH'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line of difference. And with &lt;code&gt;extra="forbid"&lt;/code&gt;, a &lt;strong&gt;purely additive&lt;/strong&gt; producer change — adding an optional &lt;code&gt;tier&lt;/code&gt; field nobody has to use — takes &lt;em&gt;every&lt;/em&gt; consumer down. On a public API that is what you want. On an internal event bus it may not be, and you should choose deliberately rather than inherit the default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over-validating drags domain rules into the boundary.&lt;/strong&gt; "Is this a well-formed email address" belongs at the border. "May this user publish" does not — that needs the database, the session, and the current state of the world, none of which the border has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not a security or integrity boundary on its own.&lt;/strong&gt; A perfectly well-formed duplicate sails straight through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model accepted: email='wiz@example.com' role='admin' age=30
sqlite3.IntegrityError: UNIQUE constraint failed: users.email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model has no idea what is already in the table. Uniqueness, authorization, balances, rate limits, quotas — still the database's job, still the service's job. Edge validation stops bad &lt;strong&gt;shapes&lt;/strong&gt;, not bad &lt;strong&gt;facts&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The performance folklore is backwards
&lt;/h2&gt;

&lt;p&gt;The usual objection is that validating every request costs you. Measured with &lt;code&gt;timeit&lt;/code&gt;, N = 100,000, pydantic 2.12.5 on Python 3.10:&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;Per call&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json.loads(bytes)&lt;/code&gt; — baseline, no validation&lt;/td&gt;
&lt;td&gt;2.75 µs&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;Small.model_validate(dict)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.30 µs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;Small.model_validate_json(bytes)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.31 µs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;WithEmailStr.model_validate(dict)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;92.9 µs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Validating a three-field model straight from JSON bytes is &lt;strong&gt;1.31 µs — roughly twice as fast as the &lt;code&gt;json.loads&lt;/code&gt; you were already paying for.&lt;/strong&gt; pydantic-core parses the JSON itself in Rust, so you skip the stdlib parse entirely. On this model, validation is not a tax. It is a discount.&lt;/p&gt;

&lt;p&gt;But do not then say validation is free, because one field blows that up: &lt;strong&gt;&lt;code&gt;EmailStr&lt;/code&gt; costs 92.9 µs, about 68× the rest of the model put together.&lt;/strong&gt; The &lt;code&gt;email-validator&lt;/code&gt; library does real syntax and IDNA work in Python. If a hot endpoint validates an email on every call, &lt;em&gt;that&lt;/em&gt; is your cost — and it is one field, not the idea. Which is a much more useful thing to know than either slogan, because you can act on it: keep the border, and decide consciously whether that endpoint needs full RFC email validation or a cheaper constrained string.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same border, one layer up: LLM tool calls and structured output
&lt;/h2&gt;

&lt;p&gt;If you are building anything agentic, you already have this problem in its purest form — and most codebases are currently solving it with the four-checks version.&lt;/p&gt;

&lt;p&gt;When a model calls a tool, what your code receives is &lt;strong&gt;a JSON string generated by a probabilistic text generator&lt;/strong&gt;. That is not a stricter edge than an HTTP request body; it is a looser one. An HTTP client is at worst buggy. A language model is &lt;em&gt;sampling&lt;/em&gt;, and it will occasionally produce &lt;code&gt;"42"&lt;/code&gt; where you documented an integer, omit a field it has filled correctly a thousand times, invent an enum value that reads perfectly plausibly, or hallucinate a parameter your function does not have. &lt;code&gt;role: "admn"&lt;/code&gt; is exactly the failure mode of a model that is 99% right.&lt;/p&gt;

&lt;p&gt;So the border is the same border:&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;SearchArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict&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;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&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;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&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;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs&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;tickets&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;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SearchArgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# or it does not run
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things transfer directly, and one flips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The allow-list becomes the schema you hand the model.&lt;/strong&gt; The same declaration that rejects bad arguments is also the JSON Schema you put in the tool definition — so the allow-list is doing double duty: it constrains generation &lt;em&gt;and&lt;/em&gt; it verifies the result. With constrained decoding or strict structured-output modes, part of your border has moved into the decoder itself. That is genuinely better, and it is still not sufficient — the shape can be guaranteed while the &lt;em&gt;values&lt;/em&gt; remain nonsense, so you still validate what comes back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 422 body becomes a repair prompt.&lt;/strong&gt; This is the part that is nicer in the LLM case than in the HTTP one. Pydantic's error list is not just diagnostics — it is text a model can act on. Feed &lt;code&gt;Input should be greater than or equal to 13&lt;/code&gt; back to the model and it will usually fix the argument on the next turn. The "name the field that failed" rule stops being a courtesy to a human client and becomes the actual control loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every edge still counts, and there are more of them.&lt;/strong&gt; Model output into your tool, tool output back into the context window, retrieved documents into the prompt, another agent's message into this agent's inbox. Each of those is an untrusted dictionary crossing into code that assumes a shape.&lt;/p&gt;

&lt;p&gt;And the flip: &lt;strong&gt;the cost argument disappears completely.&lt;/strong&gt; That 92.9 µs &lt;code&gt;EmailStr&lt;/code&gt; field, which was worth thinking about on a hot HTTP endpoint, sits next to an inference call measured in &lt;em&gt;hundreds of milliseconds&lt;/em&gt;. It is four orders of magnitude cheaper than the thing it is guarding. There is no performance conversation to have.&lt;/p&gt;

&lt;p&gt;One warning, and it is §8c again in a much more dangerous costume: &lt;strong&gt;validating tool arguments is not authorization.&lt;/strong&gt; A model that asks to delete every row in a table will produce beautifully well-formed arguments. The border will hand you a perfectly valid &lt;code&gt;DeleteArgs&lt;/code&gt;. Whether that call is &lt;em&gt;allowed&lt;/em&gt; is a question about permissions and blast radius, and no schema in the world answers it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Validation is not a check you perform. It is a border you cross.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Do it &lt;strong&gt;once&lt;/strong&gt;, at the place data enters your program, and let it &lt;strong&gt;convert&lt;/strong&gt; rather than approve — so what comes out the other side is a typed value that could not have been constructed if the input were wrong. Everything inside then takes that type as a precondition, instead of asking the same question again in slightly different words.&lt;/p&gt;

&lt;p&gt;The practical test is the one you can run on your own codebase this afternoon: &lt;strong&gt;go find the &lt;code&gt;if x is None&lt;/code&gt; checks buried in your business logic.&lt;/strong&gt; Every one of them is either a border you never built, or a border that leaked. In the app measured here there were thirteen of them, and building one border took all thirteen to zero — along with 36 lines of code and three of four 500s.&lt;/p&gt;

&lt;p&gt;And then stay honest about the edges of the idea: the shape is still written in your database too, and it will drift. Strict borders break callers on schema change, which is a feature you must still opt into deliberately on an internal bus. And a well-formed duplicate is not the model's problem — that is what the &lt;code&gt;UNIQUE&lt;/code&gt; constraint is for, and it stays.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The mechanism — validate once and convert&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pydantic documentation, &lt;a href="https://docs.pydantic.dev/latest/concepts/models/" rel="noopener noreferrer"&gt;Models&lt;/a&gt; — &lt;code&gt;model_validate&lt;/code&gt; / &lt;code&gt;model_validate_json&lt;/code&gt; and the "parse, don't validate" behaviour this whole article rests on: the model returns a typed instance or raises, never a boolean.&lt;/li&gt;
&lt;li&gt;Alexis King, &lt;a href="https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/" rel="noopener noreferrer"&gt;&lt;em&gt;Parse, Don't Validate&lt;/em&gt;&lt;/a&gt; (2019) — the clearest statement of why a function that returns a &lt;em&gt;type&lt;/em&gt; beats one that returns &lt;code&gt;True&lt;/code&gt;, and where the "every &lt;code&gt;if x is None&lt;/code&gt; is evidence the border leaked" diagnostic comes from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Rule 1 — say what you accept&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OWASP, &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html" rel="noopener noreferrer"&gt;Input Validation Cheat Sheet&lt;/a&gt; — the allow-list-over-block-list position, stated as a security control rather than a style preference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Rule 2 — reject, do not coerce&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pydantic documentation, &lt;a href="https://docs.pydantic.dev/latest/concepts/conversion_table/" rel="noopener noreferrer"&gt;Conversion Table&lt;/a&gt; — exactly which inputs are accepted for each field type in lax versus strict mode; the &lt;code&gt;'42'&lt;/code&gt; → &lt;code&gt;42&lt;/code&gt; case is in here.&lt;/li&gt;
&lt;li&gt;Pydantic documentation, &lt;a href="https://docs.pydantic.dev/latest/concepts/strict_mode/" rel="noopener noreferrer"&gt;Strict Mode&lt;/a&gt; — what &lt;code&gt;strict=True&lt;/code&gt; does and does not cover, which is the source of the coercion-versus-normalisation distinction above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Rule 3 — name the field that failed&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI documentation, &lt;a href="https://fastapi.tiangolo.com/tutorial/handling-errors/" rel="noopener noreferrer"&gt;Handling Errors&lt;/a&gt; — &lt;code&gt;RequestValidationError&lt;/code&gt; and the shape of the 422 body, including the &lt;code&gt;loc&lt;/code&gt; / &lt;code&gt;msg&lt;/code&gt; / &lt;code&gt;type&lt;/code&gt; entries quoted here.&lt;/li&gt;
&lt;li&gt;RFC 9457, &lt;a href="https://www.rfc-editor.org/rfc/rfc9457.html" rel="noopener noreferrer"&gt;&lt;em&gt;Problem Details for HTTP APIs&lt;/em&gt;&lt;/a&gt; (IETF, 2023) — the standard way to return a machine-readable error that says which member of the request was wrong and why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The costs — drift, breakage, and what the border does not cover&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pydantic documentation, &lt;a href="https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.extra" rel="noopener noreferrer"&gt;Model Config — &lt;code&gt;extra&lt;/code&gt;&lt;/a&gt; — the &lt;code&gt;extra="forbid"&lt;/code&gt; setting and therefore the additive-producer-change trade-off.&lt;/li&gt;
&lt;li&gt;SQLite documentation, &lt;a href="https://www.sqlite.org/lang_createtable.html#check_constraints" rel="noopener noreferrer"&gt;CHECK constraints&lt;/a&gt; — the second definition of your data's shape, and why the &lt;code&gt;UNIQUE&lt;/code&gt; violation in the duplicate case came from the database rather than the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The LLM edge&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenAI, &lt;a href="https://platform.openai.com/docs/guides/structured-outputs" rel="noopener noreferrer"&gt;Structured Outputs&lt;/a&gt; — schema-constrained generation: the allow-list pushed into the decoder, and an explicit statement of what it does and does not guarantee.&lt;/li&gt;
&lt;li&gt;Anthropic, &lt;a href="https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;Tool use&lt;/a&gt; — how tool arguments are returned and why they need validating on arrival like any other untrusted input.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you'd expect is missing, say so in the comments and I'll add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; the &lt;a href="https://youtube.com/shorts/NURKdhLQ_2c" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; draws the four-stage pipeline and shows the drift; the &lt;a href="https://youtu.be/hSDufaDKZPI" rel="noopener noreferrer"&gt;full episode&lt;/a&gt; proves all of it on two apps that really run.&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>api</category>
    </item>
    <item>
      <title>Read Replicas Explained: How to Scale Database Reads Without Shipping a Time-Travel Bug</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:13:58 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/read-replicas-explained-how-to-scale-database-reads-without-shipping-a-time-travel-bug-ki8</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/read-replicas-explained-how-to-scale-database-reads-without-shipping-a-time-travel-bug-ki8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtube.com/shorts/u-B9Jtp99UM" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/read-replicas-explained-how-to-scale-database-reads-without-shipping-a-time-travel-bug?id=180" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is a system design question that comes up constantly, and that almost everyone answers half-correctly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your database has 300 million rows. It's serving reads &lt;strong&gt;and&lt;/strong&gt; writes, 24 hours a day. It's falling over. How do you scale it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reflex answer is "add read replicas." That's not wrong — it's the right answer to the &lt;strong&gt;read&lt;/strong&gt; half of the question. But the half people skip is the half where the bugs live, and it's the half that separates someone who has read about replicas from someone who has run them.&lt;/p&gt;

&lt;p&gt;Let's build it from the floor up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Start below the concept: one box
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DevTalk&lt;/strong&gt; is a developer forum. Threads, comments, upvotes. It runs on &lt;strong&gt;one database box&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every page view hits that machine. Every new comment hits that machine. One CPU, one disk, one queue for all of it. That works fine — until it doesn't.&lt;/p&gt;

&lt;p&gt;Before reaching for a solution, measure what the traffic actually &lt;em&gt;is&lt;/em&gt;. On a forum, it looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Thousands of people &lt;strong&gt;reading&lt;/strong&gt; threads.&lt;/li&gt;
&lt;li&gt;A handful of people &lt;strong&gt;posting&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roughly &lt;strong&gt;90% reads, 10% writes&lt;/strong&gt;. That lopsided ratio isn't a detail. It's the entire opening — it's the reason a fix exists at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why one box fails, specifically
&lt;/h3&gt;

&lt;p&gt;The failure mode is more interesting than "it gets slow." As read traffic grows, the flood of reads &lt;strong&gt;crowds out the writes&lt;/strong&gt;. Your database has finite CPU, finite disk IO, finite connection slots, and reads are consuming nearly all of them.&lt;/p&gt;

&lt;p&gt;So the user who is &lt;em&gt;contributing&lt;/em&gt; — writing a comment, the thing your product actually exists for — is queued behind 10,000 people who are just browsing. The cheapest, least valuable requests are starving the most valuable one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mechanism: copy the box
&lt;/h2&gt;

&lt;p&gt;The fix is exactly as literal as it sounds. &lt;strong&gt;Copy the box.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The original machine becomes the &lt;strong&gt;primary&lt;/strong&gt;. The copies are &lt;strong&gt;read replicas&lt;/strong&gt; — full, live copies of the same data, on separate machines.&lt;/p&gt;

&lt;p&gt;And then one rule does all the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every &lt;strong&gt;WRITE&lt;/strong&gt; goes to the &lt;strong&gt;primary&lt;/strong&gt;, and only the primary.&lt;/li&gt;
&lt;li&gt;Every &lt;strong&gt;READ&lt;/strong&gt; fans out across the &lt;strong&gt;replicas&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One writer, many readers. That's the whole idea. Everything else is plumbing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the copies stay current
&lt;/h3&gt;

&lt;p&gt;The primary already writes every change it makes to a log — Postgres calls it the &lt;strong&gt;WAL&lt;/strong&gt; (write-ahead log), MySQL calls it the &lt;strong&gt;binlog&lt;/strong&gt;. It exists for crash recovery, and it's a complete, ordered record of every change.&lt;/p&gt;

&lt;p&gt;Replication reuses it. The primary &lt;strong&gt;streams that log&lt;/strong&gt; to each replica, continuously. Each replica &lt;strong&gt;replays&lt;/strong&gt; it, applying the same changes in the same order. The replica isn't running your queries again — it's replaying the outcome.&lt;/p&gt;

&lt;p&gt;Add replicas, add read capacity, roughly linearly. Three replicas, three times the read throughput.&lt;/p&gt;

&lt;h3&gt;
  
  
  Routing, in practice
&lt;/h3&gt;

&lt;p&gt;Most stacks give you two connection strings and let you pick per query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Two engines, two roles.
&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://primary.db.internal/devtalk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;replica&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://replica.db.internal/devtalk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# usually behind a LB
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# A read: safe to be a few ms stale.
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;replica&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&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;conn&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;SELECT_THREAD&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_comment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# A write: primary, always. There is no "sometimes" here.
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INSERT_COMMENT&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;thread&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure mode to watch for is a write that sneaks onto a replica connection — replicas are read-only, so it doesn't corrupt anything, it just throws. That's a good thing: the database enforces the rule you're trying to enforce in code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The upper bound nobody mentions
&lt;/h2&gt;

&lt;p&gt;Here's where "just add more replicas" stops being a strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every replica replays every write.&lt;/strong&gt; All of them. A replica isn't doing less work than the primary on the write path — it's doing exactly the same write work, just without originating it.&lt;/p&gt;

&lt;p&gt;Which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replicas buy you &lt;strong&gt;zero write capacity&lt;/strong&gt;. If your bottleneck is writes, replicas do nothing. Worse than nothing — you now have more machines applying the same write load.&lt;/li&gt;
&lt;li&gt;Each replica &lt;strong&gt;costs money&lt;/strong&gt;, and adds one more copy that can fall behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Replicas are a knob for one specific problem. Know which problem you have before you turn it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Dimension&lt;/th&gt;
      &lt;th&gt;Primary&lt;/th&gt;
      &lt;th&gt;Read replica&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Accepts writes&lt;/td&gt;
      &lt;td&gt;Yes — the only one that does&lt;/td&gt;
      &lt;td&gt;No, read-only&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Serves reads&lt;/td&gt;
      &lt;td&gt;Yes, but you want to spare it&lt;/td&gt;
      &lt;td&gt;Yes — this is its whole job&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Data freshness&lt;/td&gt;
      &lt;td&gt;Always current, by definition&lt;/td&gt;
      &lt;td&gt;Milliseconds behind, sometimes much worse&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Write work performed&lt;/td&gt;
      &lt;td&gt;Originates every change&lt;/td&gt;
      &lt;td&gt;Replays every change anyway&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;What adding one buys you&lt;/td&gt;
      &lt;td&gt;n/a — there is exactly one&lt;/td&gt;
      &lt;td&gt;More read throughput, ~linearly&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;What adding one does NOT buy you&lt;/td&gt;
      &lt;td&gt;n/a&lt;/td&gt;
      &lt;td&gt;Any write capacity at all&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Scaling limit&lt;/td&gt;
      &lt;td&gt;Vertical only — a bigger box&lt;/td&gt;
      &lt;td&gt;Cost, and lag pressure per replica&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Replication lag: the reason this is a real topic
&lt;/h2&gt;

&lt;p&gt;Now the part that turns this from a diagram into an incident.&lt;/p&gt;

&lt;p&gt;That streaming is &lt;strong&gt;asynchronous&lt;/strong&gt;. The primary commits your write, tells the client "done", and moves on. It does &lt;strong&gt;not&lt;/strong&gt; wait for the replicas to catch up. That's deliberate — if it waited, every write would be as slow as your slowest replica, and you'd have traded a read problem for a much worse write problem.&lt;/p&gt;

&lt;p&gt;The consequence: &lt;strong&gt;a replica is always a little behind.&lt;/strong&gt; Usually single-digit milliseconds. During a bulk import, a long-running vacuum, or a network hiccup, it can be seconds or minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The bug you will actually hit
&lt;/h3&gt;

&lt;p&gt;Here is the sequence, and it is so ordinary that it ships to production constantly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user types a comment and hits &lt;strong&gt;Post&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The write lands on the &lt;strong&gt;primary&lt;/strong&gt;. It commits. The API returns 200.&lt;/li&gt;
&lt;li&gt;The page reloads and fetches the thread.&lt;/li&gt;
&lt;li&gt;That read is a read, so it goes to a &lt;strong&gt;replica&lt;/strong&gt; — one that happens to be 40ms behind.&lt;/li&gt;
&lt;li&gt;The comment &lt;strong&gt;isn't there.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From the user's side, the app just ate their comment. So they post it again. Now you have duplicate comments, and a support ticket that says "the site is broken" with no error in any log, because nothing errored. Every component did exactly what it was told.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;consistency&lt;/strong&gt; problem wearing a UI bug's clothing, and it is the single most common way read replicas hurt teams that adopted them successfully.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: read-your-own-writes
&lt;/h3&gt;

&lt;p&gt;The fix has a name, and knowing the name is most of the battle: &lt;strong&gt;read-your-own-writes consistency&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The rule: for a few seconds after a user writes something, route &lt;strong&gt;that one user's&lt;/strong&gt; reads to the &lt;strong&gt;primary&lt;/strong&gt;. Everyone else keeps hitting replicas.&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;RYOW_WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&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;def&lt;/span&gt; &lt;span class="nf"&gt;engine_for_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# After you write, you read from the primary — briefly, and only you.
&lt;/span&gt;    &lt;span class="n"&gt;last_write&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lastwrite:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last_write&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_write&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;RYOW_WINDOW&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;primary&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;replica&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_comment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INSERT_COMMENT&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;thread&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&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="n"&gt;cache&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;lastwrite:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RYOW_WINDOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note how narrow this is. It's not "turn off replicas." It's a &lt;strong&gt;per-user, time-boxed exception&lt;/strong&gt; that costs the primary a trickle of extra reads — the reads of people who just wrote, which by the 90/10 ratio is a tiny slice of traffic.&lt;/p&gt;

&lt;p&gt;There are stronger variants when you need them: pin reads to a replica that has confirmed it replayed at least the log position of your write (Postgres exposes this via LSN comparison), or run one synchronous replica for the reads that truly cannot be stale. Both cost latency. Start with the time-boxed version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watch the lag, or the lag will find you
&lt;/h3&gt;

&lt;p&gt;Replication lag is a first-class metric, not a debugging afterthought. Alert on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Postgres, on a replica: how far behind is this copy, in seconds?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pg_last_xact_replay_timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;replication_delay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And have a rule for what happens when it spikes: a replica lagging 30 seconds should be pulled out of the read pool, not left quietly serving stale pages.&lt;/p&gt;




&lt;h2&gt;
  
  
  Replicas are not sharding
&lt;/h2&gt;

&lt;p&gt;Keep this distinction clean, because interviews probe it and architectures die on it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replication&lt;/strong&gt; copies &lt;em&gt;all&lt;/em&gt; the data to more machines. It scales &lt;strong&gt;reads&lt;/strong&gt;. Every machine has everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharding&lt;/strong&gt; splits the data &lt;em&gt;across&lt;/em&gt; machines by some key. It scales &lt;strong&gt;writes&lt;/strong&gt; (and dataset size). Each machine has a slice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your writes are the bottleneck, replicas will not help you, no matter how many you add. That's sharding's problem, and sharding is a substantially harder tool — cross-shard queries, rebalancing, and a shard key you can never comfortably change.&lt;/p&gt;

&lt;p&gt;They compose, too: a sharded system usually has replicas &lt;em&gt;per shard&lt;/em&gt;. But reach for them for different reasons.&lt;/p&gt;




&lt;h2&gt;
  
  
  The AI reframe: replicas under a RAG stack
&lt;/h2&gt;

&lt;p&gt;This isn't only a 2010s web-forum concern. It reappears, with the same shape and the same bug, the moment you put a retrieval layer in front of an LLM.&lt;/p&gt;

&lt;p&gt;A typical RAG service has exactly the DevTalk asymmetry, only more extreme:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reads:&lt;/strong&gt; every user question triggers a vector similarity search, often several (query expansion, multi-hop retrieval, re-ranking a wide candidate set). One question can be five reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes:&lt;/strong&gt; ingestion — documents chunked, embedded, upserted. Bursty, and comparatively rare.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your embeddings live in Postgres with &lt;code&gt;pgvector&lt;/code&gt;, you have a database serving 95%+ reads, and read replicas are the obvious lever: fan the similarity searches across replicas, keep ingestion on the primary. Vector search is CPU-hungry per query, so this scales unusually well.&lt;/p&gt;

&lt;p&gt;And then you hit the same bug, in a form that's harder to diagnose:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A user uploads a document. The ingestion job embeds it and writes it to the primary. The UI says "indexed". The user immediately asks a question about that document — the retrieval read hits a replica that hasn't replayed the upsert yet — and the model answers &lt;strong&gt;"I don't have information about that."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The user doesn't see a stale row. They see a model that is &lt;em&gt;confidently wrong&lt;/em&gt;, which is far more corrosive to trust than a missing comment. The fix is identical: read-your-own-writes on the retrieval path, scoped to the user (or tenant) who just ingested.&lt;/p&gt;

&lt;p&gt;A second wrinkle worth knowing: an ANN index (HNSW, IVFFlat) has to be &lt;strong&gt;built&lt;/strong&gt;, and replaying an index build on every replica is real, sustained work. Bulk ingestion is exactly the workload that pushes replication lag from milliseconds into minutes — which is to say, the moment you most want the new documents visible is the moment your replicas are furthest behind. Ingest in throttled batches, and watch the lag metric during them.&lt;/p&gt;

&lt;p&gt;The same asymmetry logic also drives the serving tier around the model — read-heavy traffic gets fanned out and cached, the small write path stays authoritative — but the database layer is where the correctness bug actually bites.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read replicas scale reads by giving up "now."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the trade in one line. Your data on a replica is not wrong — it is &lt;em&gt;correct, just slightly late&lt;/em&gt;. And the engineering judgment isn't whether to accept that; it's deciding, read by read, which ones can tolerate lateness and which absolutely cannot.&lt;/p&gt;

&lt;p&gt;A working default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fan out to replicas:&lt;/strong&gt; thread lists, search results, feeds, dashboards, analytics, recommendation lookups, anything a cache would already be serving stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep on the primary:&lt;/strong&gt; the read immediately after that user's own write, balance and inventory checks, anything that gates a decision (auth, permissions, "can this user do X"), and any read whose result you're about to write back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add replicas when reads are your bottleneck. Reach for sharding when writes are. And whichever you add, ship the lag metric with it — because the failure mode isn't an error page, it's a user who quietly stops trusting your app.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The read/write asymmetry and the one-box floor&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (O'Reilly, 2017) — chapter 5, "Replication", is the single best treatment of everything in this article; it opens with exactly this argument for why a read-heavy workload has a replication-shaped answer.&lt;/li&gt;
&lt;li&gt;Silvia Botros &amp;amp; Jeremy Tinley, &lt;em&gt;High Performance MySQL&lt;/em&gt;, 4th ed. (O'Reilly, 2021) — the operational view: what actually saturates first on one box, and how replication topologies are run in practice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The mechanism — log streaming and replay&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL documentation, &lt;a href="https://www.postgresql.org/docs/current/high-availability.html" rel="noopener noreferrer"&gt;High Availability, Load Balancing, and Replication&lt;/a&gt; — streaming replication, hot standby, and the knobs (&lt;code&gt;synchronous_commit&lt;/code&gt;, &lt;code&gt;hot_standby_feedback&lt;/code&gt;) referenced above.&lt;/li&gt;
&lt;li&gt;MySQL documentation, &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/replication.html" rel="noopener noreferrer"&gt;Replication&lt;/a&gt; — the binlog-based equivalent, including row- vs statement-based replication.&lt;/li&gt;
&lt;li&gt;Alex Petrov, &lt;em&gt;Database Internals&lt;/em&gt; (O'Reilly, 2019) — part II on distributed systems; useful for understanding &lt;em&gt;why&lt;/em&gt; the log is the replication unit rather than the query.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Replication lag and read-your-own-writes&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (O'Reilly, 2017) — "Problems with Replication Lag" in chapter 5 names read-your-own-writes, monotonic reads, and consistent prefix reads. The comment-disappears bug is his "reading your own writes" example.&lt;/li&gt;
&lt;li&gt;PostgreSQL documentation, &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-REPLICATION-VIEW" rel="noopener noreferrer"&gt;Monitoring — replication views&lt;/a&gt; — &lt;code&gt;pg_stat_replication&lt;/code&gt; and the LSN positions you need to alert on lag or to pin a read to a caught-up replica.&lt;/li&gt;
&lt;li&gt;Amazon, &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ReadRepl.html" rel="noopener noreferrer"&gt;Working with Amazon RDS read replicas&lt;/a&gt; — the managed-service version, and a clear statement of the asynchronous guarantee you are actually buying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Replicas vs. sharding&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (O'Reilly, 2017) — chapter 6, "Partitioning", is the other tool; reading 5 and 6 back to back makes the distinction permanent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The retrieval/AI angle&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pgvector, &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;project documentation&lt;/a&gt; — index types (HNSW, IVFFlat), build cost, and the read characteristics that make vector search a good replica candidate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you'd expect is missing, say so in the comments and I'll add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; the &lt;a href="https://youtube.com/shorts/u-B9Jtp99UM" rel="noopener noreferrer"&gt;3-minute version&lt;/a&gt; builds DevTalk from one box, adds the replicas on screen, and walks through the disappearing-comment bug and its fix.&lt;/p&gt;

</description>
      <category>database</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>MVCC and VACUUM: Why Deleting a Million Rows Made Your Table Bigger</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:07:30 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/mvcc-and-vacuum-why-deleting-a-million-rows-made-your-table-bigger-10e6</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/mvcc-and-vacuum-why-deleting-a-million-rows-made-your-table-bigger-10e6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtube.com/shorts/ZKo53qR9IqU" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/mvcc-and-vacuum-why-deleting-a-million-rows-made-your-table-bigger?id=179" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Someone deletes a million old rows to free up disk. They watch the table afterwards and it is &lt;strong&gt;bigger&lt;/strong&gt; than before.&lt;/p&gt;

&lt;p&gt;Nothing is broken. They just met the machinery that lets every read in the database run without ever waiting for a write — and got the invoice for it.&lt;/p&gt;

&lt;p&gt;To see why, you have to start one level below the concept everybody quotes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The floor: an UPDATE does not overwrite the row
&lt;/h2&gt;

&lt;p&gt;Here is the thing that explains all of it.&lt;/p&gt;

&lt;p&gt;When your database runs an &lt;code&gt;UPDATE&lt;/code&gt;, it does &lt;strong&gt;not&lt;/strong&gt; go to that row on disk and change the bytes. It writes a &lt;strong&gt;new version&lt;/strong&gt; of the row somewhere else, and stamps the old one &lt;em&gt;dead as of transaction 91&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Both versions are now sitting in the table.&lt;/p&gt;

&lt;p&gt;Why go to that trouble? Because of a query that started a second ago and is still running. That query must keep seeing the world it started in. If the update had overwritten the bytes, the reader would either have to be blocked until the writer finished, or watch its own data change underneath it mid-scan.&lt;/p&gt;

&lt;p&gt;So the database keeps both, and lets each reader figure out which one is theirs.&lt;/p&gt;

&lt;p&gt;Every row therefore carries two hidden columns you never wrote:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;xmin&lt;/code&gt;&lt;/strong&gt; — the transaction that &lt;strong&gt;created&lt;/strong&gt; this version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;xmax&lt;/code&gt;&lt;/strong&gt; — the transaction that &lt;strong&gt;killed&lt;/strong&gt; it (empty while it is still live).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can see them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmax&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;status&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4711&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

 &lt;span class="n"&gt;xmin&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;xmax&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="c1"&gt;------+------+------+---------&lt;/span&gt;
   &lt;span class="mi"&gt;87&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;   &lt;span class="mi"&gt;91&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;4711&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;
   &lt;span class="mi"&gt;91&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;    &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;4711&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;shipped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One logical row. Two physical rows. This is the whole idea, and everything below is a consequence of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Snapshots: how a reader knows which version is its own
&lt;/h2&gt;

&lt;p&gt;Each query — more precisely, each transaction, depending on your isolation level — takes a &lt;strong&gt;snapshot&lt;/strong&gt;. A snapshot is essentially just a number plus a short list of transactions that were still in flight when it was taken.&lt;/p&gt;

&lt;p&gt;The visibility rule is then almost embarrassingly simple. A row version is visible to you if:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;its &lt;code&gt;xmin&lt;/code&gt; committed &lt;strong&gt;before&lt;/strong&gt; your snapshot, &lt;strong&gt;and&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;its &lt;code&gt;xmax&lt;/code&gt; is empty, or belongs to a transaction that had &lt;strong&gt;not&lt;/strong&gt; committed before your snapshot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run that against the two versions of order 4711 above. A reader with snapshot 89 sees version &lt;code&gt;(87, 91)&lt;/code&gt; — created before it, killed by a transaction that had not happened yet — so it reads &lt;code&gt;pending&lt;/code&gt;. A reader with snapshot 95 sees &lt;code&gt;(91, 0)&lt;/code&gt; and reads &lt;code&gt;shipped&lt;/code&gt;. Same table, same instant, two different truths, both correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing locked.&lt;/strong&gt; That is the payoff, and it is worth saying precisely, because the slogan gets repeated without its meaning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Readers never block writers, and writers never block readers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A reader never waits for a writer, because the version the reader needs is still physically sitting there. A writer never waits for a reader, because it is appending a new version rather than fighting for the old one. Two writers touching the &lt;em&gt;same row&lt;/em&gt; still serialise — MVCC does not make write conflicts disappear — but the read path, which is most of your traffic, stops queueing entirely.&lt;/p&gt;

&lt;p&gt;That is a genuinely large win. Now the bill.&lt;/p&gt;




&lt;h2&gt;
  
  
  A DELETE frees nothing
&lt;/h2&gt;

&lt;p&gt;If an &lt;code&gt;UPDATE&lt;/code&gt; is "insert new, stamp old", what is a &lt;code&gt;DELETE&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;only&lt;/strong&gt; the stamp. The database writes &lt;code&gt;xmax = 104&lt;/code&gt; onto the row and moves on. It does not free a byte. It cannot — some transaction older than 104 may still legitimately need to read that row.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;DELETE FROM orders WHERE created_at &amp;lt; '2024-01-01'&lt;/code&gt; on a million rows produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a million rows that are now &lt;strong&gt;dead tuples&lt;/strong&gt; — invisible to every new query, still fully present on disk,&lt;/li&gt;
&lt;li&gt;a WAL record for each one,&lt;/li&gt;
&lt;li&gt;and, on a table that was also taking ordinary update churn, a total of something like &lt;strong&gt;3,400,000&lt;/strong&gt; dead tuples.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Live rows: unchanged at 2,000,000. File on disk: &lt;strong&gt;1.2 GB → 4.8 GB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the moment people file the bug report.&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;What happens on disk&lt;/th&gt;
      &lt;th&gt;Space freed immediately&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;INSERT&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;New version written, &lt;code&gt;xmin&lt;/code&gt; = your txn&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;UPDATE&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;New version written &lt;em&gt;and&lt;/em&gt; old one stamped &lt;code&gt;xmax&lt;/code&gt;
&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;None&lt;/strong&gt; — the table grows by one row&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;DELETE&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Old version stamped &lt;code&gt;xmax&lt;/code&gt;. Nothing else.&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;None&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;VACUUM&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Dead tuples marked reusable inside the same file&lt;/td&gt;
      &lt;td&gt;None &lt;em&gt;returned to the OS&lt;/em&gt;; space is reusable internally&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code&gt;VACUUM FULL&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Whole table rewritten into a fresh file&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;All of it&lt;/strong&gt; — under an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Bloat charges you twice
&lt;/h3&gt;

&lt;p&gt;The disk number is the visible half. The other half is worse:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scans read the corpses.&lt;/strong&gt; A sequential scan has to walk 4.8 GB of pages to find 1.2 GB of live rows. Your buffer cache fills with dead tuples. The query that used to fit in memory now does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexes still point into them.&lt;/strong&gt; Every index entry for a dead tuple is still an index entry. The index grows, gets deeper, and every lookup that lands on a dead pointer has to go to the heap to find out it was wasted work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the table did not merely get bigger. It got slower in two independent ways, and neither of them shows up as an error.&lt;/p&gt;




&lt;h2&gt;
  
  
  VACUUM: the janitor, and the part everyone misses
&lt;/h2&gt;

&lt;p&gt;The cleanup job is &lt;code&gt;VACUUM&lt;/code&gt;, usually running automatically as &lt;strong&gt;autovacuum&lt;/strong&gt;. What it does is scan for tuples that &lt;strong&gt;nobody can see any more&lt;/strong&gt; and mark that space as reusable.&lt;/p&gt;

&lt;p&gt;Run it and the dead-tuple count drops to zero. Then you check the file size, and it is still &lt;strong&gt;4.8 GB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the single most misunderstood fact about the whole system:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔴 &lt;code&gt;VACUUM&lt;/code&gt; reclaims space for &lt;strong&gt;reuse inside the same file&lt;/strong&gt;. It does not hand it back to the disk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is the right trade, once you see the alternative. Handing space back to the operating system means moving live rows down to fill the holes and truncating the file — that is &lt;code&gt;VACUUM FULL&lt;/code&gt;, and it rewrites the entire table while holding an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock. Nothing reads it. Nothing writes it. On a 40 GB table that is an outage, not a maintenance task. (&lt;code&gt;pg_repack&lt;/code&gt; exists to do the same job online, at the cost of temporarily needing room for a second copy.)&lt;/p&gt;

&lt;p&gt;For a table with steady churn, the ordinary &lt;code&gt;VACUUM&lt;/code&gt; is what you actually want anyway. That 3.6 GB of reusable space is where the &lt;em&gt;next&lt;/em&gt; 3.6 GB of updates will be written, instead of extending the file further. The table reaches a steady state at some size larger than its live set. That is not a leak. That is the working set of a versioned store.&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure that actually pages people
&lt;/h2&gt;

&lt;p&gt;Everything so far is fine. Here is the one that ruins a night.&lt;/p&gt;

&lt;p&gt;A dead tuple can only be cleaned once &lt;strong&gt;nobody can still see it&lt;/strong&gt;. That condition is global, not local. The database computes an &lt;em&gt;oldest visible transaction&lt;/em&gt; horizon across the whole system, and refuses to remove any version newer than it — because some transaction out there might still legally read it.&lt;/p&gt;

&lt;p&gt;So a &lt;strong&gt;single forgotten open transaction&lt;/strong&gt; pins every dead row in the database.&lt;/p&gt;

&lt;p&gt;An idle &lt;code&gt;BEGIN;&lt;/code&gt; in a colleague's psql window from Tuesday. A connection-pool client that opened a transaction, hit an exception, and never issued a &lt;code&gt;COMMIT&lt;/code&gt; or &lt;code&gt;ROLLBACK&lt;/code&gt;. A read replica with &lt;code&gt;hot_standby_feedback = on&lt;/code&gt; running a long analytics query, holding the primary's horizon back from the other end of the network. An abandoned replication slot.&lt;/p&gt;

&lt;p&gt;And the symptom is not an error. The symptom is &lt;strong&gt;autovacuum running all night and reclaiming nothing&lt;/strong&gt;, while the disk fills. You find out from the pager.&lt;/p&gt;

&lt;p&gt;The first thing to look at is therefore not the table. It is the horizon:&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="c1"&gt;-- who is holding the horizon back?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend_xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;txn_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_xmin&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backend_xmin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
 &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- replication slots do it too, without any session attached&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;catalog_xmin&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The oldest &lt;code&gt;backend_xmin&lt;/code&gt; — usually a session sitting in &lt;code&gt;idle in transaction&lt;/code&gt; — is your culprit far more often than any vacuum tuning parameter. Two defences worth having on by default: &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; so a forgotten &lt;code&gt;BEGIN&lt;/code&gt; cannot live forever, and an alert on the &lt;em&gt;age&lt;/em&gt; of the oldest transaction rather than only on disk usage, so you hear about it hours before the disk does.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same bill, in an AI stack
&lt;/h2&gt;

&lt;p&gt;If your database is also your vector store — and for a lot of RAG systems it is, via &lt;code&gt;pgvector&lt;/code&gt; — this stops being a Postgres trivia question and becomes a running cost, because embedding workloads are unusually good at generating dead tuples.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Re-embedding is an UPDATE storm.&lt;/strong&gt; Swap the embedding model, or re-chunk a corpus, and you rewrite the &lt;code&gt;embedding&lt;/code&gt; column on every row. Each one is insert-new-plus-stamp-old, on rows carrying a 1536-dimension vector — roughly 6 KB each before overhead. A million re-embedded chunks is gigabytes of dead tuples, generated in one batch job that looked like an in-place update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The index pays the most.&lt;/strong&gt; An HNSW or IVFFlat index is far more expensive per entry than a B-tree, and it inherits the same problem: entries for dead tuples are still entries, and a bloated vector index is both larger in RAM and slower to traverse. Recall stays correct; latency does not stay flat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-running ingestion pins everything.&lt;/strong&gt; A nightly job that opens one transaction and streams a corpus through it for two hours is, from the horizon's point of view, exactly the forgotten &lt;code&gt;BEGIN&lt;/code&gt;. Batch the commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And the reason you wanted MVCC is still true.&lt;/strong&gt; During a re-index, your retrieval traffic keeps reading the old versions without blocking. That is the feature. Just budget for the disk it costs, and monitor autovacuum on the embeddings table specifically — it is usually the one that needs a more aggressive &lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt; than the default, because the default is tuned for small rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general rule, whatever the workload: &lt;strong&gt;anything that rewrites a whole column across a whole table is a bloat event.&lt;/strong&gt; Backfills, re-embeddings, schema migrations that touch every row, GDPR deletion sweeps. Plan the vacuum, not just the write.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;The alternative to MVCC is not "no cost". It is making writers wait for readers — a lock-based system, cheaper on disk and considerably more miserable to operate, where one slow report can stall your write path. MVCC is the better trade for very nearly everyone.&lt;/p&gt;

&lt;p&gt;You just have to know what you are paying in, and it is three things: &lt;strong&gt;disk&lt;/strong&gt;, &lt;strong&gt;index bloat&lt;/strong&gt;, and &lt;strong&gt;a background job you are obliged to monitor&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So: do not fight the model. Do not reach for &lt;code&gt;VACUUM FULL&lt;/code&gt; on a schedule, do not read a table that is larger than its live set as corruption, and do not tune autovacuum before you have checked whether anything can be vacuumed at all. Delete in batches with commits between them. Alert on the age of your oldest open transaction. Expect a churny table to settle at some multiple of its live size and treat that number as normal.&lt;/p&gt;

&lt;p&gt;Your database never deletes anything at the moment you tell it to. It writes a tombstone and hires a janitor.&lt;/p&gt;

&lt;p&gt;40 GB of table holding 2 GB of live rows is not corruption. It is the invoice for concurrency.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the floor — row versions, &lt;code&gt;xmin&lt;/code&gt;/&lt;code&gt;xmax&lt;/code&gt;, and why an UPDATE is an insert&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://www.postgresql.org/docs/current/mvcc.html" rel="noopener noreferrer"&gt;PostgreSQL Documentation, Ch. 13: Concurrency Control&lt;/a&gt;&lt;/em&gt; — the primary source for the visibility rules and what each isolation level actually promises; the definition this article's snapshot section is built on.&lt;/li&gt;
&lt;li&gt;Bruce Momjian, &lt;em&gt;&lt;a href="https://momjian.us/main/writings/pgsql/mvcc.pdf" rel="noopener noreferrer"&gt;MVCC Unmasked&lt;/a&gt;&lt;/em&gt; — the clearest walkthrough of the hidden system columns and how a row version is judged visible, with the on-disk picture drawn out.&lt;/li&gt;
&lt;li&gt;Joseph M. Hellerstein, Michael Stonebraker &amp;amp; James Hamilton, &lt;em&gt;&lt;a href="https://dsf.berkeley.edu/papers/fntdb07-architecture.pdf" rel="noopener noreferrer"&gt;Architecture of a Database System&lt;/a&gt;&lt;/em&gt; (Foundations and Trends in Databases, 2007) — where the storage layer and the transaction layer meet, and why version storage is a design axis rather than an implementation detail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On snapshots and what they do and do not guarantee&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton, Elizabeth O'Neil &amp;amp; Patrick O'Neil, &lt;em&gt;&lt;a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf" rel="noopener noreferrer"&gt;A Critique of ANSI SQL Isolation Levels&lt;/a&gt;&lt;/em&gt; (SIGMOD, 1995) — the paper that defined snapshot isolation formally and showed which anomalies it still permits. The right next read if this article left you wondering what a snapshot &lt;em&gt;cannot&lt;/em&gt; protect you from.&lt;/li&gt;
&lt;li&gt;Jim Gray &amp;amp; Andreas Reuter, &lt;em&gt;Transaction Processing: Concepts and Techniques&lt;/em&gt; (Morgan Kaufmann, 1993) — the canonical treatment of the lock-based alternative, which is what makes the trade in the verdict concrete rather than rhetorical.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the bill — dead tuples, bloat, and the janitor&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://www.postgresql.org/docs/current/routine-vacuuming.html" rel="noopener noreferrer"&gt;PostgreSQL Documentation, §25.1: Routine Vacuuming&lt;/a&gt;&lt;/em&gt; — states directly that ordinary &lt;code&gt;VACUUM&lt;/code&gt; makes space available for re-use rather than returning it to the operating system, and covers the autovacuum tuning knobs.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-vacuum.html" rel="noopener noreferrer"&gt;PostgreSQL Documentation: VACUUM&lt;/a&gt;&lt;/em&gt; — the &lt;code&gt;FULL&lt;/code&gt; variant, its &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, and the disk-space requirement for the rewrite.&lt;/li&gt;
&lt;li&gt;Yingjun Wu, Joy Arulraj, Jiexi Lin, Ran Xian &amp;amp; Andrew Pavlo, &lt;em&gt;&lt;a href="https://www.vldb.org/pvldb/vol10/p781-Wu.pdf" rel="noopener noreferrer"&gt;An Empirical Evaluation of In-Memory Multi-Version Concurrency Control&lt;/a&gt;&lt;/em&gt; (VLDB, 2017) — a systematic comparison of version storage, garbage collection and index-management schemes across MVCC implementations; the best single source for why garbage collection, not the versioning itself, is where these systems live or die.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://reorg.github.io/pg_repack/" rel="noopener noreferrer"&gt;pg_repack&lt;/a&gt;&lt;/em&gt; — the online alternative to &lt;code&gt;VACUUM FULL&lt;/code&gt;, and its own trade-off: no long exclusive lock, but room for a second copy of the table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the AI/vector-store section&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector&lt;/a&gt;&lt;/em&gt; — index types, build and maintenance behaviour for HNSW and IVFFlat; the reference for why a re-embedding pass is an index event and not only a table event.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you'd expect is missing, say so in the comments and I'll add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/ZKo53qR9IqU" rel="noopener noreferrer"&gt;MVCC and VACUUM — why deleting a million rows made your table bigger&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>database</category>
    </item>
    <item>
      <title>What Is Middleware? The Onion Model, and Why the Order Is the Configuration</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 27 Aug 2026 06:40:32 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/what-is-middleware-the-onion-model-and-why-the-order-is-the-configuration-13n6</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/what-is-middleware-the-onion-model-and-why-the-order-is-the-configuration-13n6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/_Vlj8mVj6nk" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/what-is-middleware-the-onion-model-and-why-the-order-is-the-configuration?id=178" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One small service. One process, one port, six business endpoints plus a health check.&lt;/p&gt;

&lt;p&gt;Three things had to happen on almost every one of them: check the caller's token, write a log line with a request id, start and stop a timer. Nine lines. They were written once, and then pasted by hand into endpoint after endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the SAME nine lines, pasted by hand into endpoint after endpoint
&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;ep_account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;user_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;        &lt;span class="c1"&gt;# pasted
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                        &lt;span class="c1"&gt;# pasted
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# pasted
&lt;/span&gt;    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                       &lt;span class="c1"&gt;# pasted
&lt;/span&gt;    &lt;span class="n"&gt;log&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;GET /account&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                &lt;span class="c1"&gt;# pasted
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;# the actual work
&lt;/span&gt;    &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&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 /account&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# pasted
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&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;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the sixth endpoint was added. It was copied from the one next to it, and in the copy the two auth lines did not come along.&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ep_create_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                       &lt;span class="c1"&gt;# pasted
&lt;/span&gt;    &lt;span class="n"&gt;log&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;POST /reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;               &lt;span class="c1"&gt;# pasted
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;user_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&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="c1"&gt;# the actual work
&lt;/span&gt;    &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&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 /reviews&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# pasted
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&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;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the auth lines are not here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sweep the API with no &lt;code&gt;Authorization&lt;/code&gt; header at all and you get this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET     /books            401  {"error": "unauthorized"}
GET     /books/1          401  {"error": "unauthorized"}
POST    /orders           401  {"error": "unauthorized"}
GET     /orders/1         401  {"error": "unauthorized"}
GET     /account          401  {"error": "unauthorized"}
POST    /reviews          200  {"review": {"user": "anonymous", "book": 1, "stars": 5}}
GET     /healthz          200  {"ok": true}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;POST /reviews&lt;/code&gt; answered everyone. Nothing threw. No test failed. No error log line was ever written — the endpoint was working exactly as its code said. Counting the cross-cutting lines inside the endpoint functions gives &lt;strong&gt;50&lt;/strong&gt; across the module: nine on each of five endpoints, and &lt;strong&gt;five&lt;/strong&gt; on the sixth. The missing four are the entire incident.&lt;/p&gt;

&lt;p&gt;That is the problem middleware solves. Not "less typing" — we will get to the honest bill later, and it is not smaller. &lt;strong&gt;One place.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What a middleware actually is
&lt;/h2&gt;

&lt;p&gt;A middleware is a function that is handed &lt;strong&gt;two&lt;/strong&gt; things: the request, and &lt;em&gt;the next thing to call&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That second argument is the entire idea.&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;timing&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;call_next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                      &lt;span class="c1"&gt;# 1. before  — runs on the way IN
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_next&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="c1"&gt;# 2. hand it on, and WAIT here
&lt;/span&gt;    &lt;span class="n"&gt;took&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;               &lt;span class="c1"&gt;# 3. after   — runs on the way OUT
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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-took-ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;took&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at line 2. It is not "finish my job and hand over." It &lt;strong&gt;blocks&lt;/strong&gt; until everything deeper in the chain has run and come back. Which is precisely why line 3 is able to know how long the whole rest of the request took.&lt;/p&gt;

&lt;p&gt;If you delete the &lt;code&gt;call_next&lt;/code&gt; argument, you no longer have a middleware. You have a hook.&lt;/p&gt;




&lt;h2&gt;
  
  
  The chain is an onion, not a queue
&lt;/h2&gt;

&lt;p&gt;Almost everyone's first mental picture of a middleware chain is a row of turnstiles: the request passes through gate one, then gate two, then gate three, then reaches your handler. It is a tidy picture and it is wrong.&lt;/p&gt;

&lt;p&gt;The steps do not stand in a row. &lt;strong&gt;Each one wraps the next.&lt;/strong&gt; So every step runs &lt;em&gt;twice&lt;/em&gt;: once on the way in, and once on the way back out.&lt;/p&gt;

&lt;p&gt;Here is a real trace, printed by a chain of five:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--&amp;gt; ERRORS
--&amp;gt; REQUEST ID
--&amp;gt; LOG + TIMER
--&amp;gt; AUTH
--&amp;gt; RATE LIMIT
    [ YOUR HANDLER RUNS ]
&amp;lt;-- RATE LIMIT
&amp;lt;-- AUTH
&amp;lt;-- LOG + TIMER
&amp;lt;-- REQUEST ID
&amp;lt;-- ERRORS

entered 5 steps, exited 5 steps
exits are the entries REVERSED : True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the two halves. Going in, the order is the list order. Coming out, it is the list &lt;strong&gt;reversed&lt;/strong&gt;. That reversal is the whole shape, and it is not a stylistic detail — it is what makes several of the most common middlewares possible at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;timer&lt;/strong&gt; on the outside can measure the whole request, because its "after" half runs last.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;error handler&lt;/strong&gt; on the outside can catch an exception thrown by anything beneath it.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;response-header&lt;/strong&gt; step can set a header only after the response exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured on a chain of four: the outermost layer's mean was &lt;strong&gt;68.3 µs&lt;/strong&gt;, the innermost (the handler alone) &lt;strong&gt;33.9 µs&lt;/strong&gt;. The handler was &lt;strong&gt;49.7 %&lt;/strong&gt; of what the outermost layer measured, and the outer figure was greater than or equal to the inner one on &lt;strong&gt;every one of 2,000&lt;/strong&gt; requests. That is the onion, in a number.&lt;/p&gt;




&lt;h2&gt;
  
  
  A step is allowed to refuse
&lt;/h2&gt;

&lt;p&gt;The second consequence of "you are handed the next thing to call" is that you may decline to call it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;auth&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;call_next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;valid_token&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;detail&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;unauthorized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# no call_next
&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;user_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;call_next&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="c1"&gt;# only a valid caller gets past this line
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty requests with no token, through a chain of &lt;code&gt;auth → rate limit → database lookup → handler&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;AUTH reached           50
RATE LIMIT reached      0   &amp;lt;- never ran
database lookup         0   &amp;lt;- never ran
your handler reached    0   &amp;lt;- never ran
responses              50 x 401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your handler did not decide to refuse those. &lt;strong&gt;Your handler was never asked.&lt;/strong&gt; A browser CORS preflight that never reaches your code is the same move, and so is a WAF rule, and so is an API gateway's quota check.&lt;/p&gt;

&lt;p&gt;This is also the first hint of a real operational problem. If a request is rejected two layers above your handler, your handler's own metrics never see it. In one run, &lt;strong&gt;160&lt;/strong&gt; requests arrived, the handler counted &lt;strong&gt;50&lt;/strong&gt;, and the gap of &lt;strong&gt;110&lt;/strong&gt; reconciled exactly to 80 rejections plus 30 throttles. That is &lt;strong&gt;68.8 %&lt;/strong&gt; of all traffic invisible to the dashboard most teams actually look at.&lt;/p&gt;




&lt;h2&gt;
  
  
  The order is the configuration
&lt;/h2&gt;

&lt;p&gt;Here is the part that makes middleware a design decision rather than a convenience.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the whole ordering decision, in one list, outermost first.
&lt;/span&gt;&lt;span class="n"&gt;CHAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# A: auth, then limit
&lt;/span&gt;&lt;span class="n"&gt;CHAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# B: limit, then auth
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same two components. Same code inside them. One line moved. Now send the identical 90-request burst at both — 30 from one authenticated user, 30 from thirty different authenticated users, 30 with no token, limit 10 per key:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;On the same 90 requests&lt;/th&gt;
      &lt;th&gt;ORDER A&lt;br&gt;&lt;small&gt;auth → limit&lt;/small&gt;
&lt;/th&gt;
      &lt;th&gt;ORDER B&lt;br&gt;&lt;small&gt;limit → auth&lt;/small&gt;
&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;reached the auth check&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;reached the rate limiter&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;200 OK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;401 unauthorized&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;429 too many requests&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;rate-limiter keys used&lt;/td&gt;
&lt;td&gt;31 &lt;small&gt;(per user)&lt;/small&gt;
&lt;/td&gt;
&lt;td&gt;1 &lt;small&gt;(per IP)&lt;/small&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;the one heavy user, throttled&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;thirty different users, throttled&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;40 successful responses versus 7.&lt;/strong&gt; That is not a mild trade-off, it is two different products.&lt;/p&gt;

&lt;p&gt;And neither order is &lt;em&gt;wrong&lt;/em&gt;. Order A is a &lt;strong&gt;per-user quota&lt;/strong&gt;; order B is a &lt;strong&gt;per-source flood gate&lt;/strong&gt;. The limiter can only key by user if something above it has already identified one — placed first it has no user yet, so it falls back to keying by client address, and behind a shared address that means thirty innocent users share one bucket. That is not a bug. It is the consequence of the position.&lt;/p&gt;

&lt;p&gt;The same principle bites a request-id step. Placed &lt;strong&gt;first&lt;/strong&gt;, all four downstream log lines carry the id. Placed &lt;strong&gt;last&lt;/strong&gt;, only &lt;strong&gt;1 of 4&lt;/strong&gt; does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request-id FIRST                      request-id LAST
[log:edge]    rid=ffc68931            [log:edge]    rid=-
[log:access]  rid=ffc68931            [log:access]  rid=-
[log:audit]   rid=ffc68931            [log:audit]   rid=-
[log:app]     rid=ffc68931            [log:app]     rid=fe9f57b1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that it is 3 of 4 lost, not all four — the step still tags whatever runs beneath it. And the error handler is the sharpest case of all. Outermost, with a failing layer below it: &lt;strong&gt;HTTP 500&lt;/strong&gt;, body &lt;code&gt;{"error": "internal server error"}&lt;/code&gt;, caught. Innermost, with the failing layer above it: &lt;strong&gt;no HTTP response at all&lt;/strong&gt; — the exception escapes the application entirely.&lt;/p&gt;

&lt;p&gt;The worst property of an ordering mistake is that it is &lt;strong&gt;silent&lt;/strong&gt;. On that 90-request burst under the wrong order: exceptions escaped &lt;strong&gt;0&lt;/strong&gt;, exceptions caught &lt;strong&gt;0&lt;/strong&gt;, ERROR log lines &lt;strong&gt;0&lt;/strong&gt;, 5xx responses &lt;strong&gt;0&lt;/strong&gt;, log lines written &lt;strong&gt;90&lt;/strong&gt; — all INFO, every status code documented and expected. The only visible symptom, and only if you already knew your intent, was that &lt;strong&gt;27&lt;/strong&gt; requests from users who had sent &lt;em&gt;one request each&lt;/em&gt; came back &lt;code&gt;429&lt;/code&gt;.&lt;/p&gt;




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

&lt;p&gt;Middleware is usually sold as a cleanup. Measured, it is a trade, and it is worth being precise about what you are trading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not mean fewer lines.&lt;/strong&gt; The endpoint module shrank from &lt;strong&gt;92 SLOC to 52&lt;/strong&gt;. But the steps themselves are another &lt;strong&gt;137 SLOC&lt;/strong&gt;, plus an 8-line list to order them. At six endpoints, the repository is &lt;em&gt;bigger&lt;/em&gt;. What you bought is not brevity — it is that the auth rule exists in &lt;strong&gt;one place&lt;/strong&gt;, so the sixth endpoint cannot forget it. On the chain variant, the same unauthenticated sweep returns 401 on all six business endpoints, &lt;strong&gt;including the newest one, which nobody had to remember&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs something on every request.&lt;/strong&gt; A chain of eight layers against a bare handler: bare &lt;strong&gt;4.3 µs&lt;/strong&gt;, full chain &lt;strong&gt;30.7 µs&lt;/strong&gt; — an overhead of &lt;strong&gt;26.4 µs/request&lt;/strong&gt;, about &lt;strong&gt;7.1×&lt;/strong&gt;, roughly &lt;strong&gt;3.3 µs per layer&lt;/strong&gt;. Quoted alone, that number sounds alarming. So here is the other one: measured end to end over a real socket, a request took &lt;strong&gt;640.1 µs&lt;/strong&gt;, and the chain was &lt;strong&gt;4.1 %&lt;/strong&gt; of it. Quote either figure by itself and you are misleading someone. The chain is expensive relative to a function call and cheap relative to a request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every request pays, including the ones that did not need to.&lt;/strong&gt; One layer doing a small database read added &lt;strong&gt;+13.8 µs/request&lt;/strong&gt; to &lt;code&gt;/healthz&lt;/code&gt; — &lt;strong&gt;2.02×&lt;/strong&gt; the same chain without it — and performed &lt;strong&gt;5,300 of 5,300&lt;/strong&gt; database reads for an endpoint that needed exactly none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is invisible from the handler, and the blast radius is the whole API.&lt;/strong&gt; Changing &lt;code&gt;if scheme != "Bearer":&lt;/code&gt; to &lt;code&gt;if scheme == "Bearer":&lt;/code&gt; is a &lt;strong&gt;one-character&lt;/strong&gt; diff in a shared layer. Result: &lt;strong&gt;6 of 6&lt;/strong&gt; business endpoints reject a valid credential, &lt;strong&gt;1&lt;/strong&gt; file changed, &lt;strong&gt;0&lt;/strong&gt; endpoint files changed, and &lt;code&gt;handlers.py&lt;/code&gt; byte-identical before and after. Nothing in the handler you are staring at explains the failure.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same shape, in front of a model
&lt;/h2&gt;

&lt;p&gt;If you work on LLM serving, you have this chain whether you called it middleware or not — it is what an inference gateway &lt;em&gt;is&lt;/em&gt;. And the vocabulary maps cleanly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt; becomes API-key resolution to a tenant and a model allowlist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; becomes a &lt;strong&gt;token&lt;/strong&gt; budget rather than a request budget, and the ordering lesson lands twice as hard: a limiter placed before authentication cannot key by tenant, so one noisy customer and thirty quiet ones share a bucket — the exact 27-out-of-30 failure above, except now it is a paying customer's SLA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request id&lt;/strong&gt; becomes the trace id that has to survive a response lasting several seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails&lt;/strong&gt; — prompt-injection screening on the way in, PII or policy filtering on the way out — are the onion's two halves, and the reason they belong in a wrapper rather than in the handler is the same reason auth did: the next endpoint you add must not be able to skip them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost accounting&lt;/strong&gt; is a pure "way out" step: you cannot bill for tokens you have not generated yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But one thing genuinely does &lt;em&gt;not&lt;/em&gt; transfer, and it catches people. The onion's "on the way back out" half assumes the response is a single object handed back up the stack. When you stream tokens over SSE, the response object returns almost immediately and the body arrives afterwards. A timing middleware written the ordinary way will therefore record &lt;strong&gt;time to first token&lt;/strong&gt;, not total generation time — a number that can be twenty times smaller and looks perfectly healthy on a dashboard. The same applies to an output filter: by the time your "after" half runs, the first tokens are already on the client's screen. Streaming-aware guardrails have to wrap the &lt;em&gt;iterator&lt;/em&gt;, not the response.&lt;/p&gt;

&lt;p&gt;The shape holds. The assumption that a request is one atomic unit of work does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Copy-paste versus chain, side by side
&lt;/h2&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;Pasted into every endpoint&lt;/th&gt;
      &lt;th&gt;One chain, registered once&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;cross-cutting lines inside endpoints&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;total code&lt;/td&gt;
&lt;td&gt;92 SLOC&lt;/td&gt;
&lt;td&gt;52 + 137 SLOC (bigger)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;the newest endpoint&lt;/td&gt;
&lt;td&gt;can silently omit auth&lt;/td&gt;
&lt;td&gt;covered without being asked&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;changing the auth rule&lt;/td&gt;
&lt;td&gt;edit 6 files, hope you got them all&lt;/td&gt;
&lt;td&gt;edit 1 file&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;blast radius of a typo&lt;/td&gt;
&lt;td&gt;one endpoint&lt;/td&gt;
&lt;td&gt;every endpoint&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;where the behaviour is written&lt;/td&gt;
&lt;td&gt;in front of you, in the handler&lt;/td&gt;
&lt;td&gt;somewhere else, in a list&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;cost&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;td&gt;~3.3 µs per layer, on every request&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;ordering bugs&lt;/td&gt;
&lt;td&gt;impossible&lt;/td&gt;
&lt;td&gt;possible, and silent&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;Use middleware for the things that are genuinely true of &lt;strong&gt;almost every&lt;/strong&gt; request — authentication, request ids, error handling, logging, rate limiting, CORS. Those earn the wrapper, because their failure mode is &lt;em&gt;omission&lt;/em&gt;, and a chain makes omission impossible.&lt;/p&gt;

&lt;p&gt;Do not use it for things that are true of &lt;em&gt;some&lt;/em&gt; requests. A layer that reads the database for every call so that three endpoints can avoid a lookup is a tax collected 5,300 times to be spent 3 times; that belongs in a dependency the three endpoints ask for.&lt;/p&gt;

&lt;p&gt;And then treat the order as what it is. It is not registration boilerplate at the bottom of a file — it is a configuration file for your API's behaviour, written in the least obvious syntax imaginable, and it fails without raising anything. Put the list somewhere a reviewer will look at it, write down &lt;em&gt;why&lt;/em&gt; each step sits where it does, and test the order the way you would test a feature: send a burst, count the status codes, and check you got the product you meant to build.&lt;/p&gt;

&lt;p&gt;Your handler is not the first code that runs. Know what is in front of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the problem — cross-cutting concerns and the pasted nine lines&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gregor Kiczales et al., &lt;em&gt;&lt;a href="https://www.cs.ubc.ca/~gregor/papers/kiczales-ECOOP1997-AOP.pdf" rel="noopener noreferrer"&gt;Aspect-Oriented Programming&lt;/a&gt;&lt;/em&gt; (ECOOP, 1997) — the paper that named cross-cutting concerns and the scattering/tangling problem; the missing auth block in &lt;code&gt;POST /reviews&lt;/code&gt; is a textbook instance of scattering.&lt;/li&gt;
&lt;li&gt;Deepak Alur, John Crupi &amp;amp; Dan Malks, &lt;em&gt;Core J2EE Patterns&lt;/em&gt;, 2nd ed. (Prentice Hall, 2003) — the &lt;strong&gt;Intercepting Filter&lt;/strong&gt; pattern: a configurable, ordered chain of filters around a request handler. This is the pattern middleware implements, described before the word "middleware" was common in web frameworks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the shape — why each step wraps the next&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://peps.python.org/pep-3333/" rel="noopener noreferrer"&gt;PEP 3333 — Python Web Server Gateway Interface v1.0.1&lt;/a&gt;&lt;/em&gt; — the primary source for the wrapping definition: middleware is itself an application that plays server to the application below it. The onion is written into the spec, not into a framework.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://asgi.readthedocs.io/en/latest/specs/main.html" rel="noopener noreferrer"&gt;The ASGI Specification&lt;/a&gt;&lt;/em&gt; — the async successor, and the place to read how the model changes when a response is a stream of events rather than one return value.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;&lt;a href="https://expressjs.com/en/guide/writing-middleware.html" rel="noopener noreferrer"&gt;Writing middleware for use in Express apps&lt;/a&gt;&lt;/em&gt; (Express documentation) — the shortest statement of the &lt;code&gt;next()&lt;/code&gt; contract, including what happens when you decline to call it.&lt;/li&gt;
&lt;li&gt;Roy T. Fielding, &lt;em&gt;&lt;a href="https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm" rel="noopener noreferrer"&gt;Architectural Styles and the Design of Network-based Software Architectures&lt;/a&gt;&lt;/em&gt;, ch. 5 (dissertation, 2000) — the &lt;strong&gt;layered system&lt;/strong&gt; constraint: why intermediaries a client cannot see are a deliberate property of the web, not an accident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On ordering, refusal, and what it costs you operationally&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Michael T. Nygard, &lt;em&gt;Release It!&lt;/em&gt;, 2nd ed. (Pragmatic Bookshelf, 2018) — the case for guards that run before your code at all (bulkheads, circuit breakers, handshaking), and why a component that sheds load must sit where it can actually see the load.&lt;/li&gt;
&lt;li&gt;Charity Majors, Liz Fong-Jones &amp;amp; George Miranda, &lt;em&gt;Observability Engineering&lt;/em&gt; (O'Reilly, 2022) — instrumenting at the edge versus inside the handler; the direct answer to the 68.8 % of traffic the handler's own counters could not see.&lt;/li&gt;
&lt;li&gt;Jeffrey Dean &amp;amp; Luiz André Barroso, &lt;em&gt;&lt;a href="https://research.google/pubs/the-tail-at-scale/" rel="noopener noreferrer"&gt;The Tail at Scale&lt;/a&gt;&lt;/em&gt; (Communications of the ACM, 56(2), 2013) — why a small fixed per-request cost is usually the wrong thing to worry about, and variance is the right one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the model-serving section&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Woosuk Kwon et al., &lt;em&gt;&lt;a href="https://arxiv.org/abs/2309.06180" rel="noopener noreferrer"&gt;Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a&gt;&lt;/em&gt; (SOSP, 2023) — the vLLM paper; useful here for why a request to a model server is not one atomic unit of work, which is exactly the assumption a "way back out" middleware makes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you'd expect is missing, say so in the comments and I'll add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/AJ7XToKwSk0" rel="noopener noreferrer"&gt;Middleware — the code that runs before your handler&lt;/a&gt; · or the &lt;a href="https://youtu.be/_Vlj8mVj6nk" rel="noopener noreferrer"&gt;full episode&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>middleware</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Design Search Autocomplete: The Four-Point Answer, and the Trap</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 25 Aug 2026 16:35:04 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/design-search-autocomplete-the-four-point-answer-and-the-trap-3dn5</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/design-search-autocomplete-the-four-point-answer-and-the-trap-3dn5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtube.com/shorts/6Dt2_rekodU" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/design-search-autocomplete-the-four-point-answer-and-the-trap?id=177" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Design search autocomplete" — the suggestion dropdown under a search box — is one of the most&lt;br&gt;
common system-design interview questions, and one of the easiest to answer badly. The obvious&lt;br&gt;
answer is a search: the user types, you go and find the matching queries, you sort them, you&lt;br&gt;
return the best ten.&lt;/p&gt;

&lt;p&gt;That is the failure. It describes a &lt;strong&gt;search&lt;/strong&gt;, and the answer is a &lt;strong&gt;read&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Everything below follows from one observation: the request fires on &lt;strong&gt;every keystroke&lt;/strong&gt;, it has a&lt;br&gt;
budget of roughly &lt;strong&gt;100 milliseconds&lt;/strong&gt; end to end, and reads outnumber writes by an enormous&lt;br&gt;
margin. A design that does real work per keystroke cannot survive that ratio. So you do the work&lt;br&gt;
in advance, once, for everybody.&lt;/p&gt;


&lt;h2&gt;
  
  
  1. A trie — a prefix tree
&lt;/h2&gt;

&lt;p&gt;Start with the structure, because it is the thing that makes "what the user has typed so far" a&lt;br&gt;
first-class object rather than a string you have to search for.&lt;/p&gt;

&lt;p&gt;A trie stores a set of strings as a tree in which &lt;strong&gt;every prefix is a node on the path from the&lt;br&gt;
root&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        (root)
          │
          d
          │
         da
          │
        dat
          │
       data
        /    \
  database   data structures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two consequences worth saying out loud in the interview:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A keystroke is one step, not one query.&lt;/strong&gt; The client already knows which node it was on for
&lt;code&gt;dat&lt;/code&gt;; typing &lt;code&gt;a&lt;/code&gt; moves to a child. Lookup cost is a function of the &lt;em&gt;length of the prefix&lt;/em&gt;, not
of how many queries you have indexed. Ten queries or ten billion, &lt;code&gt;data&lt;/code&gt; is four hops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prefix is a position.&lt;/strong&gt; "What the user has typed" stops being a search key and becomes a
pointer into a data structure. That reframing is the whole answer in miniature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In production this is rarely a naïve pointer-per-character trie — it is usually compacted (a radix&lt;br&gt;
tree) or compiled into a finite-state transducer so that the whole thing fits in memory and shares&lt;br&gt;
suffixes. Worth naming, not worth dwelling on. The shape is what matters.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Precompute the top K completions &lt;strong&gt;at&lt;/strong&gt; each node
&lt;/h2&gt;

&lt;p&gt;Here is the trick the entire design turns on, and the point where most answers quietly go wrong.&lt;/p&gt;

&lt;p&gt;The naïve version is: walk to the node for &lt;code&gt;data&lt;/code&gt;, then &lt;strong&gt;traverse the subtree below it&lt;/strong&gt; to&lt;br&gt;
collect every completion, score them, and take the best ten. That is a graph traversal on the hot&lt;br&gt;
path, executed once per keystroke — and it is slowest exactly where the traffic is heaviest,&lt;br&gt;
because short popular prefixes (&lt;code&gt;d&lt;/code&gt;, &lt;code&gt;da&lt;/code&gt;) have enormous subtrees.&lt;/p&gt;

&lt;p&gt;Instead, at build time, you store the finished list &lt;strong&gt;on the node itself&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node "data"   →   top 10, precomputed
                    data structures
                    database
                    data science
                    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the query path is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;walk &lt;code&gt;d → da → dat → data&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;read the list stored there&lt;/li&gt;
&lt;li&gt;return it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No traversal, no sort, no scoring, no ranking work of any kind. You &lt;strong&gt;land on the node and read&lt;br&gt;
the answer that is already sitting there&lt;/strong&gt;. That is a lookup, not a search — and it is the sentence&lt;br&gt;
you want the interviewer to hear.&lt;/p&gt;

&lt;p&gt;The cost you are trading against is space: every node carries a small list, so the index gets&lt;br&gt;
bigger. That is a good trade, and it is worth volunteering. You are buying a bounded, predictable&lt;br&gt;
read for a one-off increase in the size of an artifact you rebuild offline anyway.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Cache the hot prefixes at the edge
&lt;/h2&gt;

&lt;p&gt;Short section, deliberately.&lt;/p&gt;

&lt;p&gt;Prefix traffic is wildly skewed. A small number of prefixes — the first one or two characters of&lt;br&gt;
whatever the world is currently searching for — account for most of the requests, while the long&lt;br&gt;
tail is enormous and almost never hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ▉
 ▉  ▊
 ▉  ▊  ▍
 ▉  ▊  ▍  ▁  ▁
 └── prefixes, by traffic ──►
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So put the answers for the hot prefixes in a cache close to the user. A few thousand entries&lt;br&gt;
absorb the bulk of the load and never reach your service at all. The response for a given prefix&lt;br&gt;
is a small, stable blob, which is exactly the shape a cache likes. (I have written separately&lt;br&gt;
about what a CDN actually is and how it differs from an application cache; here that is the only&lt;br&gt;
property that matters.)&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Rank from real query logs, rebuilt offline in batch
&lt;/h2&gt;

&lt;p&gt;This is the point most answers miss, and the one that most clearly separates "I read a blog post"&lt;br&gt;
from "I have thought about this".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ranking is not alphabetical.&lt;/strong&gt; Nobody wants the alphabetically-first completion; they want&lt;br&gt;
the one people actually search for. So the score on each completion comes from &lt;strong&gt;real query&lt;br&gt;
logs&lt;/strong&gt; — how often that full query was issued, and often whether the suggestion was clicked when&lt;br&gt;
it was shown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the ranking is not live.&lt;/strong&gt; The pipeline is a batch job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yesterday's query logs
        │
        ▼
  batch job: count + rank
        │
        ▼
  a fresh trie, shipped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aggregate yesterday's query frequencies, recompute the top-K list for every node, build a whole&lt;br&gt;
new trie, and ship it as an &lt;strong&gt;immutable artifact&lt;/strong&gt; that the serving tier loads and swaps in.&lt;/p&gt;

&lt;p&gt;The consequence is the important part: &lt;strong&gt;the serving path only reads.&lt;/strong&gt; It never writes a query&lt;br&gt;
back into the index it is currently serving from. The moment it does, you have put a write on the&lt;br&gt;
hot path — locks, invalidation, tail latency — and given away the property you designed the whole&lt;br&gt;
system to have.&lt;/p&gt;

&lt;p&gt;Two follow-ups worth having ready, because a good interviewer will ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"What about a query that trends right now?"&lt;/strong&gt; You run a second, much smaller, fast path — a
near-real-time layer over the last few minutes of logs — and merge its handful of results with
the batch answer at read time. It is a deliberate exception to the batch rule, and it is small
precisely because the exception is expensive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"How do you personalise it?"&lt;/strong&gt; Same shape: the batch trie is the global answer, and a small
per-user signal re-orders the top few. You do not build a trie per user.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two designs, side by side
&lt;/h2&gt;

&lt;p&gt;The difference is not an optimisation. It is a different system with a different failure mode:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&amp;nbsp;&lt;/th&gt;
      &lt;th&gt;Search at query time (the wrong answer)&lt;/th&gt;
      &lt;th&gt;Read a precomputed answer (the right one)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;What a keystroke does&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Matches the prefix, collects candidates, scores and sorts them&lt;/td&gt;
      &lt;td&gt;Walks a few nodes and reads a stored list&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Work per request&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Grows with the size of the subtree under the prefix&lt;/td&gt;
      &lt;td&gt;Proportional to the length of the prefix — bounded, and tiny&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Worst case&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Short, popular prefixes: biggest subtree, highest traffic, same request&lt;/td&gt;
      &lt;td&gt;Short prefixes are the &lt;em&gt;cheapest&lt;/em&gt; and the most cacheable&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Where the ranking happens&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;On the hot path, per request, per user&lt;/td&gt;
      &lt;td&gt;Once, offline, in a batch job over query logs&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;What the serving tier does&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Reads and writes the live index&lt;/td&gt;
      &lt;td&gt;Reads only; new data arrives as a swapped-in artifact&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cost you pay&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;CPU and tail latency, on every keystroke, forever&lt;/td&gt;
      &lt;td&gt;Storage, plus staleness measured in hours&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Fails by&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Falling over exactly when it is popular&lt;/td&gt;
      &lt;td&gt;Being slightly out of date&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The right-hand column trades a property nobody notices (a few hours of staleness in a suggestion&lt;br&gt;
list) for a property everybody notices (a dropdown that appears instantly). That is the trade the&lt;br&gt;
interviewer is checking you can make.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same trick, in LLM serving
&lt;/h2&gt;

&lt;p&gt;If you work on model serving rather than search, you have met this design already — it is wearing&lt;br&gt;
different vocabulary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prefix is still a position.&lt;/strong&gt; When you send a prompt to an LLM, the expensive part of the&lt;br&gt;
first token is processing the prompt itself. But most production traffic shares prefixes: the same&lt;br&gt;
system prompt, the same few-shot examples, the same conversation replayed one turn longer. So&lt;br&gt;
serving engines keep the computed attention state (the KV cache) keyed by &lt;strong&gt;token prefix&lt;/strong&gt;, and a&lt;br&gt;
request that shares a prefix with something already computed starts from where that left off&lt;br&gt;
instead of from scratch. SGLang's RadixAttention does this with a literal &lt;strong&gt;radix tree over token&lt;br&gt;
prefixes&lt;/strong&gt; — the same structure as section 1, holding cached computation instead of cached&lt;br&gt;
completions. "Prompt caching" in the commercial APIs is the same idea behind a billing line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the ranking is still offline.&lt;/strong&gt; In a RAG system, nothing about the corpus is understood at&lt;br&gt;
request time. The chunking, the embeddings, the index — all built in a batch job, ahead of time,&lt;br&gt;
and shipped. The request embeds one short query and does a lookup. If your retrieval path is&lt;br&gt;
embedding documents while the user waits, you have made the autocomplete mistake in a new costume.&lt;/p&gt;

&lt;p&gt;The mapping is close enough to be worth carrying between the two domains:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Autocomplete&lt;/th&gt;
      &lt;th&gt;LLM serving&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Trie node per prefix&lt;/td&gt;
      &lt;td&gt;Radix-tree node per token prefix (RadixAttention, prompt caching)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Top-K list precomputed on the node&lt;/td&gt;
      &lt;td&gt;KV cache: the attention state for that prefix, already computed&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Hot prefixes cached at the edge&lt;/td&gt;
      &lt;td&gt;Hot system prompts pinned in cache; a shared prefix is a cache hit&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Trie rebuilt nightly from query logs&lt;/td&gt;
      &lt;td&gt;Vector index rebuilt from the corpus, offline, and swapped in&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Serving path never writes&lt;/td&gt;
      &lt;td&gt;Inference never rebuilds the index it is retrieving from&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same sentence in both worlds: &lt;strong&gt;do the expensive thing once, in advance, and make the request a&lt;br&gt;
read.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: it is a read, not a search
&lt;/h2&gt;

&lt;p&gt;Here is the closing line, and it belongs at the &lt;em&gt;start&lt;/em&gt; of your answer, not the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are running a search when the user types, you have already lost.&lt;/strong&gt; Autocomplete answers&lt;br&gt;
from something that was built hours ago. The trie was built offline. The top-K lists were computed&lt;br&gt;
offline. The ranking came from yesterday's logs. All the request does on the hot path is walk a&lt;br&gt;
few nodes and read a list.&lt;/p&gt;

&lt;p&gt;Say the word &lt;strong&gt;precomputed&lt;/strong&gt; in the first minute, and every other decision — why the top-K lives&lt;br&gt;
on the node, why the ranking is a batch job, why the serving tier never writes — follows from it&lt;br&gt;
without you having to argue for any of them separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;The whole answer in four lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;a trie&lt;/strong&gt; → one node per prefix; a keystroke is one step down, not one query&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;top K&lt;/strong&gt; → precomputed and stored &lt;em&gt;on&lt;/em&gt; the node, so the read is a lookup and not a traversal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the edge&lt;/strong&gt; → a few thousand hot prefixes absorb most of the traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the logs&lt;/strong&gt; → yesterday's real queries decide the ranking, rebuilt offline; serving never writes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the one sentence underneath all four: the request does not compute the answer, it &lt;strong&gt;fetches&lt;/strong&gt;&lt;br&gt;
one that already exists. Every autocomplete design that is fast is fast for that reason, and every&lt;br&gt;
one that is slow is slow because something on the hot path is still thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The structure (§1)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edward Fredkin, &lt;em&gt;Trie Memory&lt;/em&gt; (Communications of the ACM 3(9), pp. 490–499, 1960) — the original paper introducing the trie, and the source of the property §1 rests on: the key is the path, so every prefix is a node and a lookup costs the length of the key. Covers the data structure only, nothing about ranking or serving.&lt;/li&gt;
&lt;li&gt;Robert Sedgewick &amp;amp; Kevin Wayne, &lt;em&gt;Algorithms&lt;/em&gt;, 4th ed. (Addison-Wesley, 2011) — §5.2 on R-way tries and ternary search tries, including &lt;code&gt;keysWithPrefix&lt;/code&gt;. This is the textbook version of the "collect the completions by traversing the subtree" operation that §2 deliberately replaces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Precomputing the completions (§2)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Elastic, &lt;a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters.html" rel="noopener noreferrer"&gt;Elasticsearch Reference — Suggesters (completion suggester)&lt;/a&gt; — a production system with exactly this shape: a dedicated in-memory FST built at index time, optimised for prefix lookups, rather than a query against the inverted index at request time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Ranking from query logs (§4)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ziv Bar-Yossef &amp;amp; Naama Kraus, &lt;em&gt;Context-Sensitive Query Auto-Completion&lt;/em&gt; (WWW '11, pp. 107–116, 2011) — defines and evaluates MostPopularCompletion, ranking completions by their frequency in a query log rather than alphabetically. The direct support for §4's claim about where the ranking comes from; it does not address the batch/serving split.&lt;/li&gt;
&lt;li&gt;Fei Cai &amp;amp; Maarten de Rijke, &lt;em&gt;A Survey of Query Auto Completion in Information Retrieval&lt;/em&gt; (Foundations and Trends in Information Retrieval 10(4), pp. 273–363, 2016) — the broad survey: candidate generation, ranking signals (popularity, time-sensitivity, personalisation), and evaluation. The best single source if you want to go past the four points, and the place to read up on the trending-query follow-up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The LLM-serving parallel&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lianmin Zheng et al., &lt;a href="https://arxiv.org/abs/2312.07104" rel="noopener noreferrer"&gt;&lt;em&gt;SGLang: Efficient Execution of Structured Language Model Programs&lt;/em&gt;&lt;/a&gt; (2024) — RadixAttention keeps the KV cache in a radix tree keyed on token prefixes so that requests sharing a prefix reuse the computation. The same structure as §1, holding cached work instead of cached completions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect to see here is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;▶ &lt;strong&gt;Watch the reel:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/6Dt2_rekodU" rel="noopener noreferrer"&gt;Design search autocomplete — the four-point answer, and the trap&lt;/a&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>interview</category>
    </item>
    <item>
      <title>The Ring Buffer: Why a Queue With No Limit Is a Memory Leak (and What Happens When It Fills)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 25 Aug 2026 07:26:35 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/the-ring-buffer-why-a-queue-with-no-limit-is-a-memory-leak-and-what-happens-when-it-fills-3cc4</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/the-ring-buffer-why-a-queue-with-no-limit-is-a-memory-leak-and-what-happens-when-it-fills-3cc4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/-pUNFU3WFf0" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/the-ring-buffer-why-a-queue-with-no-limit-is-a-memory-leak-and-what-happens-when-it-fills?id=176" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is a system small enough to hold in your head. &lt;strong&gt;One Python process.&lt;/strong&gt; No broker, no network, no database. A request handler measures how long each request took and writes the sample into a queue. A background thread reads samples back out and appends them to a file on disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# one CPython process. no broker, no network, no database.
# the request handler writes a sample; a background thread drains it to disk.
&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deque&lt;/span&gt;

&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deque&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                                     &lt;span class="c1"&gt;# no maxlen
&lt;/span&gt;&lt;span class="n"&gt;sample&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;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;/checkout&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;ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;41.2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c1"&gt;# one sample, made by the request
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works perfectly, right up until the writer is faster than the reader. And then it does something worse than break: it keeps working.&lt;/p&gt;

&lt;p&gt;The queue never complains. It never returns an error, never logs a warning, never blocks. &lt;strong&gt;It just gets longer.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The failure that never looks like a failure
&lt;/h2&gt;

&lt;p&gt;I ran it. A producer pushing 4× faster than the consumer drains, three million samples produced, 750,000 consumed, 2.25 million left as backlog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== collections.deque()  (NO maxlen) ===
  start RSS                :       9.9 MB
  after   250,000 events : RSS     60.6 MB   grew     50.7 MB     202.8 MB/M events
  after 1,000,000 events : RSS    212.4 MB   grew    202.5 MB     202.5 MB/M events
  after 2,000,000 events : RSS    414.8 MB   grew    404.9 MB     202.5 MB/M events
  after 3,000,000 events : RSS    617.2 MB   grew    607.3 MB     202.4 MB/M events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;9.9 MB to 617.4 MB.&lt;/strong&gt; And look at the last column — 202.8 at the first checkpoint, 202.4 at the twelfth. The growth is not "roughly linear", it is linear to within a rounding error: &lt;strong&gt;202.5 MB per million backlogged events&lt;/strong&gt;, forever.&lt;/p&gt;

&lt;p&gt;The accounting closes exactly, which is what makes it so mundane: 2.25 million dicts at 232 bytes each is 522 MB of dict objects, and the measured growth is 607 MB — the extra ~85 MB is the deque's own block storage plus the float objects inside each sample. Nothing is leaking in the C sense. Every byte is reachable, correct, and doing its job. There is simply no line of code anywhere that says &lt;em&gt;stop&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is why it is so hard to catch. A classic memory leak has a smoking gun — an object nobody freed. Here, every object has a legitimate owner. The queue is &lt;em&gt;supposed&lt;/em&gt; to hold things. It is holding things. The bug is that nobody ever said how many.&lt;/p&gt;

&lt;p&gt;Swap in &lt;code&gt;queue.Queue()&lt;/code&gt; with no &lt;code&gt;maxsize&lt;/code&gt; and you get the same curve — 617.4 MB peak, 196.4 MB per million — plus a lock on every operation that makes it &lt;strong&gt;4.3× slower&lt;/strong&gt; (4.60 s vs 1.08 s for the same work).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;There is no bound in the code. The only bound is the OOM killer&lt;/strong&gt; — and it doesn't run at a convenient time, it doesn't pick the process you'd have picked, and it leaves you a &lt;code&gt;Killed&lt;/code&gt; in dmesg and nothing else.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The fix is a decision, not a data structure
&lt;/h2&gt;

&lt;p&gt;A ring buffer is a fixed-length array plus two integers. That's genuinely all it is:&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;CAPACITY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;                     &lt;span class="c1"&gt;# decided once, at startup, and never again
&lt;/span&gt;
&lt;span class="n"&gt;buf&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;CAPACITY&lt;/span&gt;         &lt;span class="c1"&gt;# the entire memory footprint is this line
&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;                         &lt;span class="c1"&gt;# how many samples have ever been written
&lt;/span&gt;&lt;span class="n"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;                         &lt;span class="c1"&gt;# how many samples have ever been read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is that &lt;code&gt;head&lt;/code&gt; and &lt;code&gt;tail&lt;/code&gt; &lt;strong&gt;never wrap&lt;/strong&gt;. They count forever. Only the &lt;em&gt;index&lt;/em&gt; wraps, at the moment you touch the array:&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;Ring&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;__init__&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;n&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;buf&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;     &lt;span class="c1"&gt;# allocated once, never again
&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;n&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&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;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;              &lt;span class="c1"&gt;# total writes, ever
&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;tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;              &lt;span class="c1"&gt;# total reads, ever
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;push&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;x&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;buf&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;head&lt;/span&gt; &lt;span class="o"&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;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&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;head&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pop&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;x&lt;/span&gt; &lt;span class="o"&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;buf&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;tail&lt;/span&gt; &lt;span class="o"&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;n&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;tail&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the counters are monotonic, the three questions you actually want to ask become plain arithmetic, with no special cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;how many are in there?&lt;/strong&gt; &lt;code&gt;head - tail&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;is it empty?&lt;/strong&gt; &lt;code&gt;head == tail&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;is it full?&lt;/strong&gt; &lt;code&gt;head - tail == n&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the same run against a 1,024-slot ring — identical producer, identical three million events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  start RSS                  :     10.4 MB
  sys.getsizeof(buf) BEFORE  : 8,248 bytes
  after 1,000,000 events   : RSS     11.0 MB   grew   +0.64 MB   getsizeof(buf) 8,248
  after 3,000,000 events   : RSS     11.0 MB   grew   +0.64 MB   getsizeof(buf) 8,248
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;strong&gt;three million writes&lt;/strong&gt; the container is &lt;strong&gt;byte-for-byte the same size&lt;/strong&gt; it was before the first one. RSS growth: &lt;strong&gt;+0.64 MB against +607.4 MB — 949× less.&lt;/strong&gt; Measured with &lt;code&gt;tracemalloc&lt;/code&gt; on a smaller run, peak traced memory is 79.28 MB unbounded versus 0.27 MB for the ring, a ratio of &lt;strong&gt;293:1&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One honest correction, because it's the kind of thing that gets repeated wrongly: &lt;strong&gt;in Python, "nothing is allocated after startup" is true of the buffer and false of the events.&lt;/strong&gt; Half a million dicts really were created in that loop — I counted them. What the ring changes is that at most &lt;code&gt;n&lt;/code&gt; of them are ever &lt;em&gt;reachable&lt;/em&gt;, so CPython recycles the same freed memory instead of asking the OS for more. Memory goes &lt;strong&gt;flat, not to zero.&lt;/strong&gt; In C, where the ring holds raw structs rather than pointers to heap objects, the stronger claim does hold — you &lt;code&gt;malloc&lt;/code&gt; once at startup and push/pop are index arithmetic and a store, with no allocator on the hot path at all. That is precisely why kernels, audio callbacks and embedded firmware use this structure: not because it's fast, but because it's &lt;em&gt;predictable&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part everybody gets wrong: full and empty look identical
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;head&lt;/code&gt; and &lt;code&gt;tail&lt;/code&gt; are stored &lt;strong&gt;wrapped&lt;/strong&gt; — the way most tutorials write it — the structure has a genuine ambiguity, and it is not a subtle one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  EMPTY ring : pushed 0, popped 0        -&amp;gt; (head, tail) = (0, 0)
  FULL  ring : pushed 8, popped 0        -&amp;gt; (head, tail) = (0, 0)

  contents of the FULL ring's storage    : [1, 2, 3, 4, 5, 6, 7, 8]
  contents of the EMPTY ring's storage   : [None, None, None, None, None, None, None, None]

  (head, tail) pairs IDENTICAL?          : True   &amp;lt;-- BYTE-IDENTICAL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A buffer holding eight unread events and a buffer holding nothing produce the &lt;strong&gt;byte-identical&lt;/strong&gt; pair &lt;code&gt;(0, 0)&lt;/code&gt;. And it isn't an artifact of starting at zero — do three push/pop cycles first and you get &lt;code&gt;(3, 3)&lt;/code&gt; for empty and &lt;code&gt;(3, 3)&lt;/code&gt; for full. &lt;code&gt;head == tail&lt;/code&gt; cheerfully reports EMPTY while eight events sit there unread.&lt;/p&gt;

&lt;p&gt;The reason is a counting argument, and once you see it the whole family of fixes falls out:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A ring of &lt;code&gt;n&lt;/code&gt; slots has &lt;strong&gt;&lt;code&gt;n + 1&lt;/code&gt; possible occupancies&lt;/strong&gt; — 0 through n — but a pair of wrapped indices can only encode &lt;strong&gt;&lt;code&gt;n&lt;/code&gt; distinct differences&lt;/strong&gt;. One state doesn't fit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So you either &lt;strong&gt;add one bit of information&lt;/strong&gt;, or you &lt;strong&gt;delete a state&lt;/strong&gt;. Every real implementation is one of those two moves:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Technique&lt;/th&gt;
      &lt;th&gt;The move&lt;/th&gt;
      &lt;th&gt;Usable capacity&lt;/th&gt;
      &lt;th&gt;Cost&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Separate &lt;code&gt;count&lt;/code&gt; field&lt;/td&gt;
      &lt;td&gt;add information&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;n of n&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;every push and pop must update a third variable&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Waste one slot&lt;/td&gt;
      &lt;td&gt;delete a state&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;n − 1 of n&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;declare 8 slots, actually get 7&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Monotonic counters (never wrap)&lt;/td&gt;
      &lt;td&gt;add information&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;n of n&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;counters grow unboundedly (a non-issue at 64 bits)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A single &lt;code&gt;full&lt;/code&gt; boolean&lt;/td&gt;
      &lt;td&gt;add information&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;n of n&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;one bit, plus remembering to maintain it&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You'll often read that there are only two options — a count or a wasted slot. There are at least four, and the third one is the formulation used above and the one the Linux kernel's &lt;code&gt;kfifo&lt;/code&gt; uses. It costs nothing and wastes nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real question: what happens when it's full?
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes this a system-design topic rather than a data-structures exercise.&lt;/p&gt;

&lt;p&gt;Bounding the queue doesn't make the overflow problem go away. It &lt;strong&gt;forces you to answer it&lt;/strong&gt;, at the moment you write the code, instead of discovering the answer at 3am when the OOM killer picks a process. And there are exactly two answers:&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;push_overwrite&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;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;       &lt;span class="c1"&gt;# a log buffer, a metrics window
&lt;/span&gt;        &lt;span class="k"&gt;if&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;head&lt;/span&gt; &lt;span class="o"&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;tail&lt;/span&gt; &lt;span class="o"&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;n&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;tail&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;                 &lt;span class="c1"&gt;# give up the oldest sample
&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;dropped&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;              &lt;span class="c1"&gt;# and COUNT what you gave up
&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;buf&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;head&lt;/span&gt; &lt;span class="o"&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;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&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;head&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;push_or_refuse&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;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;       &lt;span class="c1"&gt;# a job queue: never lose work
&lt;/span&gt;        &lt;span class="k"&gt;if&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;head&lt;/span&gt; &lt;span class="o"&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;tail&lt;/span&gt; &lt;span class="o"&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;n&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;refused&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;                   &lt;span class="c1"&gt;# the producer has to handle this
&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;buf&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;head&lt;/span&gt; &lt;span class="o"&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;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&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;head&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Same structure. Opposite guarantee. One branch of code apart.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Overwrite says: &lt;em&gt;the newest data matters most, and I would rather lose history than lose the present.&lt;/em&gt; That's a metrics window, a log ring, an audio buffer, a flight recorder — you want the last N seconds before the crash, and the seconds before those are worthless.&lt;/p&gt;

&lt;p&gt;Refuse says: &lt;em&gt;every item is work that must not vanish, so if I can't take it, the producer needs to know.&lt;/em&gt; That's a job queue. Refusing is how backpressure gets created — the pain travels back up to whoever is producing too fast, which is the only place it can actually be fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choosing wrong is a data-loss bug wearing a performance costume.&lt;/strong&gt; Silently overwriting a job queue loses paid work. Refusing writes to a metrics buffer takes down the request path to protect telemetry that nobody was going to read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python hands you both — but only one for free
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deque&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Queue&lt;/span&gt;

&lt;span class="c1"&gt;# POLICY ONE, handed to you: a bounded deque OVERWRITES, silently
&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deque&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;2&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="n"&gt;maxlen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# d is now deque([2, 3, 4]) - the 1 is gone
&lt;/span&gt;                             &lt;span class="c1"&gt;# nothing raised, nothing logged, no return value
&lt;/span&gt;
&lt;span class="c1"&gt;# POLICY TWO, and you have to ask for it by name
&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="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="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;2&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="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&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="c1"&gt;# raises queue.Full
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just how silent is the overwrite? I went looking for anything at all that signals the loss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;append()&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; — exactly as it does on a non-full deque.&lt;/li&gt;
&lt;li&gt;No exception, no warning, no log line.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;len()&lt;/code&gt; is 8 before and 8 after, so length can't tell you either.&lt;/li&gt;
&lt;li&gt;There is &lt;strong&gt;no attribute anywhere on the object&lt;/strong&gt; that counts discards. I checked &lt;code&gt;dir()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only way to know is to ask &lt;em&gt;before&lt;/em&gt; you append: &lt;code&gt;if len(d) == d.maxlen: dropped += 1&lt;/code&gt;. If you take one practical thing from this article, take that line.&lt;/p&gt;

&lt;p&gt;The refusing side is loud but oddly terse: the exception is &lt;code&gt;queue.Full&lt;/code&gt;, and &lt;strong&gt;&lt;code&gt;str(e)&lt;/code&gt; is the empty string&lt;/strong&gt; — the type &lt;em&gt;is&lt;/em&gt; the message. After the refusal &lt;code&gt;qsize()&lt;/code&gt; is still 8 and the contents are unchanged. Note which item died in each case: &lt;strong&gt;&lt;code&gt;deque(maxlen)&lt;/code&gt; sacrifices the oldest data silently; &lt;code&gt;Queue(maxsize)&lt;/code&gt; sacrifices the newest, loudly.&lt;/strong&gt; Same bounded capacity, opposite victim.&lt;/p&gt;

&lt;p&gt;One more distinction worth keeping straight: a &lt;code&gt;deque&lt;/code&gt; without &lt;code&gt;maxlen&lt;/code&gt; grows. A ring buffer refuses to — and &lt;em&gt;refusing to grow is the entire feature&lt;/em&gt;, not a limitation you work around.&lt;/p&gt;




&lt;h2&gt;
  
  
  The loss isn't just bounded, it's predictable
&lt;/h2&gt;

&lt;p&gt;"You'll drop data" sounds like a concession. It's actually the strongest thing about the design, because the drops obey a closed form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ring n=1,024  burst=  900 events   DROPPED        0 ( 0.00 %)
  ring n=1,024  burst=1,200 events   DROPPED   35,200 (14.67 %)
  ring n=1,024  burst=4,096 events   DROPPED  614,400 (75.00 %)
  ring n=4,096  burst=4,096 events   DROPPED        0 ( 0.00 %)

  predicted drops = bursts x max(0, burst - n)   -&amp;gt; EXACT MATCH, all four rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;drops = bursts × max(0, burst_size − n)&lt;/code&gt;, and the measured count matched the prediction &lt;strong&gt;exactly&lt;/strong&gt; in every configuration. Size the ring at or above the burst — the last row — and the drop count is exactly zero.&lt;/p&gt;

&lt;p&gt;Meanwhile peak RSS stayed at ~11 MB across all of it. Push the overrun to its worst and peak memory &lt;strong&gt;does not move&lt;/strong&gt;, because peak memory tracks the ring you &lt;em&gt;chose&lt;/em&gt;, not the burst you got. That is the whole trade: you convert an unbounded, invisible, unpredictable memory risk into a bounded, counted, predictable data loss. One of those you can put on a dashboard and alert on. The other one pages you at 3am.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it's already running on your machine
&lt;/h2&gt;

&lt;p&gt;You don't have to adopt this pattern; you're already surrounded by it. All of these are readable without root:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Ring&lt;/th&gt;
      &lt;th&gt;Size on my box&lt;/th&gt;
      &lt;th&gt;Policy&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Kernel log ring (&lt;code&gt;printk&lt;/code&gt;)&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;256 KiB&lt;/strong&gt; (&lt;code&gt;CONFIG_LOG_BUF_SHIFT=18&lt;/code&gt;, fixed at kernel build)&lt;/td&gt;
      &lt;td&gt;overwrite the oldest&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A pipe&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;65,536 bytes&lt;/strong&gt; = 16 pages of 4 KiB&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;refuse&lt;/strong&gt; — blocks the writer&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;
&lt;code&gt;perf&lt;/code&gt; event ring&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;516 KiB&lt;/strong&gt; per user&lt;/td&gt;
      &lt;td&gt;overwrite, and &lt;em&gt;increments a lost-event counter&lt;/em&gt;
&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ALSA PCM preallocation&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;32,768 KiB&lt;/strong&gt; max per substream&lt;/td&gt;
      &lt;td&gt;overwrite (audio can't wait)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pipe is the best one, because you can watch it happen with no privileges at all. &lt;code&gt;F_GETPIPE_SZ&lt;/code&gt; on a fresh pipe reports 65,536 bytes. Fill it non-blocking and it accepts &lt;strong&gt;exactly 65,536&lt;/strong&gt; and then the write &lt;strong&gt;refuses&lt;/strong&gt; with &lt;code&gt;EAGAIN&lt;/code&gt;. Read 8,192 bytes back out and the writer immediately accepts 4,096 more — the ring wrapped and reused the freed slots.&lt;/p&gt;

&lt;p&gt;So the &lt;code&gt;deque(maxlen)&lt;/code&gt;-versus-&lt;code&gt;Queue(maxsize)&lt;/code&gt; decision isn't a Python quirk. It's the same fork, made inside the kernel: the log ring overwrites you, the pipe blocks you. Same structure, the other policy.&lt;/p&gt;

&lt;p&gt;And note what &lt;code&gt;perf&lt;/code&gt; does, because it's the design worth copying: it overwrites &lt;em&gt;and&lt;/em&gt; counts what it lost. Overwriting is defensible. Overwriting silently is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bit-trick that isn't (in Python)
&lt;/h2&gt;

&lt;p&gt;While measuring, one piece of folklore fell over, and it's worth flagging because it's repeated everywhere: &lt;em&gt;"use &lt;code&gt;&amp;amp; (n - 1)&lt;/code&gt; instead of &lt;code&gt;% n&lt;/code&gt;, it's much faster."&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  bare arithmetic  head % N     :   24.66 ns
  bare arithmetic  head &amp;amp; MASK  :   34.27 ns     &amp;lt;- the mask is 39 % SLOWER
  inside a method  RingMod.push :  285.14 ns
  inside a method  RingMask.push:  283.00 ns     &amp;lt;- indistinguishable, it's noise
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CPython the mask is &lt;strong&gt;39% slower&lt;/strong&gt; on the bare arithmetic, and inside a real method call the difference vanishes into noise. Both are a single bytecode op, so it's a fair fight — CPython's &lt;code&gt;long_mod&lt;/code&gt; has a fast path for single-digit ints while &lt;code&gt;&amp;amp;&lt;/code&gt; goes through a more general path.&lt;/p&gt;

&lt;p&gt;The trick is real &lt;strong&gt;in C, Rust and assembly&lt;/strong&gt;, where &lt;code&gt;%&lt;/code&gt; compiles to a hardware divide costing tens of cycles and &lt;code&gt;&amp;amp;&lt;/code&gt; is one. Say it about compiled languages; don't claim it about the Python on your screen. Use whichever is clearer, and save the trick for the language where it pays.&lt;/p&gt;

&lt;p&gt;While we're at it, here's what the operations actually cost:&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;ns/op&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;hand-written ring &lt;strong&gt;push&lt;/strong&gt; (Python method)&lt;/td&gt;
&lt;td&gt;~285&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;hand-written ring &lt;strong&gt;pop&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;~109&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;
&lt;code&gt;deque(maxlen=n).append&lt;/code&gt; on a full deque&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~37&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Queue.put_nowait&lt;/code&gt; + &lt;code&gt;get_nowait&lt;/code&gt; pair&lt;/td&gt;
&lt;td&gt;~1,867&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The stdlib &lt;code&gt;deque&lt;/code&gt; beats the hand-rolled ring by 7.8×&lt;/strong&gt;, because &lt;code&gt;deque&lt;/code&gt; is C and your ring pays a ~250 ns Python method call. &lt;code&gt;Queue&lt;/code&gt; is 4.7× slower than the hand-written ring and 32.7× slower than &lt;code&gt;deque&lt;/code&gt; — that gap is the lock, and it's the honest number for what thread-safety costs you.&lt;/p&gt;

&lt;p&gt;So: write the ring by hand to &lt;em&gt;understand&lt;/em&gt; it. In production Python, reach for &lt;code&gt;deque(maxlen=n)&lt;/code&gt; — and add the drop counter yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  The reframe: this is the shape of LLM serving
&lt;/h2&gt;

&lt;p&gt;If you work on model serving rather than web backends, you've met all of this under different names — and the failure mode is more expensive, because the memory in question is GPU memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The request queue in front of a model server is the exact &lt;code&gt;push_or_refuse&lt;/code&gt; branch.&lt;/strong&gt; An inference server can only hold so many concurrent sequences; the KV cache for each one is real, reserved VRAM. If the arrival rate exceeds the completion rate and the admission queue is unbounded, you don't get slow — you get a CUDA OOM that kills in-flight requests that were already 80% generated. This is why serving stacks admit a bounded number of sequences and &lt;strong&gt;reject&lt;/strong&gt; the overflow with a 429 rather than buffering it. Refusing the write &lt;em&gt;is&lt;/em&gt; the backpressure, and it's the difference between a degraded service and a dead one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sliding-window attention is &lt;code&gt;push_overwrite&lt;/code&gt;, applied to context.&lt;/strong&gt; A model with a fixed window keeps the last &lt;code&gt;n&lt;/code&gt; tokens of KV cache and evicts the oldest as new ones arrive — a ring buffer over tokens, with the overwrite policy chosen deliberately because recent context is worth more than distant context. The fixed window is what makes memory per sequence a constant you chose at config time rather than a function of conversation length. Exactly the 617 MB → 11 MB trade, in VRAM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming token buffers are rings too.&lt;/strong&gt; The generation loop produces tokens faster than a network client consumes them; a bounded buffer between them keeps a slow client from making the GPU-side loop hold unbounded state.&lt;/p&gt;

&lt;p&gt;And the metrics case is the original one: every inference server tracks rolling TTFT and inter-token latency over a &lt;strong&gt;fixed window of recent requests&lt;/strong&gt;. Not because old measurements are wrong, but because a p99 over "all of time" is useless and an unbounded list of samples is the 202.5 MB/million curve waiting to happen on your serving box.&lt;/p&gt;

&lt;p&gt;The question is identical in every one of these, and it's the question the unbounded version never made anyone ask: &lt;strong&gt;when this fills up, does the oldest thing die quietly, or does the newest thing get refused loudly?&lt;/strong&gt; In a token window, the oldest should die. In an admission queue, the newest must be refused. Getting that backwards costs you either a truncated context or a dropped payment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;An unbounded queue is not a queue. It's an &lt;strong&gt;unbounded memory allocation with a friendly API&lt;/strong&gt;, and it will happily grow at 202.5 MB per million events until the kernel picks a process and kills it. It never returns an error, because from its point of view nothing has gone wrong.&lt;/p&gt;

&lt;p&gt;A ring buffer is a fixed number of slots decided once, at startup. That single constraint buys you three things: memory that is &lt;strong&gt;flat and known before you deploy&lt;/strong&gt; (+0.64 MB versus +607.4 MB over the same three million events), loss that is &lt;strong&gt;predictable in closed form&lt;/strong&gt; rather than catastrophic, and — most valuable of all — it &lt;strong&gt;forces the overflow decision into your source code&lt;/strong&gt;, where you can read it, instead of leaving it to the OOM killer.&lt;/p&gt;

&lt;p&gt;So the real content of this data structure isn't the modulo. It's the branch:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What a buffer does when it is full is a product decision wearing a data-structure costume.&lt;/strong&gt; Overwrite the oldest and count what you dropped, or refuse the write and push back on the producer. Pick deliberately, write down why, and expose the counter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Python, reach for &lt;code&gt;deque(maxlen=n)&lt;/code&gt; when overwriting is right and &lt;code&gt;Queue(maxsize=n)&lt;/code&gt; when refusing is right. Then add the one line the stdlib doesn't give you — the counter that tells you it's happening.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The structure itself, and the wrap arithmetic&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Donald E. Knuth, &lt;em&gt;The Art of Computer Programming, Volume 1: Fundamental Algorithms&lt;/em&gt;, 3rd ed. (Addison-Wesley, 1997) — §2.2.2 "Sequential Allocation" is the classical treatment of a queue in a circular array, including the full-versus-empty problem and the wasted-slot fix.&lt;/li&gt;
&lt;li&gt;Linux kernel documentation, &lt;a href="https://www.kernel.org/doc/html/latest/core-api/kernel-api.html#fifo-buffer" rel="noopener noreferrer"&gt;FIFO Buffer (&lt;code&gt;kfifo&lt;/code&gt;)&lt;/a&gt; — the monotonic-counter formulation used throughout this article, in production C.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choosing the policy: overwrite or refuse&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python documentation, &lt;a href="https://docs.python.org/3/library/collections.html#collections.deque" rel="noopener noreferrer"&gt;&lt;code&gt;collections.deque&lt;/code&gt;&lt;/a&gt; — the &lt;code&gt;maxlen&lt;/code&gt; paragraph, which is where the silent-discard behaviour is specified rather than implied.&lt;/li&gt;
&lt;li&gt;Python documentation, &lt;a href="https://docs.python.org/3/library/queue.html" rel="noopener noreferrer"&gt;&lt;code&gt;queue.Queue&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;maxsize&lt;/code&gt;, &lt;code&gt;put_nowait&lt;/code&gt;, and the &lt;code&gt;queue.Full&lt;/code&gt; exception.&lt;/li&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (O'Reilly, 2017) — chapter 11 on message brokers frames "what happens when the queue fills" (drop, buffer, or apply backpressure) as a design decision with consequences, which is the argument this article makes at process scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cost, locks, and lock-free rings&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Thompson, Dave Farley, Michael Barker, Patricia Gee &amp;amp; Andrew Stewart, &lt;a href="https://lmax-exchange.github.io/disruptor/disruptor.html" rel="noopener noreferrer"&gt;&lt;em&gt;Disruptor: High Performance Alternative to Bounded Queues for Exchanging Data Between Concurrent Threads&lt;/em&gt;&lt;/a&gt; (LMAX, 2011) — a production ring buffer built specifically to avoid the lock that costs &lt;code&gt;queue.Queue&lt;/code&gt; its 32.7× penalty here.&lt;/li&gt;
&lt;li&gt;Ross Bencina, &lt;a href="http://www.rossbencina.com/code/real-time-audio-programming-101-time-waits-for-nothing" rel="noopener noreferrer"&gt;Real-time audio programming 101: time waits for nothing&lt;/a&gt; — why "no allocator on the hot path" is a correctness requirement and not an optimisation, which is the strongest case for the fixed-at-startup array.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where the rings already are&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;man 7 pipe&lt;/code&gt; — &lt;a href="https://man7.org/linux/man-pages/man7/pipe.7.html" rel="noopener noreferrer"&gt;pipe capacity and &lt;code&gt;F_GETPIPE_SZ&lt;/code&gt;&lt;/a&gt;; the 65,536-byte figure measured above, and the blocking semantics that make a pipe the &lt;em&gt;refusing&lt;/em&gt; variant.&lt;/li&gt;
&lt;li&gt;Brendan Gregg, &lt;em&gt;Systems Performance: Enterprise and the Cloud&lt;/em&gt;, 2nd ed. (Addison-Wesley, 2020) — the &lt;code&gt;perf&lt;/code&gt; and BPF ring buffers, including the lost-event counters that make overwriting observable rather than silent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you'd expect is missing, say so in the comments and I'll add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; the &lt;a href="https://youtube.com/shorts/46u8rNvwIlg" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; frames the failure, and the &lt;a href="https://youtu.be/-pUNFU3WFf0" rel="noopener noreferrer"&gt;full episode&lt;/a&gt; builds the ring from scratch and runs every measurement on screen.&lt;/p&gt;

</description>
      <category>computerscience</category>
      <category>programming</category>
      <category>python</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
