<?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: Anas Issath</title>
    <description>The latest articles on DEV Community by Anas Issath (@anas_issath).</description>
    <link>https://dev.to/anas_issath</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%2F907893%2F57da9c4e-064f-47b3-a5ca-5a75138b4d51.png</url>
      <title>DEV Community: Anas Issath</title>
      <link>https://dev.to/anas_issath</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anas_issath"/>
    <language>en</language>
    <item>
      <title>Database Transactions Are Lying to You: What Isolation Levels Actually Guarantee</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Mon, 09 Nov 2026 06:44:02 +0000</pubDate>
      <link>https://dev.to/anas_issath/database-transactions-are-lying-to-you-what-isolation-levels-actually-guarantee-1m00</link>
      <guid>https://dev.to/anas_issath/database-transactions-are-lying-to-you-what-isolation-levels-actually-guarantee-1m00</guid>
      <description>&lt;p&gt;&lt;em&gt;Two requests hit your database at the same time. One overwrites the other's work, and neither one errors. Here's why, and how to stop it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's a scenario I've seen dozens of times.&lt;/p&gt;

&lt;p&gt;A "buy now" button works perfectly in every manual test. One person clicks it, stock decrements by one, order gets created. Ships to production.&lt;/p&gt;

&lt;p&gt;A few weeks later, a flash sale runs, and support tickets start coming in: &lt;strong&gt;customers charged for items that were actually out of stock.&lt;/strong&gt; The inventory count went negative. Nobody's code has a bug you can point to. Every individual request did exactly what it was told.&lt;/p&gt;

&lt;p&gt;The problem wasn't one request. It was &lt;strong&gt;two requests, reading the same stock count at the same instant&lt;/strong&gt;, both deciding there was enough left, both writing "success."&lt;/p&gt;

&lt;p&gt;This is what transaction isolation is supposed to prevent, and most developers wrap code in &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt; assuming it fully does, without knowing which guarantees their isolation level actually makes.&lt;/p&gt;

&lt;p&gt;This guide explains what a transaction really promises, the four standard isolation levels, and the concurrency bugs that slip through the cracks depending on which one you're using.&lt;/p&gt;

&lt;p&gt;Let's make your writes actually safe under load.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Transaction Actually Guarantees
&lt;/h2&gt;

&lt;p&gt;A transaction groups multiple operations so they succeed or fail as one unit. That's the "atomic" in ACID.&lt;/p&gt;

&lt;p&gt;What it does &lt;strong&gt;not&lt;/strong&gt; automatically guarantee is that your transaction is isolated from every other transaction running at the same time. That part is configurable, and the default varies by database.&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;inventory&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stock&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&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;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks safe. It isn't, by itself. It only guarantees that &lt;em&gt;within this transaction&lt;/em&gt;, both statements happen together. It says nothing about what a second, concurrent transaction running the exact same code is allowed to see or overwrite while this one is still in flight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's what isolation levels control.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Problem: Two Transactions, One Row
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Time    Transaction A                Transaction B
----    -------------------------    -------------------------
t1      BEGIN
t2      SELECT stock FROM inventory
        WHERE id=42;  → stock = 1
t3                                   BEGIN
t4                                   SELECT stock FROM inventory
                                      WHERE id=42;  → stock = 1
t5      UPDATE stock = 0
t6      COMMIT
t7                                   UPDATE stock = 0
t8                                   COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both transactions read &lt;code&gt;stock = 1&lt;/code&gt;. Both decided it was safe to sell. Both committed. The final stock is 0, but &lt;strong&gt;two orders were created for one unit of inventory.&lt;/strong&gt; Nothing errored.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is a &lt;strong&gt;lost update&lt;/strong&gt;, and it's the textbook example of what isolation levels exist to prevent.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Four Standard Isolation Levels
&lt;/h2&gt;

&lt;p&gt;The SQL standard defines four levels, each permitting fewer anomalies than the last, at the cost of more locking, more blocking, or more retries.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Read Uncommitted
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;READ&lt;/span&gt; &lt;span class="k"&gt;UNCOMMITTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weakest level. A transaction can read data another transaction has written but &lt;strong&gt;not yet committed&lt;/strong&gt;, including data that later gets rolled back. This is called a &lt;strong&gt;dirty read&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;Transaction A: UPDATE balance SET amount = 1000 WHERE id = 1;  (not committed yet)
Transaction B: SELECT amount FROM balance WHERE id = 1;  → sees 1000
Transaction A: ROLLBACK;
-- Transaction B made a decision based on data that never existed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost nobody should use this in production. PostgreSQL doesn't even implement it as distinct from Read Committed. It's there for standards compliance only.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Read Committed
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;READ&lt;/span&gt; &lt;span class="k"&gt;COMMITTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The default in PostgreSQL, Oracle, and SQL Server.&lt;/strong&gt; A transaction only ever sees data that has been committed: no dirty reads.&lt;/p&gt;

&lt;p&gt;But it doesn't protect you from a &lt;strong&gt;non-repeatable read&lt;/strong&gt;: if you query the same row twice in one transaction, another transaction's commit in between can change what you see the second time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction A: SELECT stock FROM inventory WHERE id=42;  → 1
Transaction B: UPDATE inventory SET stock=0 WHERE id=42; COMMIT;
Transaction A: SELECT stock FROM inventory WHERE id=42;  → 0
-- Same query, same transaction, different answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Repeatable Read
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;REPEATABLE&lt;/span&gt; &lt;span class="k"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The default in MySQL/InnoDB.&lt;/strong&gt; Once a transaction reads a row, it will see the same data for that row for the rest of the transaction, even if another transaction commits a change.&lt;/p&gt;

&lt;p&gt;This fixes non-repeatable reads, but the standard still permits a &lt;strong&gt;phantom read&lt;/strong&gt;: a query returning a &lt;em&gt;set&lt;/em&gt; of rows can see new rows appear on a second run, even though individual rows you already read stay stable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction A: SELECT * FROM orders WHERE status='pending';  → 5 rows
Transaction B: INSERT INTO orders (status) VALUES ('pending'); COMMIT;
Transaction A: SELECT * FROM orders WHERE status='pending';  → 6 rows
-- A new row "phantom" appeared in the result set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(In practice, PostgreSQL's implementation of Repeatable Read is stricter than the SQL standard requires and also blocks phantom reads. But don't assume that behavior is portable to every database.)&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Serializable
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SERIALIZABLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The strongest level. Guarantees that the result of running transactions concurrently is &lt;strong&gt;identical to running them one after another&lt;/strong&gt; in some order. No dirty reads, no non-repeatable reads, no phantoms, no lost updates.&lt;/p&gt;

&lt;p&gt;The cost: the database has to detect conflicts and &lt;strong&gt;abort&lt;/strong&gt; one of the transactions when it can't guarantee that ordering.&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ... transaction logic ...&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Sometimes returns:&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR: could not serialize access due to concurrent update&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application must be ready to catch this and retry.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Serializable isn't "safe and free." It's "safe, and sometimes it tells you to try again."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Quick Reference: What Each Level Prevents
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The takeaway:&lt;/strong&gt; your database's default isolation level is Read Committed in most systems, and &lt;strong&gt;Read Committed does not prevent the lost-update bug&lt;/strong&gt; from the flash sale example. If you never explicitly thought about isolation, you're running at the level that permits it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common Isolation Problems
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Problem 1: Assuming &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt; alone prevents race conditions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Looks safe. Isn't, under Read Committed.
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&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;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stock&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="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two concurrent requests can both fetch &lt;code&gt;stock = 1&lt;/code&gt;, both pass the &lt;code&gt;if&lt;/code&gt;, both decrement to 0, and both succeed. Wrapping code in a transaction guarantees atomicity of the &lt;em&gt;write&lt;/em&gt;. It does not, by itself, guarantee the read-then-write is protected from another transaction doing the same thing in between.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; use a row-level lock (&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;) to force serialization at the row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_for_update&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="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&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;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stock&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="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What select_for_update does under the hood:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&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;42&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Second transaction's identical query now BLOCKS until this one commits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Problem 2: Using a lock when an atomic update would do
&lt;/h3&gt;

&lt;p&gt;For simple counters, you often don't need a lock at all. You need to stop reading the value into application code in the first place.&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;# Race-prone: read, modify in Python, write
&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Race-free: the decrement happens entirely inside the database&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stock&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;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;42&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;stock&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version pushes the read-modify-write into a &lt;strong&gt;single atomic statement&lt;/strong&gt; the database executes as one operation. There's no window where two transactions can both see the same stale value. This is usually cheaper and simpler than a lock for pure counter updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 3: Deadlocks from inconsistent lock ordering
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transaction A: locks row 1, then tries to lock row 2
Transaction B: locks row 2, then tries to lock row 1
-- Both wait for each other forever
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Transaction A&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;100&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;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;100&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;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Transaction B, running concurrently&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;50&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;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;50&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;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If A locks row 1 first and B locks row 2 first, each is now waiting on a row the other holds. The database detects this and kills one transaction with a deadlock error, but only after both have been stalled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; always acquire locks on multiple rows in a &lt;strong&gt;consistent order&lt;/strong&gt;, e.g. always lock the lower ID first.&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;-- Both transactions now always touch id=1 before id=2&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;amt&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="n"&gt;LEAST&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;amt&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="n"&gt;GREATEST&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Problem 4: Long transactions holding locks under load
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_for_update&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="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;send_confirmation_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# slow network call, 2–3 seconds
&lt;/span&gt;    &lt;span class="nf"&gt;charge_payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# slow network call, 2–5 seconds
&lt;/span&gt;    &lt;span class="n"&gt;order&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;confirmed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row lock from &lt;code&gt;select_for_update&lt;/code&gt; is held for the &lt;strong&gt;entire transaction&lt;/strong&gt;, including both slow external calls. Every other request trying to touch that order queues up behind it for as long as those network calls take.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; keep locks held only as long as the database work itself takes. Do slow, unrelated I/O outside the transaction.&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="nf"&gt;send_confirmation_email_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# queued, not inline
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;charge_payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# done before opening the lock
&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_for_update&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="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;order&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;confirmed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Problem 5: Picking Serializable and not handling retries
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Set isolation to Serializable, then treat it like it can't fail
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SERIALIZABLE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;transfer_funds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Serializable transactions can and will be aborted by the database whenever it can't guarantee a consistent ordering. &lt;strong&gt;This is expected, normal behavior, not an edge case.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; wrap serializable transactions in a retry loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isolation_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SERIALIZABLE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;transfer_funds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;SerializationError&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;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Choosing a Level for Your Use Case
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Read Committed&lt;/strong&gt; (the default, mostly)&lt;/td&gt;
&lt;td&gt;The majority of reads, and writes that don't depend on a prior read of the same row within the transaction. Most CRUD operations.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Row locks&lt;/strong&gt; (&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;) at Read Committed&lt;/td&gt;
&lt;td&gt;The standard fix for read-then-write races on a specific row: inventory, balances, seat reservations. Cheaper and more predictable than jumping to Serializable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Atomic SQL updates&lt;/strong&gt; (&lt;code&gt;SET x = x - 1 WHERE x &amp;gt; 0&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;The simplest, fastest option whenever the operation can be expressed as pure SQL without application logic between the read and the write.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Serializable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Logic too complex for a single atomic statement or a simple row lock: multi-row invariants, complex financial transfers, anything where "occasionally retried" is an acceptable tradeoff for full correctness.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Checking What's Actually Happening
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL: see current isolation level&lt;/span&gt;
&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt; &lt;span class="k"&gt;ISOLATION&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- PostgreSQL: see locks currently held and what's blocked on them&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;blocked_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocked_pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;blocking_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocking_pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;blocked_activity&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;AS&lt;/span&gt; &lt;span class="n"&gt;blocked_query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_locks&lt;/span&gt; &lt;span class="n"&gt;blocked_locks&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_locks&lt;/span&gt; &lt;span class="n"&gt;blocking_locks&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;blocking_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locktype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blocked_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locktype&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;blocking_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;database&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;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;blocked_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;database&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;blocking_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relation&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;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;blocked_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relation&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;blocking_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;blocked_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;blocked_activity&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;blocked_activity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blocked_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;blocked_locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;granted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're seeing mysterious slow requests under concurrent load with no slow query in sight, &lt;strong&gt;checking for blocked locks is often more revealing than checking query plans.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Wrapping code in a transaction feels like it should make concurrency problems disappear. It doesn't. It only guarantees the specific things ACID promises, and by default, most databases run at a level that still allows two requests to read the same stale value and both act on it.&lt;/p&gt;

&lt;p&gt;The flash-sale overselling bug isn't a bug in anyone's code; it's the predictable result of Read Committed doing exactly what it's documented to do.&lt;/p&gt;

&lt;p&gt;Remember:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt; guarantees atomicity, not isolation from concurrent reads.&lt;/strong&gt; Those are separate guarantees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read Committed (most databases' default) still allows lost updates.&lt;/strong&gt; Know your default, don't assume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;&lt;/strong&gt; is usually the right fix for read-then-write races on a single row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic SQL updates&lt;/strong&gt; (&lt;code&gt;SET x = x - 1 WHERE x &amp;gt; 0&lt;/code&gt;) beat locks when the logic is simple enough to express in one statement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serializable transactions can fail on purpose.&lt;/strong&gt; Your code needs a retry loop, not just a &lt;code&gt;try/except&lt;/code&gt; that gives up.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The bug that only shows up during your highest-traffic moment (the flash sale, the ticket drop, the Black Friday spike) is usually a concurrency bug, not a capacity bug.&lt;/strong&gt; Understanding isolation levels is how you catch it before that moment finds it for you.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Have you ever shipped a race condition that only showed up under real traffic? What gave it away? Tell me in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>django</category>
      <category>backend</category>
    </item>
    <item>
      <title>asyncpg vs SQLAlchemy ORM - The 9x Slowdown Nobody Warns You About</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Sun, 08 Nov 2026 14:19:10 +0000</pubDate>
      <link>https://dev.to/anas_issath/asyncpg-vs-sqlalchemy-orm-the-9x-slowdown-nobody-warns-you-about-2j99</link>
      <guid>https://dev.to/anas_issath/asyncpg-vs-sqlalchemy-orm-the-9x-slowdown-nobody-warns-you-about-2j99</guid>
      <description>&lt;p&gt;&lt;em&gt;Async doesn't mean fast. Your ORM might be quietly throwing away everything asyncpg gives you.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We migrated a FastAPI service from sync SQLAlchemy to async SQLAlchemy, expecting a performance win. Async is supposed to be faster, right? Non-blocking I/O, more concurrent requests per worker, better resource utilization.&lt;/p&gt;

&lt;p&gt;Our load test showed the opposite. &lt;strong&gt;The async version was slower per-request than the sync version had been.&lt;/strong&gt; Not by a little: a specific endpoint that fetched 500 rows and mapped them to ORM objects went from &lt;strong&gt;340ms to 890ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I assumed we'd misconfigured something. Wrong connection pool size, maybe, or a missing index. I profiled the endpoint with &lt;code&gt;cProfile&lt;/code&gt; and found the answer: &lt;strong&gt;the async ORM layer itself was the bottleneck.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Object instantiation, identity map tracking, and attribute loading (the same overhead that exists in sync SQLAlchemy) was still there in async SQLAlchemy. We'd added the complexity of async without eliminating the cost of the ORM.&lt;/p&gt;

&lt;p&gt;This isn't a reason to avoid async or avoid SQLAlchemy. It's a reason to understand exactly what you're paying for at each layer, so you can make an informed choice instead of assuming "async" automatically means "fast."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Layers and What Each One Costs
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqedoqz9y74tf5o22pbz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqedoqz9y74tf5o22pbz.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you fetch data from PostgreSQL in a modern async Python app, there are three layers between your code and the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your code
  → SQLAlchemy ORM (optional)     ← object mapping, identity map, relationships
  → SQLAlchemy Core / Driver       ← query compilation, connection management
  → asyncpg or psycopg              ← the actual wire protocol to PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer adds capability and each layer adds overhead. Understanding &lt;em&gt;where&lt;/em&gt; the overhead comes from tells you when it's worth paying and when it isn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: The Driver (asyncpg vs psycopg 3)
&lt;/h3&gt;

&lt;p&gt;At the bottom, you have the actual PostgreSQL wire protocol implementation. Two real choices in 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;asyncpg&lt;/strong&gt;: built from scratch for asyncio. Speaks PostgreSQL's binary protocol directly. No general-purpose abstraction layer. This is the fastest way to talk to PostgreSQL from Python, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;psycopg 3 (async mode)&lt;/strong&gt;: the modern rewrite of the classic psycopg2, with native async support. Slightly slower than asyncpg in raw benchmarks, but shares an API with sync psycopg, which matters if you're maintaining both sync and async code paths.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Raw asyncpg - as fast as Python gets talking to PostgreSQL
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncpg&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;get_orders&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;conn&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;asyncpg&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="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;rows&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;conn&lt;/span&gt;&lt;span class="p"&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;SELECT id, total, status FROM orders WHERE user_id = $1&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;row&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="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&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;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# psycopg 3 async - nearly as fast, more familiar API
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&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;get_orders&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncConnection&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="n"&gt;DATABASE_URL&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;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT id, total, status FROM orders WHERE user_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="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are dramatically faster than going through an ORM for simple queries. The difference between them is small enough that it rarely decides an architecture. asyncpg wins benchmarks by roughly 2x for raw throughput, but in a real application with network latency and business logic around the query, that gap shrinks to something you won't notice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: SQLAlchemy Core (No ORM)
&lt;/h3&gt;

&lt;p&gt;SQLAlchemy Core gives you a query builder and connection management &lt;strong&gt;without object mapping&lt;/strong&gt;. You write something close to SQL, but Python-native and composable:&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;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_async_engine&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_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+asyncpg://user:pass@localhost/mydb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;column&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="nf"&gt;column&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_orders&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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="n"&gt;result&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;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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adds a small amount of overhead over raw asyncpg (query compilation, parameter binding, connection pool management), but it's a fraction of what the full ORM costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: The ORM (SQLAlchemy's Declarative Models)
&lt;/h3&gt;

&lt;p&gt;The ORM adds object identity tracking, relationship loading, change tracking (dirty attribute detection), and Python object instantiation &lt;strong&gt;for every row&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;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;declarative_base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mapped_column&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;

&lt;span class="n"&gt;Base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;declarative_base&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&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;Mapped&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;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&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;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&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;result&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;session&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same query. Same data. But now every row becomes a full Python object with SQLAlchemy's instrumentation attached:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tracking whether each attribute has changed&lt;/li&gt;
&lt;li&gt;Registering the object in the session's identity map, so &lt;code&gt;session.get(Order, id)&lt;/code&gt; returns the same instance&lt;/li&gt;
&lt;li&gt;Setting up lazy-loading machinery for any relationships&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Benchmark: What Each Layer Actually Costs
&lt;/h2&gt;

&lt;p&gt;I ran this on a table with 100,000 rows, fetching 500 rows matching a filter, on a local PostgreSQL instance to eliminate network variance:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The jump from Core to ORM is the one that matters.&lt;/strong&gt; Going from raw asyncpg to SQLAlchemy Core costs you a small, reasonable overhead for a massive gain in composability and safety. Going from Core to full ORM costs you almost &lt;strong&gt;3x more&lt;/strong&gt; on top of that, for object mapping you may not even need.&lt;/p&gt;

&lt;p&gt;This matches what other benchmarks in the Python community have found: SQLAlchemy's ORM layer with async drivers can be an order of magnitude slower than raw driver access for simple read-heavy queries, primarily because of &lt;strong&gt;Python-level object construction overhead&lt;/strong&gt;, not the database round trip itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the ORM Overhead Is So High With Async
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qx758vvkwbv18myomye.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qx758vvkwbv18myomye.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the part that surprised me most. You'd expect async to at least &lt;em&gt;match&lt;/em&gt; sync ORM performance, since the actual database call is non-blocking either way. But in some benchmarks, &lt;strong&gt;async ORM queries are actually slower than sync ORM queries&lt;/strong&gt; for CPU-bound work like object instantiation.&lt;/p&gt;

&lt;p&gt;The reason: async adds its own overhead (event loop scheduling, coroutine creation, awaiting each step of the driver protocol) on top of the ORM's existing object-mapping cost.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;strong&gt;I/O-bound work&lt;/strong&gt; (waiting on the network), async wins because the event loop can do other things while waiting.&lt;/li&gt;
&lt;li&gt;But &lt;strong&gt;object instantiation isn't I/O-bound.&lt;/strong&gt; It's pure CPU work happening on the same thread, and async adds bookkeeping around it without adding parallelism, because Python's GIL means you're not getting extra CPU time from asyncio. You're just avoiding blocking waits.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;So the ORM's object-mapping tax is paid in full, and the async machinery adds a smaller additional tax on top&lt;/strong&gt;, for operations that were never going to benefit from concurrency in the first place.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  When Each Layer Is the Right Choice
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Use raw asyncpg when...
&lt;/h3&gt;

&lt;p&gt;You're building a narrow, performance-critical service: a data pipeline, an analytics endpoint, a service that streams large result sets. You control the SQL directly and don't need object mapping, relationships, or migrations tied to this code 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;# High-throughput analytics endpoint
&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;/api/analytics/daily-revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;daily_revenue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;asyncpg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_pool&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquire&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;rows&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;conn&lt;/span&gt;&lt;span class="p"&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;
            SELECT date_trunc(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, created_at) as day, SUM(total) as revenue
            FROM orders
            WHERE created_at &amp;gt;= NOW() - INTERVAL &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;30 days&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
            GROUP BY day
            ORDER BY day
        &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="nf"&gt;dict&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Use SQLAlchemy Core when...
&lt;/h3&gt;

&lt;p&gt;You want composable, injection-safe query building without the ORM's object overhead. This is the &lt;strong&gt;sweet spot for most read-heavy API endpoints&lt;/strong&gt; (list views, search, reporting) where you need dynamic filters but don't need to mutate and save objects back.&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;list_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderFilters&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# Returns dicts, not ORM objects
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;result.mappings().all()&lt;/code&gt; is the key detail.&lt;/strong&gt; It returns plain dictionaries instead of ORM entity objects, skipping the identity map and change-tracking machinery entirely while still using SQLAlchemy's query builder and connection pooling.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Use the full ORM when...
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You're doing &lt;strong&gt;writes with relationships&lt;/strong&gt; (creating an order with items in one transaction)&lt;/li&gt;
&lt;li&gt;You need the &lt;strong&gt;identity map&lt;/strong&gt; to prevent duplicate object instances across a request&lt;/li&gt;
&lt;li&gt;You're using SQLAlchemy's &lt;strong&gt;relationship loading&lt;/strong&gt; (&lt;code&gt;selectinload&lt;/code&gt;, &lt;code&gt;joinedload&lt;/code&gt;) to manage complex object graphs&lt;/li&gt;
&lt;li&gt;Your team's &lt;strong&gt;productivity gain&lt;/strong&gt; from the ORM's ergonomics outweighs the performance cost for that specific endpoint
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_order_with_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&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;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&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;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&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="nc"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product_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;quantity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quantity&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&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;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&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;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&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;order&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly where the ORM earns its overhead: cascading inserts, relationship management, and object lifecycle tracking that would take significantly more raw SQL and manual bookkeeping to replicate correctly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Returns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw asyncpg&lt;/td&gt;
&lt;td&gt;Narrow, performance-critical paths, analytics, streaming&lt;/td&gt;
&lt;td&gt;Records / dicts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLAlchemy Core&lt;/td&gt;
&lt;td&gt;Read-heavy list, search and reporting endpoints&lt;/td&gt;
&lt;td&gt;Dicts via &lt;code&gt;.mappings()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full ORM&lt;/td&gt;
&lt;td&gt;Writes with relationships, complex object graphs&lt;/td&gt;
&lt;td&gt;Tracked ORM objects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Hybrid Pattern I Actually Use
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcocz1zh9xn60kp7hqtme.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcocz1zh9xn60kp7hqtme.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most production FastAPI services benefit from using &lt;strong&gt;different layers for different endpoints&lt;/strong&gt;, all sharing the same async engine and connection pool:&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;# app/database.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;async_sessionmaker&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_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+asyncpg://user:pass@localhost/mydb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pool_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_overflow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pool_pre_ping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;AsyncSessionLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expire_on_commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncSessionLocal&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;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;

&lt;span class="c1"&gt;# Writes and relationship-heavy reads: full ORM
&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;/api/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderCreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;order&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;order_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Uses ORM
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;

&lt;span class="c1"&gt;# High-volume list endpoints: SQLAlchemy Core, dicts not objects
&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;/api/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderFilters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_order_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Core query, no ORM mapping
&lt;/span&gt;    &lt;span class="n"&gt;result&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;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="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Reporting and analytics: raw SQL via the same session
&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;/api/reports/summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;report_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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="nf"&gt;text&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 status, COUNT(*) as count, SUM(total) as revenue
        FROM orders GROUP BY status
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three share the same &lt;code&gt;AsyncSession&lt;/code&gt; and connection pool. You're not managing separate drivers or separate pools. &lt;strong&gt;You're choosing, per endpoint, how much of the ORM's machinery you actually need.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The One Setting That Changes Everything: &lt;code&gt;expire_on_commit&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you do use the ORM, this default setting quietly causes extra queries you didn't ask for:&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;# Default behavior - expires all objects after commit
&lt;/span&gt;&lt;span class="n"&gt;async_session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&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;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Triggers a NEW query to refresh the expired object!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default, SQLAlchemy marks every object's attributes as "expired" after a commit, so the next time you access them, it silently re-fetches from the database to guarantee freshness.&lt;/p&gt;

&lt;p&gt;In a request-response API, where the session ends right after the commit, this is almost always wasted work. You're about to serialize the object and return it, not keep using it across a long-lived session.&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;# Disable expiration - trust the in-memory data after commit
&lt;/span&gt;&lt;span class="n"&gt;async_session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expire_on_commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;expire_on_commit=False&lt;/code&gt; is the single highest-leverage SQLAlchemy async setting for API workloads.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I set it on every FastAPI project now. Without it, a simple "create and return" endpoint fires a &lt;code&gt;COMMIT&lt;/code&gt; and then a silent &lt;code&gt;SELECT&lt;/code&gt; to re-fetch data you already had in memory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;Async doesn't erase the cost of the layers above the database driver. It just changes what kind of cost you're paying.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raw asyncpg&lt;/strong&gt; is fast because it does almost nothing beyond speaking PostgreSQL's protocol.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLAlchemy Core&lt;/strong&gt; adds a thin, worthwhile layer of safety and composability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The full ORM&lt;/strong&gt; adds real, measurable overhead in exchange for object mapping, relationship management, and identity tracking: features that are essential for some endpoints and pure cost for others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake isn't choosing SQLAlchemy's ORM. &lt;strong&gt;It's using it uniformly across every endpoint without asking whether that endpoint needs what it provides.&lt;/strong&gt; A list endpoint returning 500 rows of read-only data doesn't need an identity map. A checkout endpoint creating an order with five related line items absolutely does.&lt;/p&gt;

&lt;p&gt;That endpoint that went from 340ms to 890ms after our sync-to-async migration? We didn't revert to sync. We kept async, dropped the ORM for that specific read path, and switched to &lt;code&gt;result.mappings().all()&lt;/code&gt; with SQLAlchemy Core.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It came back down to 95ms&lt;/strong&gt;: faster than the original sync version, using the same connection pool, the same database, and about fifteen lines of changed code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Over to You
&lt;/h2&gt;

&lt;p&gt;What's your database access pattern in async Python? Full ORM everywhere, raw SQL everywhere, or a hybrid like this? And has anyone measured the async-vs-sync ORM overhead in their own benchmarks? I'd love to see numbers from a different workload than mine. Share in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;---&lt;br&gt;
title: asyncpg vs SQLAlchemy ORM - The 9x Slowdown Nobody Warns You About&lt;br&gt;
description: Async doesn't mean fast. Your ORM might be quietly throwing away everything asyncpg gives you.&lt;/p&gt;
&lt;h2&gt;
  
  
  tags: python, fastapi, postgres, performance
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Async doesn't mean fast. Your ORM might be quietly throwing away everything asyncpg gives you.&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;We migrated a FastAPI service from sync SQLAlchemy to async SQLAlchemy, expecting a performance win. Async is supposed to be faster, right? Non-blocking I/O, more concurrent requests per worker, better resource utilization.&lt;/p&gt;

&lt;p&gt;Our load test showed the opposite. &lt;strong&gt;The async version was slower per-request than the sync version had been.&lt;/strong&gt; Not by a little: a specific endpoint that fetched 500 rows and mapped them to ORM objects went from &lt;strong&gt;340ms to 890ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I assumed we'd misconfigured something. Wrong connection pool size, maybe, or a missing index. I profiled the endpoint with &lt;code&gt;cProfile&lt;/code&gt; and found the answer: &lt;strong&gt;the async ORM layer itself was the bottleneck.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Object instantiation, identity map tracking, and attribute loading (the same overhead that exists in sync SQLAlchemy) was still there in async SQLAlchemy. We'd added the complexity of async without eliminating the cost of the ORM.&lt;/p&gt;

&lt;p&gt;This isn't a reason to avoid async or avoid SQLAlchemy. It's a reason to understand exactly what you're paying for at each layer, so you can make an informed choice instead of assuming "async" automatically means "fast."&lt;/p&gt;


&lt;h2&gt;
  
  
  The Three Layers and What Each One Costs
&lt;/h2&gt;



&lt;p&gt;When you fetch data from PostgreSQL in a modern async Python app, there are three layers between your code and the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your code
  → SQLAlchemy ORM (optional)     ← object mapping, identity map, relationships
  → SQLAlchemy Core / Driver       ← query compilation, connection management
  → asyncpg or psycopg              ← the actual wire protocol to PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer adds capability and each layer adds overhead. Understanding &lt;em&gt;where&lt;/em&gt; the overhead comes from tells you when it's worth paying and when it isn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: The Driver (asyncpg vs psycopg 3)
&lt;/h3&gt;

&lt;p&gt;At the bottom, you have the actual PostgreSQL wire protocol implementation. Two real choices in 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;asyncpg&lt;/strong&gt;: built from scratch for asyncio. Speaks PostgreSQL's binary protocol directly. No general-purpose abstraction layer. This is the fastest way to talk to PostgreSQL from Python, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;psycopg 3 (async mode)&lt;/strong&gt;: the modern rewrite of the classic psycopg2, with native async support. Slightly slower than asyncpg in raw benchmarks, but shares an API with sync psycopg, which matters if you're maintaining both sync and async code paths.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Raw asyncpg - as fast as Python gets talking to PostgreSQL
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncpg&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;get_orders&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;conn&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;asyncpg&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="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;rows&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;conn&lt;/span&gt;&lt;span class="p"&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;SELECT id, total, status FROM orders WHERE user_id = $1&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;row&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="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&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;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# psycopg 3 async - nearly as fast, more familiar API
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&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;get_orders&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncConnection&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="n"&gt;DATABASE_URL&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;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT id, total, status FROM orders WHERE user_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="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are dramatically faster than going through an ORM for simple queries. The difference between them is small enough that it rarely decides an architecture. asyncpg wins benchmarks by roughly 2x for raw throughput, but in a real application with network latency and business logic around the query, that gap shrinks to something you won't notice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: SQLAlchemy Core (No ORM)
&lt;/h3&gt;

&lt;p&gt;SQLAlchemy Core gives you a query builder and connection management &lt;strong&gt;without object mapping&lt;/strong&gt;. You write something close to SQL, but Python-native and composable:&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;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_async_engine&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_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+asyncpg://user:pass@localhost/mydb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;column&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="nf"&gt;column&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_orders&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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="n"&gt;result&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;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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adds a small amount of overhead over raw asyncpg (query compilation, parameter binding, connection pool management), but it's a fraction of what the full ORM costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: The ORM (SQLAlchemy's Declarative Models)
&lt;/h3&gt;

&lt;p&gt;The ORM adds object identity tracking, relationship loading, change tracking (dirty attribute detection), and Python object instantiation &lt;strong&gt;for every row&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;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;declarative_base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mapped_column&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;

&lt;span class="n"&gt;Base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;declarative_base&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&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;Mapped&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;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&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;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&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="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&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;result&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;session&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same query. Same data. But now every row becomes a full Python object with SQLAlchemy's instrumentation attached:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tracking whether each attribute has changed&lt;/li&gt;
&lt;li&gt;Registering the object in the session's identity map, so &lt;code&gt;session.get(Order, id)&lt;/code&gt; returns the same instance&lt;/li&gt;
&lt;li&gt;Setting up lazy-loading machinery for any relationships&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Benchmark: What Each Layer Actually Costs
&lt;/h2&gt;

&lt;p&gt;I ran this on a table with 100,000 rows, fetching 500 rows matching a filter, on a local PostgreSQL instance to eliminate network variance:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The jump from Core to ORM is the one that matters.&lt;/strong&gt; Going from raw asyncpg to SQLAlchemy Core costs you a small, reasonable overhead for a massive gain in composability and safety. Going from Core to full ORM costs you almost &lt;strong&gt;3x more&lt;/strong&gt; on top of that, for object mapping you may not even need.&lt;/p&gt;

&lt;p&gt;This matches what other benchmarks in the Python community have found: SQLAlchemy's ORM layer with async drivers can be an order of magnitude slower than raw driver access for simple read-heavy queries, primarily because of &lt;strong&gt;Python-level object construction overhead&lt;/strong&gt;, not the database round trip itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the ORM Overhead Is So High With Async
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprised me most. You'd expect async to at least &lt;em&gt;match&lt;/em&gt; sync ORM performance, since the actual database call is non-blocking either way. But in some benchmarks, &lt;strong&gt;async ORM queries are actually slower than sync ORM queries&lt;/strong&gt; for CPU-bound work like object instantiation.&lt;/p&gt;

&lt;p&gt;The reason: async adds its own overhead (event loop scheduling, coroutine creation, awaiting each step of the driver protocol) on top of the ORM's existing object-mapping cost.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;strong&gt;I/O-bound work&lt;/strong&gt; (waiting on the network), async wins because the event loop can do other things while waiting.&lt;/li&gt;
&lt;li&gt;But &lt;strong&gt;object instantiation isn't I/O-bound.&lt;/strong&gt; It's pure CPU work happening on the same thread, and async adds bookkeeping around it without adding parallelism, because Python's GIL means you're not getting extra CPU time from asyncio. You're just avoiding blocking waits.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;So the ORM's object-mapping tax is paid in full, and the async machinery adds a smaller additional tax on top&lt;/strong&gt;, for operations that were never going to benefit from concurrency in the first place.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  When Each Layer Is the Right Choice
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Use raw asyncpg when...
&lt;/h3&gt;

&lt;p&gt;You're building a narrow, performance-critical service: a data pipeline, an analytics endpoint, a service that streams large result sets. You control the SQL directly and don't need object mapping, relationships, or migrations tied to this code 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;# High-throughput analytics endpoint
&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;/api/analytics/daily-revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;daily_revenue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;asyncpg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_pool&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquire&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;rows&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;conn&lt;/span&gt;&lt;span class="p"&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;
            SELECT date_trunc(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;day&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, created_at) as day, SUM(total) as revenue
            FROM orders
            WHERE created_at &amp;gt;= NOW() - INTERVAL &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;30 days&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
            GROUP BY day
            ORDER BY day
        &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="nf"&gt;dict&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Use SQLAlchemy Core when...
&lt;/h3&gt;

&lt;p&gt;You want composable, injection-safe query building without the ORM's object overhead. This is the &lt;strong&gt;sweet spot for most read-heavy API endpoints&lt;/strong&gt; (list views, search, reporting) where you need dynamic filters but don't need to mutate and save objects back.&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;list_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderFilters&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# Returns dicts, not ORM objects
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;result.mappings().all()&lt;/code&gt; is the key detail.&lt;/strong&gt; It returns plain dictionaries instead of ORM entity objects, skipping the identity map and change-tracking machinery entirely while still using SQLAlchemy's query builder and connection pooling.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Use the full ORM when...
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You're doing &lt;strong&gt;writes with relationships&lt;/strong&gt; (creating an order with items in one transaction)&lt;/li&gt;
&lt;li&gt;You need the &lt;strong&gt;identity map&lt;/strong&gt; to prevent duplicate object instances across a request&lt;/li&gt;
&lt;li&gt;You're using SQLAlchemy's &lt;strong&gt;relationship loading&lt;/strong&gt; (&lt;code&gt;selectinload&lt;/code&gt;, &lt;code&gt;joinedload&lt;/code&gt;) to manage complex object graphs&lt;/li&gt;
&lt;li&gt;Your team's &lt;strong&gt;productivity gain&lt;/strong&gt; from the ORM's ergonomics outweighs the performance cost for that specific endpoint
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_order_with_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&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;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&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;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&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="nc"&gt;OrderItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product_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;quantity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quantity&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&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;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&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;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&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;order&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly where the ORM earns its overhead: cascading inserts, relationship management, and object lifecycle tracking that would take significantly more raw SQL and manual bookkeeping to replicate correctly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Returns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw asyncpg&lt;/td&gt;
&lt;td&gt;Narrow, performance-critical paths, analytics, streaming&lt;/td&gt;
&lt;td&gt;Records / dicts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLAlchemy Core&lt;/td&gt;
&lt;td&gt;Read-heavy list, search and reporting endpoints&lt;/td&gt;
&lt;td&gt;Dicts via &lt;code&gt;.mappings()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full ORM&lt;/td&gt;
&lt;td&gt;Writes with relationships, complex object graphs&lt;/td&gt;
&lt;td&gt;Tracked ORM objects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Hybrid Pattern I Actually Use
&lt;/h2&gt;

&lt;p&gt;Most production FastAPI services benefit from using &lt;strong&gt;different layers for different endpoints&lt;/strong&gt;, all sharing the same async engine and connection pool:&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;# app/database.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;async_sessionmaker&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_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+asyncpg://user:pass@localhost/mydb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pool_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_overflow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pool_pre_ping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;AsyncSessionLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expire_on_commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;AsyncSessionLocal&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;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;

&lt;span class="c1"&gt;# Writes and relationship-heavy reads: full ORM
&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;/api/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderCreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;order&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;order_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Uses ORM
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;

&lt;span class="c1"&gt;# High-volume list endpoints: SQLAlchemy Core, dicts not objects
&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;/api/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OrderFilters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_order_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Core query, no ORM mapping
&lt;/span&gt;    &lt;span class="n"&gt;result&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;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="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Reporting and analytics: raw SQL via the same session
&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;/api/reports/summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;report_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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="nf"&gt;text&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 status, COUNT(*) as count, SUM(total) as revenue
        FROM orders GROUP BY status
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mappings&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three share the same &lt;code&gt;AsyncSession&lt;/code&gt; and connection pool. You're not managing separate drivers or separate pools. &lt;strong&gt;You're choosing, per endpoint, how much of the ORM's machinery you actually need.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The One Setting That Changes Everything: &lt;code&gt;expire_on_commit&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you do use the ORM, this default setting quietly causes extra queries you didn't ask for:&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;# Default behavior - expires all objects after commit
&lt;/span&gt;&lt;span class="n"&gt;async_session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&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;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Triggers a NEW query to refresh the expired object!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default, SQLAlchemy marks every object's attributes as "expired" after a commit, so the next time you access them, it silently re-fetches from the database to guarantee freshness.&lt;/p&gt;

&lt;p&gt;In a request-response API, where the session ends right after the commit, this is almost always wasted work. You're about to serialize the object and return it, not keep using it across a long-lived session.&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;# Disable expiration - trust the in-memory data after commit
&lt;/span&gt;&lt;span class="n"&gt;async_session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expire_on_commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;expire_on_commit=False&lt;/code&gt; is the single highest-leverage SQLAlchemy async setting for API workloads.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I set it on every FastAPI project now. Without it, a simple "create and return" endpoint fires a &lt;code&gt;COMMIT&lt;/code&gt; and then a silent &lt;code&gt;SELECT&lt;/code&gt; to re-fetch data you already had in memory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;Async doesn't erase the cost of the layers above the database driver. It just changes what kind of cost you're paying.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raw asyncpg&lt;/strong&gt; is fast because it does almost nothing beyond speaking PostgreSQL's protocol.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLAlchemy Core&lt;/strong&gt; adds a thin, worthwhile layer of safety and composability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The full ORM&lt;/strong&gt; adds real, measurable overhead in exchange for object mapping, relationship management, and identity tracking: features that are essential for some endpoints and pure cost for others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake isn't choosing SQLAlchemy's ORM. &lt;strong&gt;It's using it uniformly across every endpoint without asking whether that endpoint needs what it provides.&lt;/strong&gt; A list endpoint returning 500 rows of read-only data doesn't need an identity map. A checkout endpoint creating an order with five related line items absolutely does.&lt;/p&gt;

&lt;p&gt;That endpoint that went from 340ms to 890ms after our sync-to-async migration? We didn't revert to sync. We kept async, dropped the ORM for that specific read path, and switched to &lt;code&gt;result.mappings().all()&lt;/code&gt; with SQLAlchemy Core.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It came back down to 95ms&lt;/strong&gt;: faster than the original sync version, using the same connection pool, the same database, and about fifteen lines of changed code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Over to You
&lt;/h2&gt;

&lt;p&gt;What's your database access pattern in async Python? Full ORM everywhere, raw SQL everywhere, or a hybrid like this? And has anyone measured the async-vs-sync ORM overhead in their own benchmarks? I'd love to see numbers from a different workload than mine. Share in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>postgres</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Life of a Django Request</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Sat, 07 Nov 2026 06:08:11 +0000</pubDate>
      <link>https://dev.to/anas_issath/the-life-of-a-django-request-3j8n</link>
      <guid>https://dev.to/anas_issath/the-life-of-a-django-request-3j8n</guid>
      <description>&lt;p&gt;&lt;em&gt;A guided tour of everything that happens between someone clicking a link and your view function running. No traps, no gotchas, just how it actually works.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most articles about Django are about what goes wrong. This one isn't. &lt;strong&gt;This is a tour.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every time someone loads a page in your Django app, a small, elegant machine springs into motion. It unwraps the request, threads it through a stack of layers, finds the right view, runs your code, and packages the answer back up, usually in well under a tenth of a second.&lt;/p&gt;

&lt;p&gt;Most of us write views for years without ever watching that machine run. We know our view function gets called with a &lt;code&gt;request&lt;/code&gt; object, and we know we return a response, and the space between is a comfortable fog.&lt;/p&gt;

&lt;p&gt;Let's clear the fog. We're going to follow a single request from the instant it leaves a browser to the instant the response arrives back, and at each stop I'll show you what Django is actually doing and why.&lt;/p&gt;

&lt;p&gt;By the end, &lt;code&gt;request&lt;/code&gt; and &lt;code&gt;response&lt;/code&gt; won't be magic. They'll be things you understand from the inside. That understanding is quietly one of the most useful things you can have as a Django developer, because once you can see the machine, you know exactly where to reach when something needs explaining, debugging, or extending.&lt;/p&gt;

&lt;p&gt;Here's the whole journey at a glance, and then we'll walk it step by step:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser → Nginx → Gunicorn (WSGI) → Django handler
      → Middleware (in) → URL resolver → Your view (ORM + templates)
      → Middleware (out) → Gunicorn → Nginx → Browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Stop 1: The Browser and the Web Server
&lt;/h2&gt;

&lt;p&gt;It starts with a browser making an HTTP request (&lt;code&gt;GET /articles/42/&lt;/code&gt;) and sending it across the internet to your server. The first thing it hits isn't Django at all. It's a &lt;strong&gt;web server&lt;/strong&gt;, typically &lt;strong&gt;Nginx&lt;/strong&gt;, sitting at the front door.&lt;/p&gt;

&lt;p&gt;Nginx handles the things Django shouldn't be bothered with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Serving static files (CSS, images) directly&lt;/li&gt;
&lt;li&gt;Terminating SSL&lt;/li&gt;
&lt;li&gt;Load-balancing across multiple app processes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anything dynamic (anything that needs your code), Nginx hands the request off to an &lt;strong&gt;application server&lt;/strong&gt; like &lt;strong&gt;Gunicorn&lt;/strong&gt; or &lt;strong&gt;uWSGI&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Think of Nginx as the receptionist who deals with routine visitors themselves and only forwards the real business inward.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So already there are two programs involved before Django wakes up. This is worth knowing, because when people say "Django is slow to serve static files" or debug a deployment, the answer usually lives right here in the handoff between the web server and the app server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stop 2: WSGI, the Universal Handshake
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvdmtiij0ak5tvhb75t92.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvdmtiij0ak5tvhb75t92.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Gunicorn now has the request, but Gunicorn and Django are separate projects written by different people. How do they talk? Through a contract called &lt;strong&gt;WSGI&lt;/strong&gt; (Web Server Gateway Interface), and understanding this contract demystifies a huge amount.&lt;/p&gt;

&lt;p&gt;WSGI is dead simple. The server calls your application as a plain function with two arguments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;environ&lt;/code&gt;&lt;/strong&gt;: a dictionary holding everything about the request (the path, headers, method, all of it)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;start_response&lt;/code&gt;&lt;/strong&gt;: a callback for setting the status and headers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your application does its work and returns the response body as bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's the entire contract.&lt;/strong&gt; And it's why you can run Django under Gunicorn or uWSGI or others interchangeably. They all speak WSGI, so any of them can talk to any WSGI framework.&lt;/p&gt;

&lt;p&gt;Django's end of this handshake lives in a file you've seen and probably never opened: &lt;strong&gt;&lt;code&gt;wsgi.py&lt;/code&gt;&lt;/strong&gt;. That file exposes a &lt;code&gt;WSGIHandler&lt;/code&gt;, the object Gunicorn calls. This is the true front door of Django itself.&lt;/p&gt;

&lt;p&gt;(There's a newer async version of this contract called &lt;strong&gt;ASGI&lt;/strong&gt;, in &lt;code&gt;asgi.py&lt;/code&gt;, which is what makes websockets and async views possible. Same idea, built for concurrency. But the classic synchronous path is WSGI, and that's the one we're walking.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Stop 3: Django Wakes Up and Builds the Request Object
&lt;/h2&gt;

&lt;p&gt;Now Django's &lt;code&gt;WSGIHandler&lt;/code&gt; takes over, and the first meaningful thing it does is fire a signal: &lt;strong&gt;&lt;code&gt;request_started&lt;/code&gt;&lt;/strong&gt;. This is genuinely your earliest possible hook into the lifecycle. Code can subscribe to that signal to run the moment any request begins, before anything else happens.&lt;/p&gt;

&lt;p&gt;Then Django takes that raw &lt;code&gt;environ&lt;/code&gt; dictionary and wraps it into the object you know and love: the &lt;strong&gt;&lt;code&gt;HttpRequest&lt;/code&gt;&lt;/strong&gt;. This is the &lt;code&gt;request&lt;/code&gt; that gets passed to every view you've ever written: &lt;code&gt;request.method&lt;/code&gt;, &lt;code&gt;request.path&lt;/code&gt;, &lt;code&gt;request.GET&lt;/code&gt;, &lt;code&gt;request.user&lt;/code&gt;. It's not magic; it's a friendly Python object built from the raw WSGI environment.&lt;/p&gt;

&lt;p&gt;Here's a lovely detail most people never learn: &lt;strong&gt;parts of the request are lazy.&lt;/strong&gt; &lt;code&gt;request.body&lt;/code&gt;, &lt;code&gt;request.POST&lt;/code&gt;, and &lt;code&gt;request.FILES&lt;/code&gt; don't actually read the incoming data from the network socket until you access them.&lt;/p&gt;

&lt;p&gt;Django creates the request object instantly without reading the (potentially huge) body, and only pulls it in when your code asks. It's an elegant efficiency: why read a 10MB upload into memory if this particular view never looks at it?&lt;/p&gt;

&lt;p&gt;(One consequence: once you've read &lt;code&gt;request.body&lt;/code&gt;, you generally can't read it again. The stream's been consumed.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Stop 4: The Middleware Onion (Going In)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftf5xcmzo84hyws0am1xj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftf5xcmzo84hyws0am1xj.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the request begins its journey inward through the &lt;strong&gt;middleware stack&lt;/strong&gt;, and the onion is the perfect mental image. The request travels from the outside layer toward the center, and later the response travels back out through the same layers in reverse.&lt;/p&gt;

&lt;p&gt;Middleware is just a list in your &lt;code&gt;settings.py&lt;/code&gt;, and Django processes it &lt;strong&gt;top to bottom on the way in&lt;/strong&gt;. Each layer is a chance to inspect, modify, or even stop the request before it reaches your view. Walk the default stack and you'll recognize old friends, each doing one job:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Middleware&lt;/th&gt;
&lt;th&gt;What it does on the way in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SecurityMiddleware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Enforces HTTPS and security-related rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionMiddleware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Loads the user's session from the session store and attaches it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CommonMiddleware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Handles URL normalization and a few housekeeping rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CsrfViewMiddleware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checks the CSRF token on form submissions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AuthenticationMiddleware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Gives you &lt;code&gt;request.user&lt;/code&gt;&lt;/strong&gt;, by reading the session and looking up who's logged in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last one explains something you've used a thousand times: &lt;strong&gt;&lt;code&gt;request.user&lt;/code&gt; isn't conjured from nowhere.&lt;/strong&gt; A specific middleware put it there, on the way in, before your view ran. (And it's lazy too: &lt;code&gt;request.user&lt;/code&gt; is only actually fetched from the database the first time you touch it.)&lt;/p&gt;

&lt;p&gt;The power of the onion is that &lt;strong&gt;any layer can short-circuit the whole thing.&lt;/strong&gt; If &lt;code&gt;CsrfViewMiddleware&lt;/code&gt; sees a bad token, it returns a 403 right there. Your view never runs, and the response turns around and heads back out.&lt;/p&gt;

&lt;p&gt;Middleware is where cross-cutting concerns (security, sessions, auth) live so your views don't have to repeat them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stop 5: The URL Resolver Finds Your View
&lt;/h2&gt;

&lt;p&gt;The request has made it through middleware. Now Django needs to answer: &lt;em&gt;which view handles &lt;code&gt;/articles/42/&lt;/code&gt;?&lt;/em&gt; That's the job of the &lt;strong&gt;URL resolver&lt;/strong&gt; (the URL dispatcher), driven by your &lt;code&gt;urls.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Django takes the request path and tests it against your URL patterns, &lt;strong&gt;in order, top to bottom&lt;/strong&gt;, until one matches. When &lt;code&gt;path("articles/&amp;lt;int:article_id&amp;gt;/", views.article_detail)&lt;/code&gt; matches, two things come out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The view function to call&lt;/strong&gt; (&lt;code&gt;article_detail&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The captured arguments from the URL&lt;/strong&gt; (&lt;code&gt;article_id=42&lt;/code&gt;, already converted to an &lt;code&gt;int&lt;/code&gt; by the &lt;code&gt;&amp;lt;int:&amp;gt;&lt;/code&gt; converter)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A great under-the-hood detail: &lt;strong&gt;the resolver is cached.&lt;/strong&gt; Django builds the URL-pattern structure once and keeps it in memory rather than rebuilding it on every request. That's an important efficiency, since every single request needs it.&lt;/p&gt;

&lt;p&gt;This is also the quiet reason that &lt;strong&gt;changing your &lt;code&gt;urls.py&lt;/code&gt; requires a server restart in production&lt;/strong&gt;: the resolved URL configuration is cached at startup, so new patterns aren't picked up until the process reloads. If you've ever wondered why a URL change "didn't take" until you restarted, now you know exactly why.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stop 6: Your View Runs (the Part You Already Know)
&lt;/h2&gt;

&lt;p&gt;Here we are at the center of the onion: your code. Django calls your view with the request object and the captured URL arguments:&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;article_detail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;article_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;article_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ORM → SQL → database
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part you write every day, so I'll just point out the two systems it usually reaches for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The ORM.&lt;/strong&gt; &lt;code&gt;Article.objects.get(...)&lt;/code&gt; doesn't talk to the database directly. It builds a description of a query that the ORM translates into SQL, sends to the database, and turns the result rows back into Python &lt;code&gt;Article&lt;/code&gt; objects. (Lazy, as ever: the query runs when the data is actually needed.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The template engine.&lt;/strong&gt; &lt;code&gt;render(...)&lt;/code&gt; takes your template and the context dictionary, and the template engine produces the final HTML string, which gets wrapped in an &lt;code&gt;HttpResponse&lt;/code&gt; object.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail worth carrying with you, especially for class-based views: &lt;strong&gt;Django creates a new view instance for every request.&lt;/strong&gt; That's deliberate. It keeps requests isolated from each other.&lt;/p&gt;

&lt;p&gt;(It's also why a &lt;em&gt;mutable class attribute&lt;/em&gt; on a view is shared across all requests while &lt;em&gt;instance attributes&lt;/em&gt; aren't: a direct consequence of this one-instance-per-request design.)&lt;/p&gt;

&lt;p&gt;Your view's job ends the moment it returns a response object. Now that response begins its journey back out.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stop 7: Back Out Through the Middleware (in Reverse)
&lt;/h2&gt;

&lt;p&gt;The response now travels back out through the same middleware stack, but &lt;strong&gt;in reverse order, bottom to top.&lt;/strong&gt; This symmetry is the elegant heart of the onion: each middleware that got a look at the request on the way in gets a look at the response on the way out.&lt;/p&gt;

&lt;p&gt;And they use it. On the way out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SessionMiddleware&lt;/code&gt;&lt;/strong&gt; saves any changes you made to the session back to the session store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SecurityMiddleware&lt;/code&gt;&lt;/strong&gt; and others add security headers to the outgoing response (things like &lt;code&gt;X-Content-Type-Options&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other middleware&lt;/strong&gt; can compress the body, set cookies, or add their own headers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the response that leaves is richer than the one your view returned. Middleware decorated it on the way out with sessions, headers, and cookies.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The same layers, in mirror order: request going in, response coming out. That mirror is the whole design.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Stop 8: The Response Goes Home
&lt;/h2&gt;

&lt;p&gt;The finished &lt;code&gt;HttpResponse&lt;/code&gt; (status code, headers, and body) is handed back to the WSGI server (Gunicorn), which converts it into raw HTTP bytes and passes it to Nginx, which sends it across the internet to the browser, which renders the page. The machine goes quiet, waiting for the next request.&lt;/p&gt;

&lt;p&gt;The whole round trip happened in a fraction of a second:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browser to Nginx to Gunicorn to Django's handler&lt;/li&gt;
&lt;li&gt;In through middleware&lt;/li&gt;
&lt;li&gt;Resolve the URL&lt;/li&gt;
&lt;li&gt;Run the view, query the database, render the template&lt;/li&gt;
&lt;li&gt;Back out through middleware&lt;/li&gt;
&lt;li&gt;And home again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And now you can see every stop.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Knowing This Makes You Better at Django
&lt;/h2&gt;

&lt;p&gt;Here's the payoff, and it's real. Almost every Django concept that feels like "magic" is actually just &lt;strong&gt;one specific stop on this tour&lt;/strong&gt;, and once you can place it, it stops being mysterious:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The question&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where does &lt;code&gt;request.user&lt;/code&gt; come from?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AuthenticationMiddleware&lt;/code&gt;, on the way in (Stop 4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why do my security headers appear even though my view didn't add them?&lt;/td&gt;
&lt;td&gt;Middleware, on the way out (Stop 7)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why did my URL change not work until I restarted?&lt;/td&gt;
&lt;td&gt;The cached URL resolver (Stop 5)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where should I put code that runs on every request?&lt;/td&gt;
&lt;td&gt;Middleware, or the &lt;code&gt;request_started&lt;/code&gt; signal (Stops 3–4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why is a class attribute on my view shared between users?&lt;/td&gt;
&lt;td&gt;One view instance per request (Stop 6)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How can I run Django under Gunicorn or uWSGI?&lt;/td&gt;
&lt;td&gt;They both speak WSGI (Stop 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these need memorizing once you hold the map. They all fall out of understanding the flow. &lt;strong&gt;That's the difference between using a framework and understanding it&lt;/strong&gt;, and it's the difference between guessing where a problem lives and knowing.&lt;/p&gt;

&lt;p&gt;Django's request/response cycle is, honestly, a beautiful piece of design. A raw network request enters, gets progressively wrapped and enriched into a friendly Python object, passes through orderly layers that each do one job, reaches your code at the calm center, and the answer flows back out through the same layers in mirror image.&lt;/p&gt;

&lt;p&gt;It's an onion, and now you've seen every ring of it. The next time you write &lt;code&gt;def my_view(request):&lt;/code&gt;, you'll know exactly what that request went through to reach you, and exactly where it's going next.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A request hits a web server (Nginx) first.&lt;/strong&gt; It serves static files and forwards dynamic requests to an app server (Gunicorn/uWSGI).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gunicorn and Django talk through WSGI&lt;/strong&gt;, a tiny contract (&lt;code&gt;environ&lt;/code&gt; dict in, response bytes out), which is why any WSGI server can run any WSGI framework. Django's entry point is &lt;code&gt;wsgi.py&lt;/code&gt; (async apps use &lt;code&gt;asgi.py&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Django fires the &lt;code&gt;request_started&lt;/code&gt; signal&lt;/strong&gt;, then wraps the raw &lt;code&gt;environ&lt;/code&gt; into the &lt;code&gt;HttpRequest&lt;/code&gt; you know, with &lt;code&gt;body&lt;/code&gt;/&lt;code&gt;POST&lt;/code&gt;/&lt;code&gt;FILES&lt;/code&gt; read lazily, only when accessed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The request travels inward through middleware (top→bottom):&lt;/strong&gt; &lt;code&gt;SecurityMiddleware&lt;/code&gt;, &lt;code&gt;SessionMiddleware&lt;/code&gt;, &lt;code&gt;AuthenticationMiddleware&lt;/code&gt; (this is what sets &lt;code&gt;request.user&lt;/code&gt;), etc. Any layer can short-circuit and return early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The URL resolver matches the path against &lt;code&gt;urls.py&lt;/code&gt; (top→bottom)&lt;/strong&gt; and extracts view + arguments. It's cached, which is why URL changes need a restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your view runs&lt;/strong&gt; (a new instance per request for CBVs), uses the ORM and template engine, and returns an &lt;code&gt;HttpResponse&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The response travels back out through middleware in reverse (bottom→top)&lt;/strong&gt;, saving the session and adding security headers, then back through WSGI → Nginx → browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nearly every "magic" Django behavior is just one stop on this tour.&lt;/strong&gt; Knowing the map turns mysteries into locations.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Enjoyed the tour? The best way to make it stick is to open your own project's &lt;code&gt;settings.py&lt;/code&gt;, read your &lt;code&gt;MIDDLEWARE&lt;/code&gt; list top to bottom, and trace one real URL from &lt;code&gt;urls.py&lt;/code&gt; to its view. You'll never see &lt;code&gt;def my_view(request):&lt;/code&gt; the same way again.&lt;/p&gt;

&lt;p&gt;Which stop on the tour surprised you most? Tell me in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Tiny Django QuerySet to Finally Understand How .filter() Works</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Fri, 06 Nov 2026 17:30:06 +0000</pubDate>
      <link>https://dev.to/anas_issath/i-built-a-tiny-django-queryset-to-finally-understand-how-filter-works-d8f</link>
      <guid>https://dev.to/anas_issath/i-built-a-tiny-django-queryset-to-finally-understand-how-filter-works-d8f</guid>
      <description>&lt;p&gt;&lt;em&gt;Django's QuerySet is "lazy" and "chainable" and everyone repeats those words, so let's build a working one in about 50 lines and watch the magic disappear.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You've written &lt;code&gt;Book.objects.filter(published=True).order_by("-created_at")[:10]&lt;/code&gt; a thousand times. You know it's "lazy." You know it's "chainable." You may have read that "nothing hits the database until you iterate."&lt;/p&gt;

&lt;p&gt;But if someone asked you to explain &lt;em&gt;how&lt;/em&gt; that actually works (how chaining &lt;code&gt;.filter()&lt;/code&gt; five times still produces one query, how the thing knows to wait), could you?&lt;/p&gt;

&lt;p&gt;Most of us couldn't, and that's fine, because you don't need to in order to use it. But there's a special kind of confidence that comes from understanding a tool from the inside. The fastest way to get there isn't to read the Django source (it's thousands of lines of edge cases). It's to &lt;strong&gt;build a tiny version yourself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So that's what we're going to do: a working, chainable, lazy QuerySet in about 50 lines of plain Python. No Django, no database, just the ideas. By the end, the "magic" will just be code you wrote.&lt;/p&gt;

&lt;p&gt;We'll build it in four small steps, each solving the problem the last one revealed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: The Naive Version (and Why It's Wrong)
&lt;/h2&gt;

&lt;p&gt;Let's start with the most obvious way to write a "filter my data" object, so we can feel exactly what's missing. Imagine our "database" is just a list of dicts:&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;BOOKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dune&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;published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1965&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;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Drafty&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;published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2020&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;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Foundation&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;published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1951&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NaiveQuery&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;data&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# immediately loop and keep matching rows
&lt;/span&gt;        &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&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;data&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;NaiveQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BOOKS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;published&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# → [{'title': 'Dune', ...}, {'title': 'Foundation', ...}]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works! But look at what it forces.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It can't chain.&lt;/strong&gt; Because &lt;code&gt;filter()&lt;/code&gt; immediately returns a plain list, &lt;code&gt;NaiveQuery(BOOKS).filter(published=True).filter(year=1965)&lt;/code&gt; blows up, because a list has no &lt;code&gt;.filter()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's eager.&lt;/strong&gt; It does the full scan the instant you call &lt;code&gt;filter&lt;/code&gt;, even if you only wanted to know "are there any?" or "how many?". If this were a real database, every &lt;code&gt;.filter()&lt;/code&gt; would be a separate expensive query.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those two problems, &lt;strong&gt;can't chain&lt;/strong&gt; and &lt;strong&gt;runs immediately&lt;/strong&gt;, are exactly the two things Django's design solves. Let's solve them too.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Make It Chainable (Return a New Object, Don't Execute)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmp1iw5pgno4jgma6zvu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmp1iw5pgno4jgma6zvu.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the key insight that unlocks chaining, and it's the same one Django uses:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;filter()&lt;/code&gt; shouldn't return results. It should return a new query object that remembers the filter and hasn't run anything yet.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So instead of storing the data and filtering it, our query object stores the &lt;strong&gt;list of conditions&lt;/strong&gt; to eventually apply:&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;Query&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;      &lt;span class="c1"&gt;# a list of conditions, not results
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# DON'T run anything. Return a NEW Query with this filter added on.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&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;filters&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what changed. &lt;code&gt;filter()&lt;/code&gt; now returns &lt;code&gt;Query(...)&lt;/code&gt; (another &lt;code&gt;Query&lt;/code&gt; object) with the new condition appended to the list. It touches no data. And because it returns a &lt;code&gt;Query&lt;/code&gt;, that returned object &lt;em&gt;also&lt;/em&gt; has a &lt;code&gt;.filter()&lt;/code&gt; method, so you can call it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BOOKS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;published&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="nf"&gt;filter&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="mi"&gt;1965&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# q is a Query holding filters=[{'published': True}, {'year': 1965}]
# ...and still NOTHING has been computed.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly Django's real behavior, and the real source looks almost identical. Every &lt;code&gt;.filter()&lt;/code&gt; calls &lt;code&gt;_clone()&lt;/code&gt; to make a new QuerySet, adds the condition to it, and returns it.&lt;/p&gt;

&lt;p&gt;Django calls this being &lt;strong&gt;immutable through cloning&lt;/strong&gt;: your original query is never modified; each method hands back a fresh copy with one more instruction. That's why you can safely build a base queryset and branch off it in different directions without them interfering.&lt;/p&gt;

&lt;p&gt;We can chain now. But we still haven't solved &lt;em&gt;"when does it actually run?"&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Make It Lazy (Run Only When Something Asks for Results)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu10242medkmxuj60dg95.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu10242medkmxuj60dg95.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the heart of it. Our &lt;code&gt;Query&lt;/code&gt; is now a &lt;strong&gt;recipe&lt;/strong&gt;: a stored list of instructions that hasn't been executed. We want it to execute only when someone actually tries to &lt;em&gt;read&lt;/em&gt; the results: loops over it, lists it, counts it.&lt;/p&gt;

&lt;p&gt;In Python, "someone loops over this object" has a specific hook: the &lt;strong&gt;&lt;code&gt;__iter__&lt;/code&gt;&lt;/strong&gt; method. That's our trigger.&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;Query&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;          &lt;span class="c1"&gt;# results live here AFTER we run
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&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;filters&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;conditions&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;_execute&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="c1"&gt;# apply every stored filter, in order - this is the "expensive" part
&lt;/span&gt;        &lt;span class="n"&gt;results&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;data&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="ow"&gt;in&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;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__iter__&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="c1"&gt;# THE TRIGGER: only now do we actually run the query
&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;_cache&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;# ...and only once
&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;_cache&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="nf"&gt;_execute&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;iter&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;_cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now watch the behavior fall into place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BOOKS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;published&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# nothing runs - just a recipe
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;built the query, DB untouched&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# prints; still nothing ran
&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                            &lt;span class="c1"&gt;# NOW __iter__ fires → _execute() runs
&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;book&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                  &lt;span class="c1"&gt;# Dune, Foundation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Building the query ran nothing. The work happened only when we looped. &lt;strong&gt;That is lazy evaluation&lt;/strong&gt;, and it's the entire reason chaining five &lt;code&gt;.filter()&lt;/code&gt; calls doesn't mean five database trips. None of them execute; they just stack instructions onto the recipe, and the one execution happens at the end when you consume it.&lt;/p&gt;

&lt;p&gt;And notice &lt;code&gt;self._cache&lt;/code&gt;. The first time you iterate, we run &lt;code&gt;_execute()&lt;/code&gt; and store the results. The second time you iterate the &lt;em&gt;same object&lt;/em&gt;, &lt;code&gt;self._cache&lt;/code&gt; is already filled, so we skip execution and reuse it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is Django's result cache&lt;/strong&gt;, the exact reason that iterating the same queryset variable twice only queries once. We just built it, in three lines.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6l9u5f4lihen57guw8tw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6l9u5f4lihen57guw8tw.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Django's real &lt;code&gt;__iter__&lt;/code&gt; is remarkably close to ours. It calls &lt;code&gt;_fetch_all()&lt;/code&gt;, which checks &lt;em&gt;"is &lt;code&gt;_result_cache&lt;/code&gt; None? If so, run the query and fill it,"&lt;/em&gt; then hands back the cached list. Same idea, same shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Add the Terminal Methods (&lt;code&gt;count&lt;/code&gt;, &lt;code&gt;exists&lt;/code&gt;, &lt;code&gt;first&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The last piece: the convenience methods that &lt;em&gt;consume&lt;/em&gt; the query. Now that iteration is our trigger, these are almost trivial to write. They just use the results:&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;# inside class Query:
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;       &lt;span class="c1"&gt;# list(self) triggers __iter__
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;exists&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;first&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of these forces evaluation by consuming the query, then answers its narrow question.&lt;/p&gt;

&lt;p&gt;And here's a subtlety worth pausing on that mirrors real Django. In our simple version, &lt;code&gt;list(self)&lt;/code&gt; fills the cache, so calling &lt;code&gt;.count()&lt;/code&gt; and then looping is efficient. In real Django, &lt;code&gt;.count()&lt;/code&gt; and &lt;code&gt;.exists()&lt;/code&gt; are actually smarter: they send &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; and &lt;code&gt;SELECT 1&lt;/code&gt; to the database instead of loading every row.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's why you should prefer &lt;strong&gt;&lt;code&gt;.exists()&lt;/code&gt;&lt;/strong&gt; over &lt;code&gt;if queryset:&lt;/code&gt; when you only need a yes/no.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the shape is the same: &lt;strong&gt;terminal methods are the things that break the laziness and make the query finally run.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Whole Thing, ~50 Lines
&lt;/h2&gt;

&lt;p&gt;Here's our complete mini-QuerySet. Read it top to bottom. There's no magic left in it, because you built every line:&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;Query&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filters&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# chainable + lazy: return a NEW query, run nothing
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&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;filters&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;conditions&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;_execute&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;results&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;data&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="ow"&gt;in&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;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__iter__&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="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;_cache&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;# run once...
&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;_cache&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="nf"&gt;_execute&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;iter&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;_cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# ...then reuse the cache
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;exists&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;first&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="c1"&gt;# our "manager":
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Manager&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;data&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&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;filter&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Manager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BOOKS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it behaves like the real thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;published&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# lazy - nothing ran
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nothing executed yet&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;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                        &lt;span class="c1"&gt;# runs once, caches
&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;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;              &lt;span class="c1"&gt;# Dune, Foundation
&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;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;                   &lt;span class="c1"&gt;# reuses the cache - no re-run
&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;books&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1965&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# Dune
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Manager&lt;/code&gt; at the bottom is the last piece of the puzzle. It's why you write &lt;code&gt;Book.objects.filter(...)&lt;/code&gt; and not &lt;code&gt;Query(Book).filter(...)&lt;/code&gt;. In Django, &lt;code&gt;.objects&lt;/code&gt; is a Manager, and its job is exactly what ours does: hand you a fresh QuerySet to start chaining from. Every &lt;code&gt;Book.objects.filter(...)&lt;/code&gt; starts a brand-new recipe.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Can Now See That You Couldn't Before
&lt;/h2&gt;

&lt;p&gt;You just built, in miniature, the real architecture of Django's ORM. And a bunch of things that were "just how it works" now have obvious mechanical explanations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Why doesn't &lt;code&gt;Book.objects.filter(...)&lt;/code&gt; hit the database?&lt;/td&gt;
&lt;td&gt;Like our &lt;code&gt;filter()&lt;/code&gt;, it returns a new object holding instructions and runs nothing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why can I chain &lt;code&gt;.filter().exclude().order_by()&lt;/code&gt; endlessly with one query?&lt;/td&gt;
&lt;td&gt;Each one just appends to the recipe and returns a new query; execution happens once, at the end.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why does iterating the same queryset twice only query once?&lt;/td&gt;
&lt;td&gt;The result cache: our &lt;code&gt;self._cache&lt;/code&gt;, Django's &lt;code&gt;_result_cache&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why is a fresh &lt;code&gt;Model.objects.filter(...)&lt;/code&gt; a separate query from another one?&lt;/td&gt;
&lt;td&gt;Different Query objects, different caches, exactly like calling our &lt;code&gt;Manager.filter()&lt;/code&gt; twice.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why does &lt;code&gt;.exists()&lt;/code&gt; exist when I could just do &lt;code&gt;if qs:&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;A purpose-built terminal method can ask the database a cheaper question than "load everything."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of this is memorization anymore. It falls out of the shape:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A query object stores instructions, stays lazy by returning new copies of itself, and only runs (once, then cached) when something consumes it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one sentence is the whole ORM, and now you've felt it from the inside.&lt;/p&gt;

&lt;p&gt;The real Django QuerySet is, of course, vastly more. It compiles these instructions into actual SQL across different database backends, handles joins and annotations and prefetching, and manages a hundred edge cases we happily ignored. But the soul of it (lazy, chainable, cloned-not-mutated, cached-after-execution) is exactly the fifty lines you just wrote.&lt;/p&gt;

&lt;p&gt;The next time you chain a queryset, you won't see magic. You'll see the recipe being built, waiting patiently for you to ask for the meal.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fniqrxzjnjziuqm6b1ioq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fniqrxzjnjziuqm6b1ioq.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Django's QuerySet feels magical because it's lazy&lt;/strong&gt; (doesn't run until consumed) &lt;strong&gt;and chainable&lt;/strong&gt; (&lt;code&gt;.filter().exclude()...&lt;/code&gt;), but both are simple mechanics you can build yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chainable:&lt;/strong&gt; &lt;code&gt;filter()&lt;/code&gt; returns a &lt;em&gt;new&lt;/em&gt; query object holding the accumulated conditions and runs nothing. Django does the same via &lt;code&gt;_clone()&lt;/code&gt;. QuerySets are "immutable through cloning," which is why you can branch a base query safely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy:&lt;/strong&gt; execution is triggered by &lt;em&gt;consumption&lt;/em&gt;, Python's &lt;code&gt;__iter__&lt;/code&gt; hook. Building the query runs nothing; looping/listing/counting is what finally executes it. That's why five chained &lt;code&gt;.filter()&lt;/code&gt;s = one query, not five.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result cache:&lt;/strong&gt; the first execution stores its results (&lt;code&gt;self._cache&lt;/code&gt; in ours, &lt;code&gt;_result_cache&lt;/code&gt; in Django), so iterating the same queryset again reuses them instead of re-querying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal methods&lt;/strong&gt; (&lt;code&gt;count&lt;/code&gt;, &lt;code&gt;exists&lt;/code&gt;, &lt;code&gt;first&lt;/code&gt;) are the things that consume the query and break the laziness; real Django's &lt;code&gt;.count()&lt;/code&gt;/&lt;code&gt;.exists()&lt;/code&gt; send cheaper SQL than loading every row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.objects&lt;/code&gt; is a Manager.&lt;/strong&gt; Its only job is to hand you a fresh QuerySet to start chaining from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the ~50-line version once&lt;/strong&gt; and the real ORM's behavior stops being magic. It's just "store instructions, clone to stay lazy, execute once when consumed."&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;The best way to make this stick: paste the 50-line version into a file, &lt;strong&gt;add an &lt;code&gt;order_by&lt;/code&gt; method yourself&lt;/strong&gt; (hint: it also just appends to the recipe), and watch it slot right in. Understanding beats memorizing, and building beats reading.&lt;/p&gt;

&lt;p&gt;Got your &lt;code&gt;order_by&lt;/code&gt; working? Share your version in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>tutorial</category>
      <category>orm</category>
    </item>
    <item>
      <title>Six Things Django Developers Say That Aren't True in 2026</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Thu, 05 Nov 2026 11:40:53 +0000</pubDate>
      <link>https://dev.to/anas_issath/six-things-django-developers-say-that-arent-true-in-2026-oan</link>
      <guid>https://dev.to/anas_issath/six-things-django-developers-say-that-arent-true-in-2026-oan</guid>
      <description>&lt;p&gt;&lt;em&gt;The recycled criticisms that sound smart, get repeated everywhere, and quietly stopped being accurate years ago.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every few months, someone declares Django dead.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's too slow." "It's not async." "It doesn't scale." "Everyone's on FastAPI now."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;These takes get repeated in Hacker News threads, Discord servers, and hallway conversations until they feel like common knowledge: the kind of thing you nod along to without checking.&lt;/p&gt;

&lt;p&gt;Here's the problem: &lt;strong&gt;most of them stopped being true years ago, and a few were never true to begin with.&lt;/strong&gt; Meanwhile, in 2026, Django quietly powers Instagram, Spotify, and Pinterest and serves millions of users for companies that never got the memo that it "doesn't scale."&lt;/p&gt;

&lt;p&gt;This isn't a "Django is perfect" article. It has real, honest limitations, and I'll point to them. It's a &lt;strong&gt;"let's check the claims against reality"&lt;/strong&gt; article. Because repeating an outdated criticism doesn't make you sound experienced; it makes you sound like you learned Django in 2015 and stopped paying attention.&lt;/p&gt;

&lt;p&gt;Let's go through the six most common ones, concede what's true in each, and correct what isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  Myth 1: "Django Doesn't Scale"
&lt;/h2&gt;

&lt;p&gt;This is the big one, and it's the easiest to debunk because the counterexamples are enormous and public. Instagram runs on Django at hundreds of millions of users. Disqus scaled Django to serve billions of comments. Spotify and Pinterest are on it. If Django "didn't scale," these companies would have been the first to find out, at the worst possible time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The grain of truth:&lt;/strong&gt; a naive Django app &lt;em&gt;does&lt;/em&gt; fall over under load, but not because of Django. It falls over because of N+1 queries, missing database indexes, blocking calls in the request cycle, and no caching. Those are architecture and code problems, and they'd sink an app in any framework.&lt;/p&gt;

&lt;p&gt;As one 2026 scaling write-up put it bluntly: scaling without measurement is superstition, and the real bottleneck is almost never the framework. It's poor query design, missing indexes, and unclear ownership.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reality:&lt;/strong&gt; Django is &lt;strong&gt;stateless by design&lt;/strong&gt;, which is exactly what makes it scale horizontally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add more Gunicorn workers or more instances behind a load balancer&lt;/li&gt;
&lt;li&gt;Cache expensive queries in Redis&lt;/li&gt;
&lt;li&gt;Push slow work to Celery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do that, and it scales to the moon. The "doesn't scale" argument is from 2015, and it was wrong then too. &lt;strong&gt;The framework was never the ceiling.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Myth 2: "Django Isn't Async, So It's Slow"
&lt;/h2&gt;

&lt;p&gt;This one sounds technical, which is why it spreads. And it contains a real fact wrapped around a wrong conclusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The grain of truth:&lt;/strong&gt; Django came to async late (async views in 3.1, ASGI support, and async ORM operations since 4.1), and its async story is genuinely still maturing. The ORM has sync roots, and there are sharp edges. If you're building something truly async-first with heavy concurrent I/O, that friction is real. (I've written about it. It's a legitimate pain point.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the logic breaks:&lt;/strong&gt; "not async" does not mean "slow." Here's the fact the benchmarks don't advertise: &lt;strong&gt;most web applications are I/O-bound, not CPU-bound&lt;/strong&gt;, and the bottleneck isn't the framework. It's your database queries, third-party API calls, and unoptimized code.&lt;/p&gt;

&lt;p&gt;Async helps when you're I/O-bound &lt;em&gt;and&lt;/em&gt; juggling many concurrent slow operations (external APIs, websockets, streaming). It does not magically make a normal CRUD endpoint faster. A well-architected Django app with proper caching, indexing, and query optimization will outperform a poorly written async app every single time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Async is a tool for a specific shape of problem, not a global speed setting&lt;/strong&gt;, and most apps don't have that shape.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Myth 3: "Django Can't Do APIs. Use FastAPI."
&lt;/h2&gt;

&lt;p&gt;Django "can't do APIs" would be news to the thousands of production APIs running on &lt;strong&gt;Django REST Framework&lt;/strong&gt;, which has been a mature, capable API toolkit for over a decade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The grain of truth:&lt;/strong&gt; for a pure, async-first, high-concurrency API microservice, FastAPI is often the better fit in 2026. It's async from the ground up, its Pydantic-based validation is lovely, and it benchmarks faster than DRF on async workloads. That's a real and fair distinction, and if that's your exact use case, reach for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reality:&lt;/strong&gt; &lt;em&gt;"Django can't do APIs"&lt;/em&gt; and &lt;em&gt;"FastAPI is better for some API workloads"&lt;/em&gt; are very different claims. Django does APIs extremely well, and in 2026 you're not even limited to DRF. &lt;strong&gt;Django Ninja&lt;/strong&gt; brings FastAPI's exact ergonomics (type hints, Pydantic v2, auto-generated docs, native async) into Django, so you can have the modern API feel without giving up the ORM, admin, auth, and migrations.&lt;/p&gt;

&lt;p&gt;The honest framing the community has settled on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Django for full products and admin-heavy SaaS, FastAPI for API-first microservices.&lt;/strong&gt; Both are correct choices, not a winner and a loser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Can't" is just wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  Myth 4: "Django Can't Do Microservices. It's a Monolith."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The grain of truth:&lt;/strong&gt; Django encourages a monolith, and its batteries-included design is optimized for building one cohesive application. That's a real default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the conclusion is wrong:&lt;/strong&gt; "encourages a monolith" is not "can't do microservices." You can absolutely build decoupled Django services that talk over APIs and message queues, with DRF, Docker, and Celery. Django is not the obstacle; the architecture is.&lt;/p&gt;

&lt;p&gt;And here's the part that actually matters in 2026: &lt;strong&gt;most applications don't need microservices in the first place.&lt;/strong&gt; Splitting prematurely buys you network complexity, debugging pain, and operational overhead in exchange for benefits you may never use.&lt;/p&gt;

&lt;p&gt;The guidance from people running Django at scale is to &lt;strong&gt;start as a modular monolith and stay one&lt;/strong&gt; until you have a concrete reason to split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Teams blocked by codebase coupling&lt;/li&gt;
&lt;li&gt;Deployment bottlenecks&lt;/li&gt;
&lt;li&gt;A domain that needs radically different scaling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A well-designed modular monolith survives astonishing scale. &lt;em&gt;"Django is a monolith"&lt;/em&gt; is a description, not an insult, and often it's describing the right choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Myth 5: "Batteries-Included Makes Django Bloated and Slow"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The grain of truth:&lt;/strong&gt; Django ships a lot, and it does carry two decades of history, including some dated modules you'll never use. If your project is genuinely tiny (a single-endpoint service), Django is more than you need, and a microframework fits better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's mostly wrong:&lt;/strong&gt; people call Django "too heavy" until they spend three days wiring up authentication, permissions, migrations, an admin interface, and CSRF protection in a lighter framework. Django gives you all of that on day one, tested and integrated.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Batteries included is a feature, not a liability"&lt;/em&gt; is the take that holds up. And &lt;strong&gt;bloat doesn't equal slow: the modules you don't use don't run.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Django's security posture alone earns its weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PBKDF2/bcrypt password hashing&lt;/li&gt;
&lt;li&gt;CSRF and XSS protections&lt;/li&gt;
&lt;li&gt;SQL-injection-safe ORM queries&lt;/li&gt;
&lt;li&gt;A framework that actively refuses to start with a dangerously weak config&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's not bloat; that's a mountain of correct decisions you didn't have to make or maintain yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Myth 6: "Real Developers Hand-Write SQL. The ORM Is for Beginners."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The grain of truth:&lt;/strong&gt; the ORM &lt;em&gt;can&lt;/em&gt; generate inefficient SQL, it hides the database in ways that cause N+1 problems, and for genuinely complex analytical queries, raw SQL is sometimes the right and clearer tool. A good Django developer can read the SQL the ORM produces and drops to raw SQL when it's warranted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the "for beginners" framing is backwards:&lt;/strong&gt; the ORM isn't training wheels you outgrow. It's a productivity and safety tool that experts use deliberately. It gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parameterized queries that prevent SQL injection by default&lt;/li&gt;
&lt;li&gt;Database-agnostic code&lt;/li&gt;
&lt;li&gt;Migrations&lt;/li&gt;
&lt;li&gt;Readable query composition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mark of an experienced Django developer isn't refusing the ORM. It's &lt;strong&gt;using it fluently for the 95% of queries where it's clearer and safer, and knowing exactly when to reach past it for the other 5%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Treating &lt;em&gt;"I hand-write all my SQL"&lt;/em&gt; as a badge of seniority usually signals the opposite: someone who hasn't learned to use the ORM well, or who's reintroducing injection risks and database lock-in for no benefit. Use the right tool; the ORM is right far more often than the myth admits.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pattern Underneath All Six
&lt;/h2&gt;

&lt;p&gt;Step back and notice something: nearly every one of these myths collapses into the same underlying truth.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The bottleneck is almost never Django. It's the architecture, the queries, and the code.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The myth says...&lt;/th&gt;
&lt;th&gt;What's actually happening&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Doesn't scale"&lt;/td&gt;
&lt;td&gt;Your N+1 queries don't scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Too slow"&lt;/td&gt;
&lt;td&gt;Your missing indexes are slow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Can't do APIs/microservices"&lt;/td&gt;
&lt;td&gt;Your design choices, not the framework&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Over and over, people blame the tool for what is, honestly, a design or discipline problem, because blaming the framework is easier than auditing your own queries. Django is a mirror here: a well-architected Django app is fast and scales enormously; a poorly-architected one falls over, exactly as a poorly-architected app in any framework would.&lt;/p&gt;

&lt;p&gt;None of this means Django is the right choice for everything. It genuinely isn't. If you're building a high-concurrency, async-first, real-time API service, FastAPI or an async-native stack is often the better call, and Django's async maturity is a real, honest limitation. That's a legitimate reason to pick something else.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Django is slow / dead / doesn't scale / can't do APIs"&lt;/em&gt; is not a legitimate reason. It's a 2015 opinion that reality stopped supporting years ago.&lt;/p&gt;

&lt;p&gt;So the useful move in 2026 isn't to defend Django or bury it. It's to &lt;strong&gt;stop repeating criticisms you haven't checked&lt;/strong&gt;, evaluate the actual workload in front of you, and pick the tool that fits it, knowing that for a huge range of real products, the boring, batteries-included, "doesn't scale" framework is quietly running some of the largest applications on the internet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Repeating the myth is easy. Checking it is what separates an engineer from a parrot.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Doesn't scale":&lt;/strong&gt; Instagram, Disqus, Pinterest, Spotify run on Django at massive scale. It's stateless and scales horizontally; what fails is N+1 queries and missing indexes, not the framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Not async, so slow":&lt;/strong&gt; most apps are I/O-bound; async only helps with many concurrent slow I/O operations, and a well-optimized sync app beats a badly-written async one. (Django's async maturity &lt;em&gt;is&lt;/em&gt; a real limitation for async-first workloads, though.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Can't do APIs":&lt;/strong&gt; DRF has done APIs well for a decade, and Django Ninja now offers FastAPI-style typed, async, auto-documented APIs inside Django. FastAPI is better for pure async microservices; that's a fit question, not a "can't."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Can't do microservices / it's a monolith":&lt;/strong&gt; you &lt;em&gt;can&lt;/em&gt; build Django services, but more importantly most apps &lt;em&gt;shouldn't&lt;/em&gt;. A modular monolith survives huge scale, and premature splitting adds cost for no benefit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Batteries-included = bloated/slow":&lt;/strong&gt; unused modules don't run; the built-in auth, admin, migrations, and security (PBKDF2, CSRF, injection-safe ORM) are a feature that saves you weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The ORM is for beginners":&lt;/strong&gt; experts use it deliberately for safety (parameterized queries) and speed of development, and drop to raw SQL only for the rare query that needs it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The through-line:&lt;/strong&gt; the bottleneck is almost never Django. It's your architecture and your queries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Pick your tools by fit, not by folklore. Django isn't right for everything, but &lt;em&gt;"it doesn't scale"&lt;/em&gt; hasn't been a real reason since before half the people repeating it started coding.&lt;/p&gt;

&lt;p&gt;Which Django myth do you hear most often? Or is there one on this list you think is actually still true? Tell me in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Adding a Database Server Doubled Capacity. One Index Multiplied It by 2,600.</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Wed, 04 Nov 2026 19:20:38 +0000</pubDate>
      <link>https://dev.to/anas_issath/adding-a-database-server-doubled-capacity-one-index-multiplied-it-by-2600-437g</link>
      <guid>https://dev.to/anas_issath/adding-a-database-server-doubled-capacity-one-index-multiplied-it-by-2600-437g</guid>
      <description>&lt;p&gt;&lt;em&gt;Measure first, then climb the ladder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The API is slow.&lt;/p&gt;

&lt;p&gt;The dashboard takes two seconds to load. Someone opens the database graph and sees CPU at 100%.&lt;/p&gt;

&lt;p&gt;Then the meeting starts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We need a read replica."&lt;br&gt;
"Should we add Redis?"&lt;br&gt;
"Maybe it's time to shard."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;All three sound like serious engineering. All three cost money, add moving parts, and stay with you for years.&lt;/p&gt;

&lt;p&gt;But nobody in that meeting has asked the first question: &lt;strong&gt;what is the database actually doing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nikki Siapno's recent Level Up Coding piece on scaling databases lays out a sensible order: measure the bottleneck, reduce the work, optimize queries, then add replicas, and shard last.&lt;/p&gt;

&lt;p&gt;So this article puts that order to the test. One table, one slow query, one load test, and real measurements at each step.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Test
&lt;/h2&gt;

&lt;p&gt;The setup is a small PostgreSQL lab that you can run yourself. The full script is at the end of this article.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL 16.15 on a 2-core Linux cloud machine&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;orders&lt;/code&gt; table with &lt;strong&gt;5 million rows&lt;/strong&gt; and 100,000 customers&lt;/li&gt;
&lt;li&gt;One common query: the 20 latest orders for a customer&lt;/li&gt;
&lt;li&gt;8 clients sending that query as fast as they can for 30 seconds&lt;/li&gt;
&lt;li&gt;Each step repeated 3 times, reporting the median&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the query:&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;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;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4242&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;created_at&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;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks harmless. You probably have one like it in your code right now.&lt;/p&gt;

&lt;p&gt;To keep the comparison fair, the primary database was pinned to one CPU core. The second core ran the load generator, or the replica in the replica test.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy02blq4fa1diohx6h0z8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy02blq4fa1diohx6h0z8.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read that table twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The replica worked.&lt;/strong&gt; Capacity went up about 2.2 times, which is close to what a second core should give.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;one &lt;code&gt;CREATE INDEX&lt;/code&gt; on the original server handled about 2,600 times the traffic.&lt;/strong&gt; It beat the two-server setup by about 1,180 times.&lt;/p&gt;

&lt;p&gt;The index took 4.8 seconds to build and used 150 MB of disk.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Measure the Bottleneck
&lt;/h2&gt;

&lt;p&gt;Before touching anything, ask PostgreSQL where the time goes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;pg_stat_statements&lt;/code&gt; extension records every query shape, how often it runs, and how long it takes. It must be added to &lt;code&gt;shared_preload_libraries&lt;/code&gt;, which needs a restart.&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;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;avg_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;total_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;left&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="mi"&gt;70&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;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&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;total_exec_time&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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Sort by total time, not average time.&lt;/strong&gt; A 5 ms query that runs a million times hurts more than a 2 second report that runs once a day.&lt;/p&gt;

&lt;p&gt;Next, look at the plan of your worst query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;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;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4242&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;created_at&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;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the real output from the test, shortened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit (actual time=224.516..224.520 rows=20)
  Buffers: shared hit=66670
  -&amp;gt; Sort
       -&amp;gt; Seq Scan on orders
            Filter: (customer_id = 4242)
            Rows Removed by Filter: 4999944
Execution Time: 245.630 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the whole problem in one line: &lt;strong&gt;&lt;code&gt;Rows Removed by Filter: 4999944&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;To find 56 rows for one customer, PostgreSQL read all 5 million. Think of a librarian who walks past every book in the building each time someone asks for one author.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No amount of extra hardware changes what this query is doing.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Different symptoms point to different problems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Look at first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High CPU, slow queries&lt;/td&gt;
&lt;td&gt;Query plans, missing indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many rows read, few returned&lt;/td&gt;
&lt;td&gt;Indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same query running repeatedly&lt;/td&gt;
&lt;td&gt;N+1 queries, caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Too many connections&lt;/td&gt;
&lt;td&gt;Connection pooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow only at peak, plans are ok&lt;/td&gt;
&lt;td&gt;Hardware or replicas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast database, slow API&lt;/td&gt;
&lt;td&gt;The app or another service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow writes, heavy disk use&lt;/td&gt;
&lt;td&gt;Write volume, too many indexes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These two queries help with the first pass:&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;-- Tables being read the hard way&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq_tup_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;seq_tup_read&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seq_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rows_per_scan&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&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;seq_tup_read&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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Connection pressure&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&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;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'idle in transaction'&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;idle_in_txn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'max_connections'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;max&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_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: The Replica Added Capacity, Not Speed
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fehzdp1yhh62p6tu752m1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fehzdp1yhh62p6tu752m1.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the step many teams take first, so it deserves a fair look.&lt;/p&gt;

&lt;p&gt;With a replica, the same 8 clients were split between two servers. Throughput doubled and average latency fell from 2,020 ms to 926 ms.&lt;/p&gt;

&lt;p&gt;That looks like a fix. &lt;strong&gt;It isn't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With no load, the query took 246 ms on the primary and 304 ms on the replica. The plan on the replica was identical: same sequential scan, same 4,999,944 rows thrown away.&lt;/p&gt;

&lt;p&gt;So where did the latency gain come from?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queueing.&lt;/strong&gt; With 8 clients on one core, each request spent most of its time waiting behind other requests. A second server shortened the line. The checkout was just as slow.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A replica shortens the queue. It does not make the work smaller.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The replica also brings bills of its own: another server to pay for and monitor, and one more problem that the next section measures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: The Replica Problem Nobody Sees in Testing
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvh5df38vc4mh5gnrbb3i.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvh5df38vc4mh5gnrbb3i.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A read replica copies changes from the primary a moment after they commit. That moment is called &lt;strong&gt;replication lag&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is the test. Insert a row on the primary, wait for the commit, then read it from the replica straight away. Do that 2,000 times.&lt;/p&gt;

&lt;p&gt;This was the best possible case: both servers on the same machine, no network between them, and almost no load.&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;Idle&lt;/th&gt;
&lt;th&gt;Under write load&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writes tested&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row missing on first read&lt;/td&gt;
&lt;td&gt;1,973&lt;/td&gt;
&lt;td&gt;1,473&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median catch-up&lt;/td&gt;
&lt;td&gt;0.33 ms&lt;/td&gt;
&lt;td&gt;0.53 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worst catch-up&lt;/td&gt;
&lt;td&gt;18.44 ms&lt;/td&gt;
&lt;td&gt;41.12 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;98.65% of immediate reads missed the row on an idle system.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The replica was only 0.33 ms behind. That was still slower than the next read.&lt;/p&gt;

&lt;p&gt;Don't read too much into the idle and load numbers being in that order. The point is that &lt;em&gt;"very low lag"&lt;/em&gt; and &lt;em&gt;"no stale reads"&lt;/em&gt; are different things. Across a real network, lag will be higher.&lt;/p&gt;

&lt;p&gt;Your users meet this as a strange bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user updates their profile.&lt;/li&gt;
&lt;li&gt;The app redirects to the profile page.&lt;/li&gt;
&lt;li&gt;The page shows the old name.&lt;/li&gt;
&lt;li&gt;The user refreshes, and now it's correct.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is to &lt;strong&gt;read from the primary right after a write&lt;/strong&gt;. Here is a simple Django router that does this for the rest of a request:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PrimaryAfterWriteRouter&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;db_for_read&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;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;hints&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;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrote&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replica&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;db_for_write&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;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;hints&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;wrote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allow_relation&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;obj1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;obj2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;hints&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allow_migrate&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;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;hints&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;db&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;primary_after_write_middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_response&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;middleware&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;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wrote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;get_response&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;finally&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;wrote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the class to &lt;code&gt;DATABASE_ROUTERS&lt;/code&gt; and the middleware to &lt;code&gt;MIDDLEWARE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This covers one request. In the profile example, the redirect is a &lt;em&gt;second&lt;/em&gt; request, so you also need something that carries over, such as a short-lived cookie or session flag that keeps that user on the primary for a few seconds.&lt;/p&gt;

&lt;p&gt;Also keep money reads on the primary: &lt;strong&gt;balances, stock counts, permissions, and anything a decision depends on.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Failure This Lab Hit by Accident
&lt;/h2&gt;

&lt;p&gt;The first run of this test never finished.&lt;/p&gt;

&lt;p&gt;During the write-heavy stage, the replica fell behind. PostgreSQL's primary keeps only a limited amount of old WAL (its change log), and it recycled the segment the replica still needed. The replica could never catch up, and the primary's log filled with this line every few seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: requested WAL segment 00000001000000000000004C has already been removed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing crashed. The replica stayed up and kept answering queries, while its data froze in the past. The test sat waiting for a row that would never arrive until it was stopped.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is the scariest kind of failure: a healthy-looking server serving old data.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix was a &lt;strong&gt;replication slot&lt;/strong&gt;, which makes the primary keep WAL until the replica has received it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_basebackup &lt;span class="nt"&gt;-h&lt;/span&gt; primary &lt;span class="nt"&gt;-D&lt;/span&gt; /data/replica &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; stream &lt;span class="nt"&gt;-c&lt;/span&gt; fast &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; lab_slot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slots have their own danger. If a replica dies and the slot stays, the primary keeps WAL forever and can fill its disk. Set &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; as a limit, and watch both of these:&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;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;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;restart_lsn&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;wal_held&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;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;application_name&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;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;replay_lsn&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;behind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;replay_lag&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_replication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a replica is missing from the second query, it has stopped replicating. &lt;strong&gt;Alert on that.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Cache Only What Repeats
&lt;/h2&gt;

&lt;p&gt;Redis is the next suggestion in most of these meetings, so the lab tested it on the slow, unindexed query.&lt;/p&gt;

&lt;p&gt;The code is the standard cache-aside pattern: check Redis, and on a miss, query PostgreSQL and store the result for 5 minutes. One client ran for 60 seconds with three traffic patterns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Traffic pattern&lt;/th&gt;
&lt;th&gt;Hit rate&lt;/th&gt;
&lt;th&gt;Average&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All customers equally&lt;/td&gt;
&lt;td&gt;0.4%&lt;/td&gt;
&lt;td&gt;218 ms&lt;/td&gt;
&lt;td&gt;210 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90% from 1,000 customers&lt;/td&gt;
&lt;td&gt;9.8%&lt;/td&gt;
&lt;td&gt;197 ms&lt;/td&gt;
&lt;td&gt;209 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90% from 100 customers&lt;/td&gt;
&lt;td&gt;84.7%&lt;/td&gt;
&lt;td&gt;33 ms&lt;/td&gt;
&lt;td&gt;0.08 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is what people imagine when they say &lt;em&gt;"add a cache."&lt;/em&gt; The first two rows are what can happen instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cache did not fail. The traffic did not repeat enough.&lt;/strong&gt; With 100,000 customers and about 5 slow queries a second, most keys were requested once and never seen again before the test ended.&lt;/p&gt;

&lt;p&gt;Even in the best case, the misses stayed painful. The 95th percentile was still 219 ms, because every miss ran the same full table scan.&lt;/p&gt;

&lt;p&gt;Compare that with the plain index: under 1 ms on average, even with 8 clients at full speed, with no hit rate to worry about and no invalidation to get wrong.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A cache makes a good query cheaper. It is a poor way to hide a bad one.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So before you add one, ask these questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do the same keys get read again and again?&lt;/li&gt;
&lt;li&gt;How stale can this data be?&lt;/li&gt;
&lt;li&gt;Who invalidates it when the data changes?&lt;/li&gt;
&lt;li&gt;What happens to the database if Redis restarts and every request misses?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is a real risk. OpenAI's write-up, covered below, describes using cache locking and leasing so that only one reader fetches a missed key from PostgreSQL.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: The Index, and Its Bill
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxqb3ip27khm9s5gic1w.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxqb3ip27khm9s5gic1w.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the plan after one index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;orders_customer_created&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Limit (actual time=0.034..0.205 rows=20)
  Buffers: shared hit=23
  -&amp;gt; Index Scan using orders_customer_created on orders
       Index Cond: (customer_id = 4242)
Execution Time: 0.228 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From 66,670 buffers to 23. From 245.6 ms to 0.23 ms.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The column order matters. &lt;code&gt;customer_id&lt;/code&gt; narrows down to one customer, and &lt;code&gt;created_at DESC&lt;/code&gt; means the rows are already sorted, so PostgreSQL can stop after 20. The sort step disappears.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;CONCURRENTLY&lt;/code&gt; on a live table. A plain &lt;code&gt;CREATE INDEX&lt;/code&gt; blocks writes while it builds. In Django, the safe version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.postgres.operations&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AddIndexConcurrently&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;atomic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="n"&gt;dependencies&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;orders&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;0041_previous&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

    &lt;span class="n"&gt;operations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;AddIndexConcurrently&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_customer_created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a concurrent build fails, it leaves an invalid index behind. Drop it and try again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indexes are not free, though.&lt;/strong&gt; The lab measured insert speed on a 1 million row table with no indexes, 1 index, and 5 indexes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Indexes&lt;/th&gt;
&lt;th&gt;Inserts/sec&lt;/th&gt;
&lt;th&gt;Index size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6,524&lt;/td&gt;
&lt;td&gt;0 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5,879&lt;/td&gt;
&lt;td&gt;17 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5,178&lt;/td&gt;
&lt;td&gt;131 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five indexes cost about 21% of write speed on this test. These runs were noisy, and the ranges for 0 and 1 index overlapped, so treat the trend as the finding, not the exact percentage.&lt;/p&gt;

&lt;p&gt;The cost grows with every index you never use. This query finds the candidates:&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&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;indexrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&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;indisunique&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indisprimary&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;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before you drop one, remember these counts are &lt;strong&gt;per server&lt;/strong&gt;. An index with no scans on the primary may be busy on a replica.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Shard Last, and Mean It
&lt;/h2&gt;

&lt;p&gt;This lab did not test sharding. It would need several machines to say anything honest. So here are two real teams that lived at the top of the ladder.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case study: Figma
&lt;/h3&gt;

&lt;p&gt;Figma's database team describes a stack that grew almost 100x since 2020.&lt;/p&gt;

&lt;p&gt;They climbed in order. In 2020 they ran a single Postgres database on AWS's largest physical instance. By the end of 2022 they had caching, read replicas, and about a dozen vertically partitioned databases, with related tables split into their own groups.&lt;/p&gt;

&lt;p&gt;Only then did they hit real limits. Some tables reached several terabytes and billions of rows, and the highest-write tables neared the IOPS limit on RDS.&lt;/p&gt;

&lt;p&gt;Horizontal sharding started in late 2022. The first table took roughly nine months and shipped in September 2023. Figma called the work "an order of magnitude more complex" than their earlier scaling, and listed what it gave up: cross-shard transactions, foreign keys, and globally unique indexes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case study: OpenAI
&lt;/h3&gt;

&lt;p&gt;OpenAI's January 2026 post describes serving 800 million ChatGPT users on PostgreSQL, with one primary handling writes and nearly 50 read replicas.&lt;/p&gt;

&lt;p&gt;They did not shard their PostgreSQL deployment. Their reasons: sharding would mean changing hundreds of endpoints and could take months or years, and the workload is mainly reads.&lt;/p&gt;

&lt;p&gt;But they were not doing nothing. They optimized queries, reviewed ORM-generated SQL, used PgBouncer, put a cache in front, and moved shardable, write-heavy workloads to systems such as Azure Cosmos DB.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note what both stories share.&lt;/strong&gt; Neither team reached for the biggest tool first, and both moved based on a specific limit they had measured.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Signs you might really need sharding
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A write bottleneck on the primary that remains after query and index work&lt;/li&gt;
&lt;li&gt;A single table so large that maintenance like vacuum causes reliability problems&lt;/li&gt;
&lt;li&gt;Storage or IOPS limits on the largest machine you can buy&lt;/li&gt;
&lt;li&gt;Isolating noisy tables into their own database has already been done&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Replicas will not help with any of these writes, because every replica has to apply every write too.&lt;/p&gt;

&lt;p&gt;And there is a smaller step that people skip. &lt;strong&gt;PostgreSQL partitioning&lt;/strong&gt; splits a big table into pieces on the same server, which helps with time-based data and deleting old rows, with no routing layer at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Ladder
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflh0rtxc4r6bihukh4d5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflh0rtxc4r6bihukh4d5.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is the order, with the question that earns each step:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Question that earns it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Measure&lt;/td&gt;
&lt;td&gt;What exactly is slow?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Do less&lt;/td&gt;
&lt;td&gt;Do we need this query at all?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Fix queries&lt;/td&gt;
&lt;td&gt;Is it reading more rows than it returns?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Pool&lt;/td&gt;
&lt;td&gt;Are connections the limit?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. Cache&lt;/td&gt;
&lt;td&gt;Do the same reads repeat, and can they be stale?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. Scale up&lt;/td&gt;
&lt;td&gt;Is a healthy query simply short of hardware?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7. Replicas&lt;/td&gt;
&lt;td&gt;Are reads the limit, and can they lag?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8. Partition&lt;/td&gt;
&lt;td&gt;Is one table too big to manage?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9. Shard&lt;/td&gt;
&lt;td&gt;Are writes or data size beyond one primary?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;You can stop at any step. Most systems should.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Test Does Not Prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2,600x is not a promise.&lt;/strong&gt; This query was chosen because it had no index on a big table. It is a best case for indexing. If your query already uses a good index, you will see nothing like this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The replica test was a clean case.&lt;/strong&gt; One extra core, no network, and only reads. Real replicas also have to replay writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One query is not a workload.&lt;/strong&gt; Real systems have many query shapes competing for the same resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The numbers depend on the machine.&lt;/strong&gt; This was a small shared cloud machine, and baseline runs ranged from 3.7 to 5.5 requests per second. Trust the size of the gaps, not the decimals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold caches matter.&lt;/strong&gt; The replica's 304 ms included reading pages into memory for the first time, while the primary's data was already cached.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What To Do on Monday
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Turn on &lt;code&gt;pg_stat_statements&lt;/code&gt;&lt;/strong&gt; if it's off, and find your top 10 queries by total time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;&lt;/strong&gt; on each one. Look for a high &lt;code&gt;Rows Removed by Filter&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for N+1 queries.&lt;/strong&gt; In Django, &lt;code&gt;select_related&lt;/code&gt; and &lt;code&gt;prefetch_related&lt;/code&gt; can remove whole groups of queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add indexes from real query patterns&lt;/strong&gt;, using &lt;code&gt;CONCURRENTLY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop indexes nobody uses&lt;/strong&gt;, after checking every replica.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before any new infrastructure, write down the number it should change.&lt;/strong&gt; &lt;em&gt;"Replica will take p95 from X to Y."&lt;/em&gt; If nobody can fill in that sentence, the measuring isn't finished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you already have replicas&lt;/strong&gt;, alert on lag and on a replica going missing, and list which reads must stay on the primary.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Common Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should I add an index for every slow query?&lt;/strong&gt;&lt;br&gt;
No. Start with queries that run often. An index for a report that runs once a month may cost more in write overhead than it saves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is high CPU always a reason to scale up?&lt;/strong&gt;&lt;br&gt;
No. High CPU tells you the database is working hard. The query plan tells you whether that work is necessary. In this test, a missing index was creating almost all of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a replica ever beat an index?&lt;/strong&gt;&lt;br&gt;
Yes, when queries are already efficient and you simply have too many of them. That is exactly when replicas earn their cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I cache instead of optimizing?&lt;/strong&gt;&lt;br&gt;
When a query is already good but runs constantly on data that rarely changes. A homepage product list is a classic example.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do managed databases solve this?&lt;/strong&gt;&lt;br&gt;
They help with backups, replicas and failover. They will not fix a sequential scan on 5 million rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are ORMs the problem?&lt;/strong&gt;&lt;br&gt;
Not by themselves. But they make it easy to run queries you never see. OpenAI's post specifically calls out reviewing ORM-generated SQL.&lt;/p&gt;


&lt;h2&gt;
  
  
  Run It Yourself
&lt;/h2&gt;

&lt;p&gt;Run this on a throwaway machine or container with at least 2 cores, and allow about 15 minutes. It creates and deletes its own data in &lt;code&gt;/tmp/scaling-lab&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 ubuntu:24.04 bash

&lt;span class="c"&gt;# inside the container&lt;/span&gt;
apt-get update
apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; postgresql redis-server python3-psycopg2 python3-redis util-linux
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the script below as &lt;code&gt;/tmp/scaling_lab.py&lt;/code&gt;, then run it as the &lt;code&gt;postgres&lt;/code&gt; user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;a+r /tmp/scaling_lab.py
su postgres &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'cd /tmp &amp;amp;&amp;amp; PATH=/usr/lib/postgresql/16/bin:$PATH python3 /tmp/scaling_lab.py'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results are saved to &lt;code&gt;lab_results.json&lt;/code&gt;. Change one thing at a time: try 20 million rows, a hotter or colder cache pattern, or a real replica on a second machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Click to expand the full lab script (scaling_lab.py)&lt;/strong&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;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Database scaling lab: test each step of the scaling ladder on one workload.

Stages: measure, read replica, cache, index, index write cost, stale reads.
Needs PostgreSQL 16 with contrib, Redis, psycopg2, redis-py and 2 CPU cores.
Run as a non-root user with the PostgreSQL bin directory on PATH.
Use a throwaway machine. It creates and deletes its own data in BASE.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;

&lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/scaling-lab&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;P&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="n"&gt;RP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;55432&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;55433&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;56379&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;CUSTOMERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RUN_S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;REPEATS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100_000&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="mi"&gt;3&lt;/span&gt;
&lt;span class="n"&gt;READ&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;SELECT id, status, amount, created_at FROM orders &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE customer_id = %s ORDER BY created_at DESC LIMIT 20&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&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;sh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;core&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="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&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;taskset -c &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shell&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;capture_output&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;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg2&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="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;autocommit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&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;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXPLAIN (ANALYZE, BUFFERS) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%s&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;4242&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pgbench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;core&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="n"&gt;RUN_S&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pgbench -n -h &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -p &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -f &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -c &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -j 1 &lt;/span&gt;&lt;span class="sh"&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;-T &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; postgres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskset -c &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cmd&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="n"&gt;shell&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;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STDOUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tps = ([\d.]+)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latency average = ([\d.]+) ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;group&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rmtree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignore_errors&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makedirs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;sh&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;initdb -D &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/p -A trust&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/p/postgresql.conf&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;a&lt;/span&gt;&lt;span class="sh"&gt;"&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;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;unix_socket_directories=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;listen_addresses=&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shared_buffers=1GB&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;max_parallel_workers_per_gather=0&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shared_preload_libraries=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pg_stat_statements&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;wal_level=replica&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_wal_senders=5&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;hot_standby=on&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;max_connections=50&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/p/pg_hba.conf&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;a&lt;/span&gt;&lt;span class="sh"&gt;"&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;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local replication all trust&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;sh&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;pg_ctl -D &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/p -l &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/p.log -w start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;core&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="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE EXTENSION pg_stat_statements&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;q&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;CREATE TABLE orders(id bigserial PRIMARY KEY, customer_id int NOT NULL,
          status text NOT NULL, amount int NOT NULL,
          created_at timestamptz NOT NULL, note text NOT NULL)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;q&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;INSERT INTO orders(customer_id, status, amount, created_at, note)
          SELECT (random()*&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CUSTOMERS&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)::int + 1,
                 (ARRAY[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;paid&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="s"&gt;shipped&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="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;])[1+(random()*3)::int],
                 (random()*50000)::int,
                 now() - random() * interval &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;365 days&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, repeat(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, 40)
          FROM generate_series(1, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ROWS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANALYZE orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/read.sql&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;w&lt;/span&gt;&lt;span class="sh"&gt;"&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;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;set cid random(1, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CUSTOMERS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%s&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;:cid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;;&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/write.sql&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;w&lt;/span&gt;&lt;span class="sh"&gt;"&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;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;set cid random(1, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CUSTOMERS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n\\&lt;/span&gt;&lt;span class="s"&gt;set amt random(1, 50000)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO orders_w(customer_id,status,amount,created_at,note) &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 (:cid,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,:amt,now(),repeat(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,40));&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/load.sql&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;w&lt;/span&gt;&lt;span class="sh"&gt;"&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;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;set cid random(1, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CUSTOMERS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n\\&lt;/span&gt;&lt;span class="s"&gt;set amt random(1, 50000)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO orders(customer_id,status,amount,created_at,note) &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 (:cid,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,:amt,now(),repeat(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,40));&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;q&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 count(*) FROM orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table_mb&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="nf"&gt;q&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 pg_total_relation_size(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)/1048576&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&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;start_replica&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;sh&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;pg_basebackup -h &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -p &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -D &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/r -R -X stream -c fast -C -S lab_slot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/r/postgresql.conf&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;a&lt;/span&gt;&lt;span class="sh"&gt;"&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;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;max_connections=50&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;sh&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;pg_ctl -D &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/r -l &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/r.log -w start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;q&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 count(*) FROM pg_stat_replication&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;q&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 count(*) FROM orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stage_baseline&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="nf"&gt;q&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 pg_stat_statements_reset()&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;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPEATS&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="nf"&gt;pgbench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/read.sql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;baseline&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;results&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_query&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="nf"&gt;q&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 calls, round(mean_exec_time::numeric,1),
        round(total_exec_time::numeric/1000,1), shared_blks_hit+shared_blks_read,
        left(query,70) FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 1&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%s&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;4242&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_noindex&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="nf"&gt;plan&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;stage_replica&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPEATS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgbench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/read.sql&lt;/span&gt;&lt;span class="sh"&gt;"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgbench&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="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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/read.sql&lt;/span&gt;&lt;span class="sh"&gt;"&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&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="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replica&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;results&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_replica&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="nf"&gt;plan&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stage_cache&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sched_setaffinity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unix_socket_path&lt;/span&gt;&lt;span class="o"&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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/redis.sock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cid&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;r&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="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;str&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;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pick&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;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flushall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;get_orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&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;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;hit_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&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;mean_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;p50_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&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;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="n"&gt;p95_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_uniform&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="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CUSTOMERS&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;hot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&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;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;
                        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CUSTOMERS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_hot_1000&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="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_hot_100&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="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sched_setaffinity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stage_index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE INDEX orders_customer_created ON orders (customer_id, created_at DESC)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index_build_s&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="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index_mb&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="nf"&gt;q&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 pg_relation_size(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;orders_customer_created&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)/1048576&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;READ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%s&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;4242&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_index&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="nf"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPEATS&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="nf"&gt;pgbench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/read.sql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indexed&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;results&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stage_writes&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="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&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;status&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;amount&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;(customer_id, created_at DESC)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&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;0&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;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROP TABLE IF EXISTS orders_w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE TABLE orders_w (LIKE orders INCLUDING DEFAULTS)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALTER TABLE orders_w ALTER id SET DEFAULT nextval(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;orders_id_seq&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;q&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_w SELECT * FROM orders LIMIT 1000000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
            &lt;span class="nf"&gt;q&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;CREATE INDEX w&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ON orders_w (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;col&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;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;()&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHECKPOINT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;tps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPEATS&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="nf"&gt;pgbench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/write.sql&lt;/span&gt;&lt;span class="sh"&gt;"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;tps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;res&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="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index_mb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;q&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 pg_indexes_size(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;orders_w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)/1048576&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;writes&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;res&lt;/span&gt;
    &lt;span class="nf"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROP TABLE orders_w&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;stale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit_s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;give_up_s&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="n"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;conn&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="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;miss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gave_up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&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="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;limit_s&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;w&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(customer_id,status,amount,created_at,note) &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 (1,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,1,now(),&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) RETURNING 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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;c&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 1 FROM orders 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;i&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="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;break&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;give_up_s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;gave_up&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;break&lt;/span&gt;
        &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&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;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;stale_first_read&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;miss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gave_up&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;gave_up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;p50_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&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;p99_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;max_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;2&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;stage_stale&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stale_idle&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="nf"&gt;stale&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="nf"&gt;pgbench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/load.sql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&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;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stale_load&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="nf"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lag_under_load&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="nf"&gt;q&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 pg_size_pretty(pg_wal_lsn_diff(
        pg_current_wal_lsn(), replay_lsn)), replay_lag::text
        FROM pg_stat_replication&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres --version&lt;/span&gt;&lt;span class="sh"&gt;"&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;span class="n"&gt;flush&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="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;sh&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;redis-server --port 0 --unixsocket &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/redis.sock --save &lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="s"&gt; --daemonize yes --dir &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&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;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&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;baseline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage_baseline&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;replica_start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_replica&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;replica&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage_replica&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;cache&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage_cache&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;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage_index&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;writes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage_writes&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;stale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage_stale&lt;/span&gt;&lt;span class="p"&gt;)]:&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;done in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lab_results.json&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;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;indent&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;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;sh&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;pg_ctl -D &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/r -m fast -w stop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;sh&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;pg_ctl -D &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/p -m fast -w stop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;sh&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;redis-cli -s &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/redis.sock shutdown nosave&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Back to That Meeting
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"We need a read replica."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Maybe you do. In this test, the replica did what it promised: it doubled capacity.&lt;/p&gt;

&lt;p&gt;But it also cost a second server, introduced stale reads, and left a 5-million-row scan behind every request. One line of SQL did far more.&lt;/p&gt;

&lt;p&gt;Here is the short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure first.&lt;/strong&gt; One &lt;code&gt;EXPLAIN&lt;/code&gt; showed 4,999,944 wasted rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replicas add capacity.&lt;/strong&gt; They don't make a bad query good.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replicas lag.&lt;/strong&gt; Even at 0.33 ms, 98.65% of immediate reads missed the row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caches need repetition.&lt;/strong&gt; Hit rate ranged from 0.4% to 84.7% on the same code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexes have a bill.&lt;/strong&gt; Five of them cost about 21% of insert speed here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharding is real work.&lt;/strong&gt; Teams like Figma did it only after hitting measured limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the next time someone says &lt;em&gt;"we need to scale,"&lt;/em&gt; ask one question first:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Which number is the bottleneck, and how do we know?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Over to You
&lt;/h2&gt;

&lt;p&gt;What was the most expensive scaling step your team took that one index could have avoided? Tell me in the comments 👇&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Test details: PostgreSQL 16.15 and Redis on a 2-core Linux cloud machine. 5 million rows, 8 clients, 3 repeats per step with medians reported. Numbers will vary on other machines. The scaling order in this piece follows Nikki Siapno's article on database scaling in the Level Up Coding System Design Newsletter (October 2026).&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>django</category>
    </item>
    <item>
      <title>x is 257 Was True on Monday and False on Tuesday</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Tue, 03 Nov 2026 12:06:50 +0000</pubDate>
      <link>https://dev.to/anas_issath/x-is-257-was-true-on-monday-and-false-on-tuesday-26mm</link>
      <guid>https://dev.to/anas_issath/x-is-257-was-true-on-monday-and-false-on-tuesday-26mm</guid>
      <description>&lt;p&gt;&lt;em&gt;Why Python's &lt;code&gt;is&lt;/code&gt; and &lt;code&gt;==&lt;/code&gt; are not the same thing, how one of them lies about numbers, and the single rule that keeps you safe.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had a check that compared two numbers. Something close to this, simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;expected_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;grant_access&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. Every test passed. Then one day, with slightly different data, the same comparison started returning &lt;code&gt;False&lt;/code&gt; for two numbers that were obviously equal. Both were &lt;code&gt;257&lt;/code&gt;. I printed them. They were identical. And &lt;code&gt;parsed_id is expected_id&lt;/code&gt; was &lt;code&gt;False&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So I changed the test data, and it went back to &lt;code&gt;True&lt;/code&gt;. Smaller numbers worked. Bigger numbers did not. &lt;strong&gt;The comparison's correctness depended on how large the number was&lt;/strong&gt;, which is not a sentence that should ever make sense.&lt;/p&gt;

&lt;p&gt;The culprit was a single two-letter word I had used where I meant something else: &lt;code&gt;is&lt;/code&gt;. In Python, &lt;code&gt;is&lt;/code&gt; and &lt;code&gt;==&lt;/code&gt; look interchangeable and are not. One asks &lt;em&gt;"are these equal?"&lt;/em&gt; The other asks a completely different question, and the answer to that question happens to be unpredictable for numbers.&lt;/p&gt;

&lt;p&gt;Here is what &lt;code&gt;is&lt;/code&gt; actually checks, why it lied to me, and the one rule that makes this disappear.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Two Questions Are Not the Same Question
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;==&lt;/code&gt; and &lt;code&gt;is&lt;/code&gt; ask different things, and the whole bug lives in the gap between them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;==&lt;/code&gt; asks: are these two values &lt;em&gt;equal&lt;/em&gt;?&lt;/strong&gt; Do they represent the same thing? &lt;code&gt;5 == 5&lt;/code&gt; is &lt;code&gt;True&lt;/code&gt; because five equals five. This is almost always the question you actually want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;is&lt;/code&gt; asks: are these two the &lt;em&gt;exact same object&lt;/em&gt; in memory?&lt;/strong&gt; Not "equal," but "literally the same object, the same identity."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two different lists that happen to hold the same items are equal but are not the same object:&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;a&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;      &lt;span class="c1"&gt;# True  -&amp;gt; same contents
&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;      &lt;span class="c1"&gt;# False -&amp;gt; two different list objects
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;a&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; are &lt;em&gt;equal&lt;/em&gt;. They are not &lt;em&gt;identical&lt;/em&gt;. They are two separate lists that look alike. &lt;code&gt;==&lt;/code&gt; sees them as equal; &lt;code&gt;is&lt;/code&gt; sees them as distinct, because they are distinct objects sitting at different places in memory.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;That is the core confusion:&lt;/strong&gt; &lt;code&gt;is&lt;/code&gt; is not a stricter &lt;code&gt;==&lt;/code&gt;. It is a different operator answering a different question.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And for most values you compare, you care about equality, not identity. You want &lt;code&gt;==&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why &lt;code&gt;is&lt;/code&gt; Lied About My Numbers
&lt;/h2&gt;

&lt;p&gt;So why did &lt;code&gt;parsed_id is expected_id&lt;/code&gt; ever return &lt;code&gt;True&lt;/code&gt; for 257, let alone sometimes?&lt;/p&gt;

&lt;p&gt;Because of an optimization most people never learn about. For performance, &lt;strong&gt;CPython keeps a cache of small integer objects, roughly -5 through 256&lt;/strong&gt;, created once at startup and reused everywhere. Every time you use the number &lt;code&gt;5&lt;/code&gt;, you get back the same cached &lt;code&gt;5&lt;/code&gt; object. So for small numbers, two equal values really are the same object, and &lt;code&gt;is&lt;/code&gt; returns &lt;code&gt;True&lt;/code&gt; by luck.&lt;/p&gt;

&lt;p&gt;Step outside that cached range and the luck runs out. Numbers above 256 are not cached, so two equal large numbers can be two different objects, and &lt;code&gt;is&lt;/code&gt; returns &lt;code&gt;False&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the trap in its purest form, which I ran on Python 3.13:&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="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;256&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;256&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;257&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;257&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# False
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that again. &lt;strong&gt;The exact same code.&lt;/strong&gt; The only difference is 256 versus 257. One is &lt;code&gt;True&lt;/code&gt;, the other is &lt;code&gt;False&lt;/code&gt;, purely because 256 is in the small-integer cache and 257 is not.&lt;/p&gt;

&lt;p&gt;That is why my check worked for small IDs and broke for large ones. &lt;code&gt;is&lt;/code&gt; was never checking equality. It was checking identity, and identity for numbers depends on an internal caching optimization that you are not supposed to rely on and that can change between Python versions and even between how the values were produced. &lt;strong&gt;I had built a correctness check on top of an implementation detail.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A note, because it makes the danger worse: with plain literals written in your source (&lt;code&gt;257 is 257&lt;/code&gt;), Python's compiler often folds them into one shared constant, so it returns &lt;code&gt;True&lt;/code&gt; and looks fine. The failure only appears when the numbers are &lt;strong&gt;computed at runtime&lt;/strong&gt;, as mine were, from parsing. So it can look correct in a quick REPL test with literals and then fail on real, computed data. Exactly the data you did not test with.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Python Itself Now Warns You
&lt;/h2&gt;

&lt;p&gt;This mistake is common enough that recent versions of Python will warn you about one form of it. Compare a value to a literal with &lt;code&gt;is&lt;/code&gt; and the interpreter says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SyntaxWarning: "is" with 'str' literal. Did you mean "=="?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The language is telling you, out loud, that &lt;code&gt;is&lt;/code&gt; is almost certainly not what you meant when comparing to a literal string or number. &lt;strong&gt;If you see that warning, the fix is virtually always to change &lt;code&gt;is&lt;/code&gt; to &lt;code&gt;==&lt;/code&gt;.&lt;/strong&gt; Python is trying to save you from the exact bug I shipped.&lt;/p&gt;




&lt;h2&gt;
  
  
  The One Place &lt;code&gt;is&lt;/code&gt; Is Not Only Fine but Correct: &lt;code&gt;None&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Here is where it flips. There is a specific, important case where &lt;code&gt;is&lt;/code&gt; is the &lt;em&gt;right&lt;/em&gt; operator and &lt;code&gt;==&lt;/code&gt; is subtly &lt;em&gt;wrong&lt;/em&gt;: comparing to &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The official Python style guide, PEP 8, states it directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Comparisons to singletons like None should always be done with is or is not, never the equality operators.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Always &lt;code&gt;x is None&lt;/code&gt;, never &lt;code&gt;x == None&lt;/code&gt;. Why? Two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, &lt;code&gt;None&lt;/code&gt; is a singleton.&lt;/strong&gt; There is exactly one &lt;code&gt;None&lt;/code&gt; object in the entire program, so "is this the same object as &lt;code&gt;None&lt;/code&gt;" is precisely the right question, and &lt;code&gt;is&lt;/code&gt; answers it directly and fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, and more important, &lt;code&gt;==&lt;/code&gt; can be overridden.&lt;/strong&gt; Any object can define its own &lt;code&gt;__eq__&lt;/code&gt; method and decide what it considers equal to. A badly behaved or cleverly designed object can make &lt;code&gt;x == None&lt;/code&gt; return &lt;code&gt;True&lt;/code&gt; when &lt;code&gt;x&lt;/code&gt; is not actually &lt;code&gt;None&lt;/code&gt;. I confirmed exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Weird&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;__eq__&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;other&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Weird&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;      &lt;span class="c1"&gt;# True   &amp;lt;- __eq__ hijacked the comparison
&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;      &lt;span class="c1"&gt;# False  &amp;lt;- correct: w is genuinely not None
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;w == None&lt;/code&gt; is &lt;code&gt;True&lt;/code&gt;, which is a lie. &lt;code&gt;w is None&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;, which is the truth. &lt;strong&gt;&lt;code&gt;is&lt;/code&gt; cannot be fooled this way&lt;/strong&gt;, because identity is not something an object can redefine. That is why checking for &lt;code&gt;None&lt;/code&gt; is the one case where you reach for &lt;code&gt;is&lt;/code&gt; on purpose.&lt;/p&gt;

&lt;p&gt;So the operators split cleanly by use case:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparing to...&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;None&lt;/code&gt; (and other singletons like &lt;code&gt;True&lt;/code&gt;/&lt;code&gt;False&lt;/code&gt; when you truly mean the object)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;is&lt;/code&gt; / &lt;code&gt;is not&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One object exists; &lt;code&gt;==&lt;/code&gt; can be overridden and lie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Numbers, strings, lists, any value&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;==&lt;/code&gt; / &lt;code&gt;!=&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;You care about equality, not identity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Rule I Take Away
&lt;/h2&gt;

&lt;p&gt;Compressed to one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;==&lt;/code&gt; asks "are these equal?" &lt;code&gt;is&lt;/code&gt; asks "are these the exact same object?"&lt;/strong&gt; Use &lt;code&gt;==&lt;/code&gt; for comparing values. Use &lt;code&gt;is&lt;/code&gt; only for &lt;code&gt;None&lt;/code&gt; and other singletons. Never use &lt;code&gt;is&lt;/code&gt; to compare numbers or strings, because its answer depends on caching and interning you must not rely on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The deeper lesson, the one that outlasts this specific trap, is that &lt;strong&gt;identity and equality are different concepts, and the two-letter word &lt;code&gt;is&lt;/code&gt; means identity, not "equals."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;English makes &lt;code&gt;is&lt;/code&gt; feel like a synonym for equality, which is exactly why people reach for it when they mean &lt;code&gt;==&lt;/code&gt;. But Python's &lt;code&gt;is&lt;/code&gt; is a precise, narrow operator about object identity, and for values like numbers that identity is governed by internal optimizations that are not part of the language's promises. Build a comparison on &lt;code&gt;is&lt;/code&gt; for a value, and you have built it on sand that shifts between numbers, between runs, and between Python versions.&lt;/p&gt;

&lt;p&gt;And the usual sting applies. This hides in testing because small numbers and literal values make &lt;code&gt;is&lt;/code&gt; accidentally return &lt;code&gt;True&lt;/code&gt;, so it looks correct right up until real, computed, larger data flows through and the identity you were never entitled to quietly stops holding.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Catch It Before It Catches You
&lt;/h2&gt;

&lt;p&gt;Keep these four habits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;==&lt;/code&gt; to compare values, always.&lt;/strong&gt; Numbers, strings, anything where you care about the value being equal. If the question is "do these represent the same thing," that is &lt;code&gt;==&lt;/code&gt;, every time. Reaching for &lt;code&gt;is&lt;/code&gt; on a value is the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;is&lt;/code&gt; only for &lt;code&gt;None&lt;/code&gt; and singletons.&lt;/strong&gt; &lt;code&gt;if x is None&lt;/code&gt;, &lt;code&gt;if x is not None&lt;/code&gt;. This is both correct and the officially recommended style (PEP 8). The same applies if you genuinely mean the one &lt;code&gt;True&lt;/code&gt; or &lt;code&gt;False&lt;/code&gt; object, but for &lt;code&gt;None&lt;/code&gt; it is essential, because &lt;code&gt;==&lt;/code&gt; can be overridden and lie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heed the &lt;code&gt;SyntaxWarning&lt;/code&gt;.&lt;/strong&gt; If Python warns &lt;em&gt;&lt;code&gt;"is" with a literal. Did you mean "=="?&lt;/code&gt;&lt;/em&gt;, the answer is yes, you did. Change it to &lt;code&gt;==&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never test identity-based comparisons only with small numbers or literals.&lt;/strong&gt; &lt;code&gt;is&lt;/code&gt; gives false confidence with cached small ints and folded literals. If you must reason about &lt;code&gt;is&lt;/code&gt;, test with large, runtime-computed values, where the caching no longer rescues you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I changed one &lt;code&gt;is&lt;/code&gt; to &lt;code&gt;==&lt;/code&gt; in that access check, and the comparison started telling the truth for every number, large or small. The two values had been equal the whole time. I had just been asking Python the wrong question, and for small numbers it had been politely giving me the right answer anyway, right up until it stopped.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;==&lt;/code&gt; and &lt;code&gt;is&lt;/code&gt; ask different questions.&lt;/strong&gt; &lt;code&gt;==&lt;/code&gt; asks "are these values equal?" &lt;code&gt;is&lt;/code&gt; asks "are these the exact same object in memory?" They are not interchangeable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two equal objects can fail an &lt;code&gt;is&lt;/code&gt; check:&lt;/strong&gt; &lt;code&gt;[1,2,3] == [1,2,3]&lt;/code&gt; is &lt;code&gt;True&lt;/code&gt;, but &lt;code&gt;[1,2,3] is [1,2,3]&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;, because they are two distinct objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;is&lt;/code&gt; lies about numbers because of small-integer caching.&lt;/strong&gt; On Python 3.13: &lt;code&gt;int('256') is int('256')&lt;/code&gt; is &lt;code&gt;True&lt;/code&gt; (256 is cached), but &lt;code&gt;int('257') is int('257')&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt; (257 is not). Same code, different answer by size. This is an implementation detail you must never rely on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It hides in testing:&lt;/strong&gt; small numbers and source literals make &lt;code&gt;is&lt;/code&gt; accidentally return &lt;code&gt;True&lt;/code&gt; (caching and compiler constant-folding). It breaks only on large, runtime-computed values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one correct use of &lt;code&gt;is&lt;/code&gt;: &lt;code&gt;None&lt;/code&gt;.&lt;/strong&gt; PEP 8 says comparisons to singletons like &lt;code&gt;None&lt;/code&gt; should always use &lt;code&gt;is&lt;/code&gt; or &lt;code&gt;is not&lt;/code&gt;. &lt;code&gt;None&lt;/code&gt; is a singleton, and &lt;code&gt;==&lt;/code&gt; can be overridden to lie (&lt;code&gt;w == None&lt;/code&gt; can be &lt;code&gt;True&lt;/code&gt; while &lt;code&gt;w is None&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule:&lt;/strong&gt; &lt;code&gt;==&lt;/code&gt; for comparing values; &lt;code&gt;is&lt;/code&gt; only for &lt;code&gt;None&lt;/code&gt; and singletons; never &lt;code&gt;is&lt;/code&gt; for numbers or strings. And if Python warns &lt;code&gt;"is" with a literal&lt;/code&gt;, change it to &lt;code&gt;==&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Search your code for &lt;code&gt;is&lt;/code&gt; and &lt;code&gt;is not&lt;/code&gt; and check each one: is the right-hand side &lt;code&gt;None&lt;/code&gt; (good) or a number, string, or other value (bug)? Every &lt;code&gt;is&lt;/code&gt; comparing a value should be &lt;code&gt;==&lt;/code&gt;. The ones comparing to &lt;code&gt;None&lt;/code&gt; are exactly right.&lt;/p&gt;

&lt;p&gt;Did &lt;code&gt;is&lt;/code&gt; vs &lt;code&gt;==&lt;/code&gt; ever bite you in production? Tell me in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>beginners</category>
      <category>programming</category>
      <category>debugging</category>
    </item>
    <item>
      <title>My Page Ran One Query in Development and 5,000 in Production</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:45:48 +0000</pubDate>
      <link>https://dev.to/anas_issath/my-page-ran-one-query-in-development-and-5000-in-production-29pk</link>
      <guid>https://dev.to/anas_issath/my-page-ran-one-query-in-development-and-5000-in-production-29pk</guid>
      <description>&lt;p&gt;&lt;em&gt;The N+1 problem: how a harmless-looking loop quietly fires one database query per row, and the two methods that collapse it back to one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The page was a simple list. Show every book, with its author's name next to it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;books&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;book.title&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt; by &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;book.author.name&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endfor&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my laptop, with twelve books in the database, it loaded instantly. It stayed fast through code review. It shipped.&lt;/p&gt;

&lt;p&gt;Then production, with a real catalogue, got slow. Not broken. &lt;strong&gt;Slow.&lt;/strong&gt; The page that rendered in 30 milliseconds locally was taking four seconds live, and the database was lighting up while it did.&lt;/p&gt;

&lt;p&gt;I turned on query logging and felt my stomach drop. That one page, the simple list, was running &lt;strong&gt;over five thousand database queries&lt;/strong&gt; to render. One to get the books. And then one more for every single book, to go fetch its author.&lt;/p&gt;

&lt;p&gt;I had written what looked like one query. I had actually written one, plus one per row. This is the &lt;strong&gt;N+1 problem&lt;/strong&gt;, and it is probably the single most common performance bug in Django. Here is exactly how the loop betrays you, and the two methods that fix it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the Thousands of Queries Come From
&lt;/h2&gt;

&lt;p&gt;The trap hides in a line that does not look like a query at all.&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;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# looks like the only database call
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line fetches the books. One query. So far so good.&lt;/p&gt;

&lt;p&gt;The damage happens later, in the template, on this piece:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;book.author.name&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;book.author&lt;/code&gt; is a foreign key. When you first access it, the author is not in memory yet, so &lt;strong&gt;Django quietly runs a query to go get that author from the database.&lt;/strong&gt; One query, right there, triggered just by reading the attribute.&lt;/p&gt;

&lt;p&gt;Now put that inside a loop over every book. For each book, the template reads &lt;code&gt;book.author&lt;/code&gt;, and each read fires its own query. One hundred books means one hundred author queries. Five thousand books means five thousand author queries.&lt;/p&gt;

&lt;p&gt;So the real cost is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;1 query&lt;/strong&gt; to fetch the books, &lt;strong&gt;plus N queries&lt;/strong&gt;, one per book, to fetch each author.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the "N+1." One query for the parents, N queries for the related objects, and the N is however many rows you have.&lt;/p&gt;

&lt;p&gt;The line that triggers it, &lt;code&gt;book.author&lt;/code&gt;, does not look like database access. It looks like reading a field on an object you already have. That is exactly why it is so easy to write and so hard to spot.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why It Is Invisible Until It Is Catastrophic
&lt;/h2&gt;

&lt;p&gt;This bug has the signature of every dangerous one. &lt;strong&gt;It scales with your data, and your development database has almost none.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On my laptop I had twelve books. Twelve books means thirteen queries, which run in a couple of milliseconds. Nothing to notice. The page was fast, the tests passed, nobody saw a problem.&lt;/p&gt;

&lt;p&gt;Production had thousands of rows. The exact same code now meant thousands of queries, each a separate round trip to the database, and the page crawled.&lt;/p&gt;

&lt;p&gt;That is the cruel part: &lt;strong&gt;the code did not change between fast and slow. The row count did.&lt;/strong&gt; N+1 is invisible precisely when you are testing, because you test with a handful of rows, and it only becomes catastrophic when real data arrives. The bug was always there. It just needed enough rows to show its teeth, and you never have enough rows on your laptop.&lt;/p&gt;

&lt;p&gt;This is also why it survives review. A reviewer reads &lt;code&gt;book.author.name&lt;/code&gt; and sees a field access, not a query. There is nothing visibly wrong with the code. The wrongness is in how it multiplies, and &lt;strong&gt;multiplication does not show up in a diff.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix for Foreign Keys: &lt;code&gt;select_related&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When the related object is reached through a forward foreign key or a one-to-one (each book has exactly &lt;strong&gt;one&lt;/strong&gt; author), the fix is &lt;code&gt;select_related&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Django to fetch the books &lt;em&gt;and&lt;/em&gt; their authors together, in a single query, using a SQL &lt;strong&gt;JOIN&lt;/strong&gt;. Instead of fetching books and then running off to get each author one at a time, the database returns each book with its author's columns already attached.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;N+1 queries collapse into one.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the template's &lt;code&gt;book.author.name&lt;/code&gt; touches data that is already in memory, loaded by that single joined query. No per-row database trip. The page that ran five thousand queries runs one.&lt;/p&gt;

&lt;p&gt;The way to read it: &lt;code&gt;select_related&lt;/code&gt; does the joining &lt;strong&gt;in the database, in one query&lt;/strong&gt;, and it is for &lt;strong&gt;single-valued relationships&lt;/strong&gt;, where each row points to exactly one related object (&lt;code&gt;ForeignKey&lt;/code&gt;, &lt;code&gt;OneToOne&lt;/code&gt;). That "one related object per row" is what lets it ride along in a single JOIN.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix for the Many Side: &lt;code&gt;prefetch_related&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A JOIN works when each book has one author. But what about the other direction, where one object has &lt;strong&gt;many&lt;/strong&gt; related rows? An author with many books, a post with many tags, an order with many line items. You cannot pull "many" into a single joined row cleanly, so Django gives you a different tool: &lt;code&gt;prefetch_related&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;authors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;books&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs a &lt;strong&gt;separate second query&lt;/strong&gt; to fetch all the related objects at once, then joins them to their parents &lt;strong&gt;in Python, in memory&lt;/strong&gt;. So fetching a list of authors and all their books costs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;2 queries total.&lt;/strong&gt; One for the authors, one for all their books.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not one query per author. Two queries for the whole thing, regardless of how many authors you have.&lt;/p&gt;

&lt;p&gt;It nests too: &lt;code&gt;prefetch_related("books", "books__publisher")&lt;/code&gt; adds one more query for the publishers, and so on: &lt;strong&gt;one additional query per relationship level, not per row.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The way to read it: &lt;code&gt;prefetch_related&lt;/code&gt; does the joining &lt;strong&gt;in Python across a small number of queries&lt;/strong&gt;, and it is for &lt;strong&gt;multi-valued relationships&lt;/strong&gt;: the many-to-many and reverse-foreign-key cases where a single JOIN would not work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which One to Reach For
&lt;/h2&gt;

&lt;p&gt;The choice comes down to one question: &lt;strong&gt;how many related objects does each row point to?&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;Each row points to...&lt;/th&gt;
&lt;th&gt;Relationship types&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;One&lt;/strong&gt; related object&lt;/td&gt;
&lt;td&gt;Forward &lt;code&gt;ForeignKey&lt;/code&gt;, &lt;code&gt;OneToOne&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;select_related&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One query, a SQL JOIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Many&lt;/strong&gt; related objects&lt;/td&gt;
&lt;td&gt;Many-to-many, reverse &lt;code&gt;ForeignKey&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prefetch_related&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A separate query per relationship, joined in Python&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A useful shortcut:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you access the relationship as a &lt;strong&gt;single object&lt;/strong&gt; (&lt;code&gt;book.author&lt;/code&gt;), you almost certainly want &lt;code&gt;select_related&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you access it as a &lt;strong&gt;collection you loop over&lt;/strong&gt; (&lt;code&gt;author.books.all()&lt;/code&gt;), you want &lt;code&gt;prefetch_related&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And you can combine them freely on one queryset when a page touches both kinds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One JOIN for the single author, one extra query for the many tags. A page that might have run thousands of queries now runs a small, fixed handful, no matter how many books, authors, or tags exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rule I Take Away
&lt;/h2&gt;

&lt;p&gt;Compressed to one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Accessing a related object inside a loop fires one query per row, turning one intended query into N+1.&lt;/strong&gt; Fetch the relationships up front: &lt;code&gt;select_related&lt;/code&gt; for single-valued relationships (one JOIN), &lt;code&gt;prefetch_related&lt;/code&gt; for multi-valued ones (a second query joined in Python).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The deeper lesson, the one that outlasts this specific framework, is that &lt;strong&gt;in an ORM, reading an attribute can be a database call&lt;/strong&gt;, and the ones that fire inside loops are the ones that hurt.&lt;/p&gt;

&lt;p&gt;The ORM is wonderful because it hides the database behind ordinary-looking object access. That same hiding is what makes N+1 so easy to write: &lt;code&gt;book.author&lt;/code&gt; looks free, costs a query, and you put it in a loop without a second thought.&lt;/p&gt;

&lt;p&gt;The fix is always the same shape: &lt;strong&gt;load what the loop will need before the loop&lt;/strong&gt;, in a bounded number of queries, instead of letting the loop pull it one row at a time.&lt;/p&gt;

&lt;p&gt;And the usual sting applies. This never shows up when you test, because you test with a handful of rows where N+1 is indistinguishable from one query. It only bites once real data arrives, which is to say in production, at the worst possible time, on the page you were sure was simple.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Catch It Before It Catches You
&lt;/h2&gt;

&lt;p&gt;Keep these four habits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count your queries, do not eyeball your code.&lt;/strong&gt; Install the Django Debug Toolbar (or log queries) and watch the query count for each page. N+1 is invisible in the code and glaringly obvious in a query count that scales with your rows. This single tool catches almost every instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suspect every related access inside a loop.&lt;/strong&gt; &lt;code&gt;book.author&lt;/code&gt;, &lt;code&gt;order.customer&lt;/code&gt;, &lt;code&gt;item.product&lt;/code&gt; inside a &lt;code&gt;for&lt;/code&gt; loop, in a template or a serializer, is a query per iteration. The moment you loop and touch a relationship, ask whether you prefetched it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the method to the relationship.&lt;/strong&gt; Single object per row (&lt;code&gt;book.author&lt;/code&gt;) → &lt;code&gt;select_related&lt;/code&gt;. Collection per row (&lt;code&gt;author.books.all()&lt;/code&gt;) → &lt;code&gt;prefetch_related&lt;/code&gt;. Combine both on one queryset when a page needs both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with realistic data volumes.&lt;/strong&gt; Seed your development or test database with thousands of rows, not twelve. N+1 hides at small scale by definition; the only way to feel it before production does is to give it enough rows to multiply.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I added &lt;code&gt;.select_related("author")&lt;/code&gt; to that one queryset, reloaded, and watched the query count fall from over five thousand to &lt;strong&gt;one&lt;/strong&gt;. The page dropped back under a tenth of a second. The code in the template never changed. I had simply loaded the authors before the loop, instead of letting the loop fetch them five thousand times.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accessing a related object (&lt;code&gt;book.author&lt;/code&gt;) inside a loop triggers a separate query per row&lt;/strong&gt;, because the ORM lazily fetches the relationship on first access. One list query plus N per-row queries is the N+1 problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is invisible in development&lt;/strong&gt; (a handful of rows means a handful of queries) and &lt;strong&gt;catastrophic in production&lt;/strong&gt; (thousands of rows means thousands of queries). The code never changes; the row count does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;select_related&lt;/code&gt;&lt;/strong&gt; fixes it for single-valued relationships (forward &lt;code&gt;ForeignKey&lt;/code&gt;, &lt;code&gt;OneToOne&lt;/code&gt;): it does a SQL JOIN and fetches everything in one query. Use it when you access the relation as one object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prefetch_related&lt;/code&gt;&lt;/strong&gt; fixes it for multi-valued relationships (many-to-many, reverse &lt;code&gt;ForeignKey&lt;/code&gt;): it runs a separate query and joins in Python, costing a small fixed number of queries (e.g. 2), not one per row. Use it when you loop over a collection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choosing:&lt;/strong&gt; one related object per row → &lt;code&gt;select_related&lt;/code&gt;; many per row → &lt;code&gt;prefetch_related&lt;/code&gt;. Combine both on one queryset when needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch it&lt;/strong&gt; with a query counter (Django Debug Toolbar), suspect every related access inside a loop, and test with realistic data volumes, because N+1 only reveals itself at scale.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Install the Django Debug Toolbar and open your busiest list page. If the query count climbs with the number of rows on the page, you have an N+1. Add &lt;code&gt;select_related&lt;/code&gt; or &lt;code&gt;prefetch_related&lt;/code&gt; to the queryset and watch it collapse to a handful.&lt;/p&gt;

&lt;p&gt;What's the worst query count you've ever found on a single page? Tell me in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>performance</category>
      <category>orm</category>
    </item>
    <item>
      <title>My API Was Returning Every User's Password Hash. I Didn't Notice for Weeks.</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Sat, 31 Oct 2026 07:33:51 +0000</pubDate>
      <link>https://dev.to/anas_issath/my-api-was-returning-every-users-password-hash-i-didnt-notice-for-weeks-4d4c</link>
      <guid>https://dev.to/anas_issath/my-api-was-returning-every-users-password-hash-i-didnt-notice-for-weeks-4d4c</guid>
      <description>&lt;p&gt;&lt;em&gt;Why a FastAPI endpoint happily sends back fields you never meant to expose, and the one line that filters them out.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The endpoint looked harmless. Fetch a user, return the user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.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;/users/{user_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&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="nb"&gt;int&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="k"&gt;await&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;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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. It returned the user's name, email, everything the frontend needed. Nobody looked twice.&lt;/p&gt;

&lt;p&gt;Weeks later, someone opened the browser network tab while debugging an unrelated issue and went quiet. The JSON response for that endpoint had a field none of us wanted anyone to see.&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;"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;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anna"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anna@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"password_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$2b$12$Q9...."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_admin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"internal_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flagged for review"&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;The password hash. The admin flag. An internal notes field.&lt;/strong&gt; Every column on the user row, shipped straight to the client, on every single request, for weeks.&lt;/p&gt;

&lt;p&gt;Nothing had errored. The endpoint did exactly what I told it to. The problem was that I had told it to return "the user," and "the user" turned out to mean &lt;em&gt;all of it&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here is why FastAPI did this, why it is the default, and the one line that would have stopped it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "Return the User" Actually Sent
&lt;/h2&gt;

&lt;p&gt;The mistake is assuming FastAPI only sends back the fields you have in mind. It does not. &lt;strong&gt;By default, it serializes whatever object you return, in full.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;db.get_user()&lt;/code&gt; returned a database object with every column on it, including &lt;code&gt;password_hash&lt;/code&gt;, &lt;code&gt;is_admin&lt;/code&gt;, and &lt;code&gt;internal_notes&lt;/code&gt;. When I returned that object, FastAPI turned the whole thing into JSON. Every field. It had no way to know some of those fields were secret, because I never told it which fields were allowed out.&lt;/p&gt;

&lt;p&gt;The endpoint was not leaking by accident. It was doing precisely what "return this object" means: send the object. All of it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is the trap.&lt;/strong&gt; "Return the user" sounds like it means "return the public view of the user." To FastAPI, it means "serialize this entire object and send it," and the entire object includes the things you would never put in a response on purpose.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Feature I Had Skipped: &lt;code&gt;response_model&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;FastAPI has a built-in answer to exactly this, and I had simply never used it. You declare a &lt;strong&gt;response model&lt;/strong&gt;: a separate schema describing what the response is allowed to contain. FastAPI then filters the output down to only those fields.&lt;/p&gt;

&lt;p&gt;The official docs state the behavior directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;FastAPI will take care of filtering out all the data that is not declared in the output model.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The docs even use this exact password scenario as the motivating example. They show the danger of reusing one model for input and output:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Now, whenever a browser is creating a user with a password, the API will return the same password in the response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And then the fix: one model for input (with the password), a different model for output (without 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="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;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EmailStr&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserIn&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;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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="n"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserOut&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="c1"&gt;# note: no password, no is_admin, no internal_notes
&lt;/span&gt;    &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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="n"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&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;/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;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UserOut&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UserIn&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;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs explain why this is safe even though the function returns the full input object:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;even though our path operation function is returning the same input user that contains the password … we declared the response_model to be our model UserOut, that doesn't include the password.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the whole mechanism. You return whatever you have. FastAPI validates it against &lt;code&gt;UserOut&lt;/code&gt;, keeps only the fields &lt;code&gt;UserOut&lt;/code&gt; declares, and drops everything else before it reaches the client. The password hash never leaves the server, because &lt;code&gt;UserOut&lt;/code&gt; does not mention it.&lt;/p&gt;

&lt;p&gt;Applied to my leaking endpoint, the fix was &lt;strong&gt;one decorator argument&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="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;/users/{user_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;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UserOut&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;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="nb"&gt;int&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="k"&gt;await&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;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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;        &lt;span class="c1"&gt;# still the full object; FastAPI filters it to UserOut
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code, same return value. But now the response contained only &lt;code&gt;username&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, and &lt;code&gt;full_name&lt;/code&gt;. The hash, the admin flag, and the internal notes were filtered out, every time, automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Filtering Is Opt-In, Not Automatic
&lt;/h2&gt;

&lt;p&gt;Here is the part worth sitting with, because it is the root of the danger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FastAPI does not filter by default.&lt;/strong&gt; Filtering only happens when you declare a response model (or a response return type). No declaration means no filter, which means the full object goes out.&lt;/p&gt;

&lt;p&gt;That design makes sense once you see it: FastAPI cannot guess which of your fields are public. Only you know that &lt;code&gt;email&lt;/code&gt; is fine and &lt;code&gt;password_hash&lt;/code&gt; is not. So the safe output is something you have to describe, and until you describe it, FastAPI does the literal thing and sends what you returned.&lt;/p&gt;

&lt;p&gt;The consequence is that &lt;strong&gt;forgetting the response model is not a loud error. It is a silent over-share.&lt;/strong&gt; The endpoint works, the tests pass, the frontend gets its data plus a pile of extra fields it quietly ignores. Nobody notices the extra fields until someone reads the raw response, because nothing is broken from the client's point of view. It is just handing out more than it should.&lt;/p&gt;

&lt;p&gt;That is the signature of this whole class of bug. The failure is not a crash. It is the absence of a restriction you never added.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two Ways to Declare It, and the One That Bit Me
&lt;/h2&gt;

&lt;p&gt;You can specify the allowed output in two places, and knowing both saves confusion.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On the decorator&lt;/strong&gt; with &lt;code&gt;response_model=UserOut&lt;/code&gt;. This is a parameter of the route decorator (&lt;code&gt;@app.get&lt;/code&gt;, &lt;code&gt;@app.post&lt;/code&gt;), not of your function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As the function's return type&lt;/strong&gt; with &lt;code&gt;-&amp;gt; UserOut&lt;/code&gt;. FastAPI reads the annotation and filters to it, and your editor and type checker get the benefit too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use both, the docs are explicit about precedence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you declare both a return type and a response_model, the response_model will take priority and be used by FastAPI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;The gotcha that got me:&lt;/strong&gt; I had typed my endpoint as &lt;code&gt;-&amp;gt; User&lt;/code&gt;, where &lt;code&gt;User&lt;/code&gt; was my &lt;em&gt;database&lt;/em&gt; model, thinking I was being careful with type hints. But &lt;code&gt;User&lt;/code&gt; had every field on it, so annotating with it filtered nothing.&lt;/p&gt;

&lt;p&gt;The type hint looked responsible and did nothing for safety, because the model I pointed at was the very model with the secrets in it. &lt;strong&gt;A response model only protects you if the model you name actually omits the sensitive fields.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  A Bonus the Response Model Unlocks: Trimming Unset Fields
&lt;/h2&gt;

&lt;p&gt;Once you are declaring response models, one related option is worth knowing, because it cleans up responses nicely. &lt;code&gt;response_model_exclude_unset=True&lt;/code&gt; tells FastAPI to leave out fields that were never actually set, so defaults do not clutter the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.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;/items/{item_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;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model_exclude_unset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item_id&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;return&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs describe the effect:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;those default values won't be included in the response, only the values actually set.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So an item stored as just &lt;code&gt;{"name": "Foo", "price": 50.2}&lt;/code&gt; returns exactly those two fields, instead of padding the response with every optional field at its default.&lt;/p&gt;

&lt;p&gt;There are siblings for this too, &lt;code&gt;response_model_exclude_defaults&lt;/code&gt; and &lt;code&gt;response_model_exclude_none&lt;/code&gt;, for when you want to strip defaults or nulls specifically. Minor features, but they make your responses lean once the response model is in place.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rule I Take Away
&lt;/h2&gt;

&lt;p&gt;Compressed to one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;FastAPI serializes whatever you return, in full, unless you declare a response model.&lt;/strong&gt; Returning a database object with no response model sends every field, secrets included. Declare a separate output schema with &lt;code&gt;response_model=&lt;/code&gt; (or a return type) that lists only the fields allowed out, and FastAPI filters the rest away.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The deeper lesson, the one that outlasts this specific framework, is to &lt;strong&gt;treat your output shape as something you declare on purpose&lt;/strong&gt;, never something you let fall out of your internal objects.&lt;/p&gt;

&lt;p&gt;Your database model and your API response are two different things that happen to overlap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;database model&lt;/strong&gt; is for storage and has everything.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;response&lt;/strong&gt; is a public contract and should have only what the public may see.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Collapsing the two, returning the storage object directly as the response, is how private fields escape. Keep an explicit output schema and the leak becomes impossible, because the secret fields are simply not in the schema.&lt;/p&gt;

&lt;p&gt;And the usual sting applies. This never shows up as a failure in testing, because the client gets its data and works fine. The extra fields ride along silently, and the only way to notice is to read the raw response and ask &lt;em&gt;"why is that in here?"&lt;/em&gt; Usually long after it has been going out to everyone.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Catch It Before It Catches You
&lt;/h2&gt;

&lt;p&gt;Keep these four habits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never return a raw database object from an endpoint without a response model.&lt;/strong&gt; If you return an ORM or database object and have not declared what is allowed out, you are shipping every column. Declare a &lt;code&gt;response_model&lt;/code&gt; that lists only public fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make output models separate from input and database models.&lt;/strong&gt; One schema for what comes in, a different schema for what goes out, and neither is your storage model. The output schema should physically not contain the secret fields, so there is nothing to leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not trust a return type that points at your database model.&lt;/strong&gt; &lt;code&gt;-&amp;gt; User&lt;/code&gt; filters to &lt;code&gt;User&lt;/code&gt;, and if &lt;code&gt;User&lt;/code&gt; has the password on it, you have filtered nothing. The protection comes from the named model omitting the secrets, not from having an annotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read your own raw JSON responses.&lt;/strong&gt; Open the network tab or &lt;code&gt;curl&lt;/code&gt; the endpoint and look at every field in the response. The leak is invisible from the frontend, which ignores extra fields. Reading the raw response is the only reliable way to spot an over-share.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I added one &lt;code&gt;response_model=UserOut&lt;/code&gt; to the leaking endpoint, reloaded, and watched the password hash, the admin flag, and the internal notes vanish from the response. The code still returned the full user object. FastAPI had simply, finally, been told what was allowed to leave.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;By default, FastAPI serializes whatever you return, in full.&lt;/strong&gt; Return a database object and every field goes to the client, including &lt;code&gt;password_hash&lt;/code&gt;, &lt;code&gt;is_admin&lt;/code&gt;, or anything else on the row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtering is opt-in.&lt;/strong&gt; FastAPI only limits the output when you declare a response model. No response model means no filter, so the whole object is sent. This is a silent over-share, not an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix:&lt;/strong&gt; declare a separate output schema and pass it as &lt;code&gt;response_model=UserOut&lt;/code&gt; on the decorator (or as a &lt;code&gt;-&amp;gt; UserOut&lt;/code&gt; return type). FastAPI filters out everything not declared in the output model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep input, output, and database models separate.&lt;/strong&gt; The output model should not contain the secret fields at all, so there is nothing to leak. The docs use this exact password example.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A return type pointing at your DB model protects nothing.&lt;/strong&gt; &lt;code&gt;-&amp;gt; User&lt;/code&gt; filters to &lt;code&gt;User&lt;/code&gt;; if &lt;code&gt;User&lt;/code&gt; holds the password, you have filtered nothing. Safety comes from the model omitting the secrets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bonus:&lt;/strong&gt; &lt;code&gt;response_model_exclude_unset=True&lt;/code&gt; (and &lt;code&gt;_exclude_defaults&lt;/code&gt;, &lt;code&gt;_exclude_none&lt;/code&gt;) trims unset or default fields from responses once a response model is declared.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is invisible in testing:&lt;/strong&gt; the frontend ignores extra fields and works fine. Read the raw JSON response to spot what is actually being sent.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Open your API and find every endpoint that returns a database object. For each one, check: &lt;strong&gt;is there a response model, and does it omit every sensitive field?&lt;/strong&gt; Any endpoint returning a raw ORM object with no response model is shipping every column to the client right now.&lt;/p&gt;

&lt;p&gt;Have you ever found something in a raw API response that shouldn't have been there? Tell me in the comments 👇&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>python</category>
      <category>security</category>
      <category>api</category>
    </item>
    <item>
      <title>select_related vs prefetch_related: Stop Memorizing, Start Understanding</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Thu, 29 Oct 2026 07:59:58 +0000</pubDate>
      <link>https://dev.to/anas_issath/selectrelated-vs-prefetchrelated-stop-memorizing-start-understanding-1kd3</link>
      <guid>https://dev.to/anas_issath/selectrelated-vs-prefetchrelated-stop-memorizing-start-understanding-1kd3</guid>
      <description>&lt;p&gt;&lt;em&gt;One question tells you which to use every single time, and it's not "which method do I remember?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;These two methods are how Django developers fix the &lt;strong&gt;N+1 query problem&lt;/strong&gt;, the single most common ORM performance bug. They're also the pair people most often memorize wrong, apply backwards, and never quite feel sure about. You look it up, you copy the one that seems right, the error goes away, and you move on still not really knowing &lt;em&gt;why&lt;/em&gt; it was that one.&lt;/p&gt;

&lt;p&gt;Let's end that. There's a &lt;strong&gt;single question&lt;/strong&gt; that tells you which to reach for, every time, with zero memorization. The answer isn't arbitrary: it falls directly out of how relational databases physically work. Once you see &lt;em&gt;why&lt;/em&gt; there are two methods, you'll never confuse them again.&lt;/p&gt;

&lt;p&gt;We'll start with the problem they both solve, then the one question, then the two traps that catch even experienced developers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem They Both Solve (a 60-Second Refresher)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3iyzw607vkjyzd8c7hay.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3iyzw607vkjyzd8c7hay.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You list 100 books and print each author's name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;        &lt;span class="c1"&gt;# 1 query for the books
&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;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# 1 query PER book to get the author
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;strong&gt;101 queries&lt;/strong&gt;: one for the books, then one more every time you touch &lt;code&gt;book.author&lt;/code&gt;, because Django didn't load the authors up front and has to run out to the database for each one.&lt;/p&gt;

&lt;p&gt;This is the N+1 problem, and both &lt;code&gt;select_related&lt;/code&gt; and &lt;code&gt;prefetch_related&lt;/code&gt; exist to collapse those hundred extra queries down to one or two. They're the same goal (&lt;em&gt;"load the related data efficiently, up front"&lt;/em&gt;) achieved two different ways.&lt;/p&gt;

&lt;p&gt;The only question is &lt;em&gt;which&lt;/em&gt; way. And that's where people flail. So here's the question that settles it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The One Question: Does This Relationship Point to ONE Thing, or MANY Things?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo5btefmzw2pzdigb8u0d.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo5btefmzw2pzdigb8u0d.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's it. That's the whole decision. Forget the method names for a second and just look at the &lt;strong&gt;shape&lt;/strong&gt; of the relationship you're crossing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Does each object point to exactly ONE related object?&lt;/strong&gt; → &lt;code&gt;select_related&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;Does each object point to MANY related objects?&lt;/strong&gt; → &lt;code&gt;prefetch_related&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Map it to Django's field types and it's completely mechanical:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Relationship&lt;/th&gt;
&lt;th&gt;Field type&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A book has one author&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ForeignKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One&lt;/td&gt;
&lt;td&gt;&lt;code&gt;select_related&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A user has one profile&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OneToOneField&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One&lt;/td&gt;
&lt;td&gt;&lt;code&gt;select_related&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An author has many books&lt;/td&gt;
&lt;td&gt;Reverse &lt;code&gt;ForeignKey&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prefetch_related&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A post has many tags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ManyToManyField&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prefetch_related&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You don't memorize &lt;em&gt;"select_related is for ForeignKey."&lt;/em&gt; You ask &lt;strong&gt;"one or many?"&lt;/strong&gt; and the field type answers it for you. ForeignKey and OneToOne point at one thing; reverse-ForeignKey and ManyToMany point at many. &lt;strong&gt;One → select. Many → prefetch.&lt;/strong&gt; Done.&lt;/p&gt;

&lt;p&gt;But &lt;em&gt;why&lt;/em&gt;? Why does "one vs many" force different methods? Because of how a database actually combines tables, and understanding that is what makes this permanent instead of memorized.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "One" Gets a JOIN (&lt;code&gt;select_related&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;select_related&lt;/code&gt; works by adding a SQL &lt;strong&gt;JOIN&lt;/strong&gt;. It fetches the object and its related object together, side by side, in a single 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="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ONE query, with a JOIN
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;# already loaded - no extra query
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQL is essentially &lt;em&gt;"get each book and glue its author's columns onto the same row."&lt;/em&gt; That works beautifully when each book has &lt;strong&gt;one&lt;/strong&gt; author, because gluing one author onto one book row keeps things tidy: 100 books → 100 rows, each carrying its author. One clean query, no duplication.&lt;/p&gt;

&lt;p&gt;This is why &lt;code&gt;select_related&lt;/code&gt; is &lt;strong&gt;restricted to single-valued relationships&lt;/strong&gt; (ForeignKey, OneToOne). Django's own docs are explicit that it's limited to single-valued relationships, and the reason is the next section. A JOIN is the right tool precisely &lt;em&gt;because&lt;/em&gt; there's one thing on the other side to glue on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "Many" Can't Use a JOIN, and Gets a Second Query Instead (&lt;code&gt;prefetch_related&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr9qcx0wjiicf8pakpjxx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr9qcx0wjiicf8pakpjxx.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now picture joining across a &lt;strong&gt;many&lt;/strong&gt; relationship. An author has 50 books. If you JOIN author-to-books, the database can't put 50 books on one author row, so it &lt;strong&gt;duplicates the author across 50 rows&lt;/strong&gt;, one per book.&lt;/p&gt;

&lt;p&gt;Do that for 100 authors with 50 books each and your result set explodes into &lt;strong&gt;5,000 rows&lt;/strong&gt;, most of it the same author data repeated over and over. That's wasteful and slow, and it's exactly why Django refuses to use a JOIN here. The docs describe it as avoiding "the much larger result set" that joining across a many-relationship produces.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;prefetch_related&lt;/code&gt; takes a completely different strategy: &lt;strong&gt;two separate queries, joined in Python.&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;authors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;books&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# TWO queries, not a JOIN
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;                  &lt;span class="c1"&gt;# already loaded - no extra query
&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;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the mechanism, and it's clever:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Django runs &lt;strong&gt;query #1&lt;/strong&gt; to get all the authors.&lt;/li&gt;
&lt;li&gt;Then it runs &lt;strong&gt;query #2&lt;/strong&gt;: a single query that grabs all the books belonging to all those authors at once (&lt;code&gt;WHERE author_id IN (...)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Then, &lt;strong&gt;in Python memory&lt;/strong&gt;, Django matches each book to its author.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two queries total, no matter how many authors or books, and no exploded result set, because there's no JOIN. Each object's data is fetched exactly once.&lt;/p&gt;

&lt;p&gt;That's the whole "why":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One relationship&lt;/strong&gt; → a JOIN is efficient → &lt;code&gt;select_related&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many relationship&lt;/strong&gt; → a JOIN explodes → separate query matched in Python → &lt;code&gt;prefetch_related&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two methods aren't arbitrary API choices you memorize. They're the only two sensible ways to load related data, each matched to the relationship shape that makes it efficient.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The reframing to keep:&lt;/strong&gt; you're not choosing a method. You're identifying a &lt;em&gt;shape&lt;/em&gt; (one or many), and the correct method is forced by how databases handle that shape.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Following Chains, and Combining Both
&lt;/h2&gt;

&lt;p&gt;Real queries cross several relationships, and both methods follow chains with the double-underscore syntax:&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;# Follow FK chain: book → author → publisher (all "one" hops)
&lt;/span&gt;&lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author__publisher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Combine them: each book has ONE author (select) and MANY tags (prefetch)
&lt;/span&gt;&lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the real-world pattern. A single query is usually a mix of "one" hops and "many" hops, so you &lt;code&gt;select_related&lt;/code&gt; the single-valued ones and &lt;code&gt;prefetch_related&lt;/code&gt; the multi-valued ones in the same queryset. Apply the &lt;em&gt;"one or many?"&lt;/em&gt; question to each relationship you touch, and chain the right tool for each.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trap 1: Filtering a Prefetch Silently Re-Queries (the One That Catches Everyone)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2plr2w5k74h7kijtyuea.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2plr2w5k74h7kijtyuea.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the trap that bites experienced developers, and it's worth internalizing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prefetch_related&lt;/code&gt; caches the related objects and attaches them to the parents, so &lt;code&gt;author.books.all()&lt;/code&gt; reads from the cache, no extra query. But &lt;strong&gt;the instant you &lt;em&gt;filter&lt;/em&gt; the prefetched relationship, Django ignores the cache and runs a fresh query per parent:&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;authors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;books&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                    &lt;span class="c1"&gt;# ✅ uses the prefetch cache - no query
&lt;/span&gt;    &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;published&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ❌ IGNORES the cache - new query PER author
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because Django prefetched &lt;em&gt;all&lt;/em&gt; the books, but it can't know in advance which filter you'll apply, so a new &lt;code&gt;.filter()&lt;/code&gt; can't be served from the "all books" cache. It goes back to the database. &lt;strong&gt;You've silently reintroduced the N+1 you were trying to kill.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is the &lt;strong&gt;&lt;code&gt;Prefetch&lt;/code&gt; object&lt;/strong&gt;, which lets you customize the query used for prefetching (including filtering it) and store the result under a new attribute name with &lt;code&gt;to_attr&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Prefetch&lt;/span&gt;

&lt;span class="n"&gt;authors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Prefetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;books&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;queryset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;published&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;to_attr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;published_books&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# store the filtered result here
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;published_books&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# ✅ pre-filtered, cached - no extra query
&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;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the filtered set is fetched efficiently up front and cached under &lt;code&gt;published_books&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Bonus: because &lt;code&gt;to_attr&lt;/code&gt; stores it in its own attribute, you can prefetch the &lt;strong&gt;same relationship multiple times with different filters&lt;/strong&gt; (e.g. &lt;code&gt;published_books&lt;/code&gt; and &lt;code&gt;draft_books&lt;/code&gt;) without them clobbering each other.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Any time you catch yourself writing &lt;code&gt;.filter()&lt;/code&gt; on a prefetched relationship inside a loop, reach for &lt;code&gt;Prefetch&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Trap 2: &lt;code&gt;prefetch_related&lt;/code&gt; Can Do ForeignKey, but You Usually Shouldn't
&lt;/h2&gt;

&lt;p&gt;A subtle point: &lt;code&gt;prefetch_related&lt;/code&gt; technically works on single-valued relationships too (ForeignKey, OneToOne). But for those, &lt;code&gt;select_related&lt;/code&gt; is more efficient, because one JOIN beats two separate queries when the JOIN doesn't cause duplication.&lt;/p&gt;

&lt;p&gt;So &lt;em&gt;"it works"&lt;/em&gt; isn't &lt;em&gt;"it's right."&lt;/em&gt; Keep the rule clean: &lt;strong&gt;single-valued → &lt;code&gt;select_related&lt;/code&gt;&lt;/strong&gt; (unless you have a specific reason not to JOIN, like joining would produce an enormous row set for other reasons). The &lt;em&gt;"one or many?"&lt;/em&gt; question still gives you the right default every time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Understanding This Beats Memorizing It
&lt;/h2&gt;

&lt;p&gt;Almost every developer eventually memorizes &lt;em&gt;"select_related for ForeignKey, prefetch_related for ManyToMany."&lt;/em&gt; That surface rule works, right up until it doesn't.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;reverse ForeignKey&lt;/strong&gt; (which is "many" but still a ForeignKey) trips the memorizers up, because their rule was about field &lt;em&gt;names&lt;/em&gt;, not relationship &lt;em&gt;shapes&lt;/em&gt;. The person who memorized sees "ForeignKey," reaches for &lt;code&gt;select_related&lt;/code&gt;, and gets an error or a bad query. The person who understands asks &lt;em&gt;"does an author point to one book or many?"&lt;/em&gt;, sees "many," and reaches for &lt;code&gt;prefetch_related&lt;/code&gt; without hesitation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Same field type, opposite answer, and only the mental model gets it right.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the whole reason this article exists. The two methods look like trivia to memorize, but they're actually a direct, physical consequence of how relational databases combine data. A JOIN is efficient for one-to-one gluing and disastrous for one-to-many multiplication, so Django gives you a JOIN-based tool for the first shape and a separate-query tool for the second.&lt;/p&gt;

&lt;p&gt;Hold that (&lt;em&gt;one gets a JOIN, many gets a second query, because that's what the database can do efficiently&lt;/em&gt;) and you never have to look it up again. You'll just glance at a relationship, see its shape, and know. That's the difference between &lt;em&gt;using&lt;/em&gt; the ORM and &lt;em&gt;understanding&lt;/em&gt; it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Both &lt;code&gt;select_related&lt;/code&gt; and &lt;code&gt;prefetch_related&lt;/code&gt; fix the N+1 query problem&lt;/strong&gt;: loading related data up front instead of one query per object. The only question is which to use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one question:&lt;/strong&gt; does the relationship point to ONE thing or MANY? One → &lt;code&gt;select_related&lt;/code&gt;. Many → &lt;code&gt;prefetch_related&lt;/code&gt;. The field type answers it: ForeignKey/OneToOne point to one; reverse-ForeignKey/ManyToMany point to many.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;select_related&lt;/code&gt; uses a SQL JOIN&lt;/strong&gt; (single query), efficient for "one" relationships because gluing one related row onto each object stays tidy. It's limited to single-valued relationships for exactly this reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prefetch_related&lt;/code&gt; uses a separate query matched in Python&lt;/strong&gt;, because JOINing a "many" relationship would explode the result set (an author repeated across 50 book rows). It runs 2 queries total and matches in memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chain and combine:&lt;/strong&gt; follow relations with &lt;code&gt;author__publisher&lt;/code&gt;, and mix &lt;code&gt;select_related("author").prefetch_related("tags")&lt;/code&gt; when a query has both shapes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trap 1:&lt;/strong&gt; calling &lt;code&gt;.filter()&lt;/code&gt; on a prefetched relationship ignores the cache and re-queries per object. Use a &lt;code&gt;Prefetch&lt;/code&gt; object (with &lt;code&gt;to_attr&lt;/code&gt;) to filter efficiently up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trap 2:&lt;/strong&gt; &lt;code&gt;prefetch_related&lt;/code&gt; works on ForeignKeys too, but &lt;code&gt;select_related&lt;/code&gt; is more efficient there. Keep single-valued → &lt;code&gt;select_related&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't memorize field-name rules&lt;/strong&gt; (they break on reverse ForeignKeys). Understand the shape: one gets a JOIN, many gets a second query, because that's what the database does efficiently.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Next time you write a queryset, don't reach for a remembered method. Glance at each relationship and ask &lt;strong&gt;"one or many?"&lt;/strong&gt; The right tool falls out on its own, and the N+1 problem stops being something you fix and starts being something you never create.&lt;/p&gt;

&lt;p&gt;Has the prefetch-filter trap ever bitten you? Tell me in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>orm</category>
      <category>performance</category>
    </item>
    <item>
      <title>Transaction Isolation Levels, Explained Without the Jargon</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Wed, 28 Oct 2026 09:49:15 +0000</pubDate>
      <link>https://dev.to/anas_issath/transaction-isolation-levels-explained-without-the-jargon-1jcb</link>
      <guid>https://dev.to/anas_issath/transaction-isolation-levels-explained-without-the-jargon-1jcb</guid>
      <description>&lt;p&gt;&lt;em&gt;Dirty reads, phantom reads, and the setting that quietly decides how safe your Django data really is.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every Django developer uses database transactions. Far fewer know that transactions come with a dial: a setting called the &lt;strong&gt;isolation level&lt;/strong&gt; that silently decides how much two simultaneous transactions are allowed to see of each other's half-finished work.&lt;/p&gt;

&lt;p&gt;Turn the dial one way and your app is fast but can read data that's wrong or half-baked. Turn it the other way and your data is rock-solid but slower and prone to conflicts. Most people never touch this dial, never learn it exists, and inherit its default without knowing what it protects them from, or doesn't.&lt;/p&gt;

&lt;p&gt;This article is the plain-English tour of that dial. We'll cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The three main things that can go wrong when transactions overlap (the "anomalies")&lt;/li&gt;
&lt;li&gt;The four isolation levels that prevent progressively more of them&lt;/li&gt;
&lt;li&gt;What Django defaults to and why&lt;/li&gt;
&lt;li&gt;When you'd actually change it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No SQL-standard jargon dumps, just the mental model.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Trade-Off in One Sentence
&lt;/h2&gt;

&lt;p&gt;Here's the whole topic compressed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Isolation level is the trade-off between concurrency (speed) and consistency (safety) when multiple transactions run at the same time.&lt;/strong&gt; Higher isolation prevents more data anomalies but reduces performance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. Everything else is detail.&lt;/p&gt;

&lt;p&gt;A database &lt;em&gt;could&lt;/em&gt; run every transaction one-at-a-time, perfectly isolated, never interfering, but that would be catastrophically slow. So instead databases let transactions overlap for speed, and the isolation level is how you choose how much overlap-weirdness you're willing to tolerate in exchange for that speed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lower isolation&lt;/strong&gt; = faster, but you might see anomalies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher isolation&lt;/strong&gt; = safer, but slower and more conflicts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To choose intelligently, you need to know what the "anomalies" actually are. There are three main ones, and they're easier to understand than their names suggest.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Things That Can Go Wrong
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flbczt358zyqq2ylejd93.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flbczt358zyqq2ylejd93.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Picture two transactions, &lt;strong&gt;A&lt;/strong&gt; and &lt;strong&gt;B&lt;/strong&gt;, running at the same time. Here's what can go wrong, from worst to subtlest.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Dirty read: reading someone's uncommitted work
&lt;/h3&gt;

&lt;p&gt;Transaction A deducts $100 from an account but &lt;strong&gt;hasn't committed yet&lt;/strong&gt;. It might still fail and roll back. Transaction B reads the balance right now and sees the lowered amount. Then A crashes and rolls back: the $100 was never actually deducted. But B already read it and acted on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B read data that never truly existed.&lt;/strong&gt; That's a dirty read: seeing another transaction's uncommitted, possibly-about-to-vanish changes. It's the most dangerous anomaly, because you're acting on phantom data.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Non-repeatable read: the same row changes under you
&lt;/h3&gt;

&lt;p&gt;Transaction B reads Maria's balance: $0. A moment later, within the same transaction, B reads it again, and now it's $100, because transaction A committed a change in between.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B asked the exact same question twice and got two different answers.&lt;/strong&gt; A row B already read got modified by someone else mid-transaction. For logic that reads a value, does some work, then re-reads expecting it to be stable, this is a real hazard.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Phantom read: new rows appear
&lt;/h3&gt;

&lt;p&gt;Transaction B runs &lt;em&gt;"count all active orders"&lt;/em&gt; and gets 10. It does some work, runs the same count again, and now it's 11, because transaction A inserted a new matching row in between.&lt;/p&gt;

&lt;p&gt;No row B read changed. Instead, &lt;strong&gt;a new row appeared that matches B's query.&lt;/strong&gt; The &lt;em&gt;set&lt;/em&gt; of rows matching a condition changed mid-transaction. It's the "many rows" cousin of the non-repeatable read.&lt;/p&gt;

&lt;p&gt;(There's a fourth, dirtiest-of-all case, &lt;em&gt;dirty write&lt;/em&gt;, that all levels prevent, so we'll set it aside.)&lt;/p&gt;

&lt;p&gt;Notice these get progressively subtler: reading uncommitted garbage → a value shifting → the row set shifting. The isolation levels are simply a ladder that blocks more of these as you climb.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Four Isolation Levels: A Ladder
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiu0pz9dx9m1prxev5ijd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiu0pz9dx9m1prxev5ijd.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each level blocks everything the levels below it block, plus one more anomaly. That's the whole structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Read Uncommitted (the bottom rung)
&lt;/h3&gt;

&lt;p&gt;Blocks almost nothing. It even permits dirty reads: a transaction can see other transactions' uncommitted changes. It's the fastest and the least safe, and in practice it's rarely used for anything that matters. (PostgreSQL doesn't even truly implement it; it treats it as Read Committed.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Read Committed (the practical baseline)
&lt;/h3&gt;

&lt;p&gt;You can only read data that has been &lt;strong&gt;committed&lt;/strong&gt;: successfully saved. This entirely prevents dirty reads. You'll never read someone's half-finished, about-to-be-rolled-back work.&lt;/p&gt;

&lt;p&gt;But it still permits non-repeatable reads and phantom reads. Read the same row twice in one transaction and it can change, because another transaction committed in between.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;default in PostgreSQL, and Django's default&lt;/strong&gt;, because it's the sweet spot for the vast majority of web apps: no reading of garbage data, minimal locking, high concurrency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Repeatable Read
&lt;/h3&gt;

&lt;p&gt;Guarantees that if you read a row, reading it again in the same transaction gives the &lt;strong&gt;same value&lt;/strong&gt;. The row is effectively frozen for your transaction's view. This blocks dirty and non-repeatable reads.&lt;/p&gt;

&lt;p&gt;In classic theory it still allows phantom reads, though PostgreSQL's implementation is stricter and prevents most of those too. The cost: more locking/versioning overhead and more chance of your transaction hitting a conflict and having to retry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Serializable (the top rung)
&lt;/h3&gt;

&lt;p&gt;The strongest: transactions behave &lt;strong&gt;as if they ran one after another&lt;/strong&gt;, completely alone, in some serial order. It blocks all three anomalies, including phantoms.&lt;/p&gt;

&lt;p&gt;It's the safest and the slowest, and it produces the most &lt;strong&gt;serialization failures&lt;/strong&gt;. The database detects that two transactions can't both commit without violating the "as if serial" guarantee, and it aborts one, which your code must catch and retry. You use this when correctness is non-negotiable and you're prepared to retry.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ladder in one glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Isolation level&lt;/th&gt;
&lt;th&gt;Dirty read&lt;/th&gt;
&lt;th&gt;Non-repeatable read&lt;/th&gt;
&lt;th&gt;Phantom read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read Uncommitted&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read Committed&lt;/td&gt;
&lt;td&gt;✅ Blocked&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;td&gt;Possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeatable Read&lt;/td&gt;
&lt;td&gt;✅ Blocked&lt;/td&gt;
&lt;td&gt;✅ Blocked&lt;/td&gt;
&lt;td&gt;Possible*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serializable&lt;/td&gt;
&lt;td&gt;✅ Blocked&lt;/td&gt;
&lt;td&gt;✅ Blocked&lt;/td&gt;
&lt;td&gt;✅ Blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;*In classic theory. PostgreSQL's Repeatable Read prevents most phantoms too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Higher = safer + slower + more retries.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What Django Actually Does
&lt;/h2&gt;

&lt;p&gt;Two things every Django developer should know about how this plays out in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Django defaults to READ COMMITTED, matching PostgreSQL
&lt;/h3&gt;

&lt;p&gt;From the official docs: like PostgreSQL itself, Django defaults to the &lt;code&gt;READ COMMITTED&lt;/code&gt; isolation level. So unless you've changed it, every transaction in your app reads only committed data (no dirty reads) but can see non-repeatable and phantom reads. For most apps, that's exactly right.&lt;/p&gt;

&lt;h3&gt;
  
  
  You change it in the &lt;code&gt;DATABASES&lt;/code&gt; &lt;code&gt;OPTIONS&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Per the current docs, use the &lt;code&gt;IsolationLevel&lt;/code&gt; enum rather than a raw string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db.backends.postgresql.psycopg_any&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;IsolationLevel&lt;/span&gt;

&lt;span class="n"&gt;DATABASES&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;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENGINE&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;django.db.backends.postgresql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# ... name, user, etc.
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPTIONS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isolation_level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IsolationLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;READ_COMMITTED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# the default
&lt;/span&gt;            &lt;span class="c1"&gt;# or REPEATABLE_READ / SERIALIZABLE for stronger guarantees
&lt;/span&gt;        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sets the default for &lt;strong&gt;all&lt;/strong&gt; transactions on that connection.&lt;/p&gt;

&lt;p&gt;If you only need a stronger level for a specific operation (the common case), the clean approach is a &lt;strong&gt;second database alias&lt;/strong&gt; pointed at the same database but with a higher isolation level, and route the sensitive transaction through it. Or use a library like &lt;code&gt;django-pgtransaction&lt;/code&gt;, which extends &lt;code&gt;transaction.atomic()&lt;/code&gt; with a per-block &lt;code&gt;isolation_level&lt;/code&gt; argument and built-in retry handling for the serialization failures that stronger levels cause.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ MySQL warning (it bites people):&lt;/strong&gt; MySQL's own default is &lt;code&gt;REPEATABLE READ&lt;/code&gt;, but Django works best with and defaults to &lt;code&gt;READ COMMITTED&lt;/code&gt;. The docs explicitly warn that with MySQL's repeatable read, &lt;strong&gt;data loss is possible&lt;/strong&gt;: you can see cases where &lt;code&gt;get_or_create()&lt;/code&gt; raises an &lt;code&gt;IntegrityError&lt;/code&gt; but the object won't appear in a subsequent &lt;code&gt;get()&lt;/code&gt;. If you're on MySQL, let Django set &lt;code&gt;READ COMMITTED&lt;/code&gt; rather than leaving MySQL's default in place.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  When You'd Actually Change the Dial
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fun4ohc8aounxiodycj4v.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fun4ohc8aounxiodycj4v.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The honest answer for most apps: &lt;strong&gt;leave it at Read Committed.&lt;/strong&gt; It's the default for good reason. But there are real cases to climb the ladder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Financial/consistency-critical operations&lt;/strong&gt; where you read several values, make a decision, and write, and a value shifting underneath you (non-repeatable read) or new rows appearing (phantom) would produce a wrong result. Bumping that transaction to &lt;code&gt;SERIALIZABLE&lt;/code&gt; (with retry logic) buys you correctness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reports/analytics that must be a consistent snapshot&lt;/strong&gt;: reading many tables that must all reflect the same instant in time. &lt;code&gt;REPEATABLE READ&lt;/code&gt; gives you a stable view for the duration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And two crucial caveats before you reach for a higher level:&lt;/p&gt;

&lt;h3&gt;
  
  
  Higher isolation is not free, and it's not a substitute for locking or atomic updates
&lt;/h3&gt;

&lt;p&gt;For a lot of the concurrency bugs people try to solve with isolation levels (like a counter losing increments, or a limit check letting one too many through), the better tools are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An atomic &lt;code&gt;F()&lt;/code&gt; update&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;select_for_update()&lt;/code&gt; row lock&lt;/li&gt;
&lt;li&gt;A database constraint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are targeted, cheaper, and clearer than cranking the whole transaction to serializable. &lt;strong&gt;Isolation level is the blunt, global dial; those are the precise instruments.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Higher levels mean retries are mandatory
&lt;/h3&gt;

&lt;p&gt;Serializable &lt;em&gt;will&lt;/em&gt; abort transactions with serialization failures under contention. If you raise the level without wrapping the operation in retry logic, you've traded silent anomalies for loud crashes. That's often the right trade, but only if you actually handle the retries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Understanding This Makes You Better at Django
&lt;/h2&gt;

&lt;p&gt;Isolation levels sit underneath a whole family of &lt;em&gt;"how is this data wrong?"&lt;/em&gt; bugs, and most developers never connect the symptom to this cause because the dial is invisible and defaulted.&lt;/p&gt;

&lt;p&gt;The developer who understands it can look at a flaky, concurrency-related data bug and reason precisely:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I'm reading this value twice in one transaction and it's changing. That's a non-repeatable read, which Read Committed allows. I either need Repeatable Read here, or better, a row lock."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The developer who doesn't just sees "sometimes the number is wrong" and starts adding random &lt;code&gt;time.sleep&lt;/code&gt;s and prayer.&lt;/p&gt;

&lt;p&gt;And the mental model is genuinely simple once the jargon is stripped away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Databases let transactions overlap for speed.&lt;/li&gt;
&lt;li&gt;Overlapping causes three flavors of weirdness: reading uncommitted data, a row changing under you, new rows appearing.&lt;/li&gt;
&lt;li&gt;The isolation level is a four-rung ladder where each rung costs performance to block one more flavor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Django hands you Read Committed by default (no dirty reads, high concurrency) and lets you climb higher for the specific transactions that need it, ideally alongside the sharper tools (&lt;code&gt;F()&lt;/code&gt;, &lt;code&gt;select_for_update&lt;/code&gt;, constraints) for the specific races those solve better.&lt;/p&gt;

&lt;p&gt;Know the ladder, and &lt;em&gt;"the data is mysteriously wrong under load"&lt;/em&gt; turns into a diagnosis with a name and a fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftskqvm2vmgxmtv7qlcgy.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftskqvm2vmgxmtv7qlcgy.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolation level is the dial trading concurrency (speed) against consistency (safety)&lt;/strong&gt; when transactions overlap. Higher = safer but slower and more retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three anomalies, subtlest-increasing:&lt;/strong&gt; dirty read (reading uncommitted, maybe-rolled-back data) → non-repeatable read (a row you read changes mid-transaction) → phantom read (new rows matching your query appear mid-transaction).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four levels, a ladder:&lt;/strong&gt; Read Uncommitted (blocks nothing) → Read Committed (blocks dirty reads) → Repeatable Read (also blocks non-repeatable reads) → Serializable (also blocks phantoms, "as if run one at a time").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Django defaults to &lt;code&gt;READ COMMITTED&lt;/code&gt;&lt;/strong&gt; (like PostgreSQL): no dirty reads, but non-repeatable and phantom reads are possible. Great for most web apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change it in &lt;code&gt;DATABASES["OPTIONS"]["isolation_level"]&lt;/code&gt;&lt;/strong&gt; using the &lt;code&gt;IsolationLevel&lt;/code&gt; enum. For one-off stronger transactions, use a second DB alias or &lt;code&gt;django-pgtransaction&lt;/code&gt; (which adds per-block level + retries).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL warning:&lt;/strong&gt; its default is Repeatable Read, but Django prefers Read Committed. The docs warn Repeatable Read can cause data loss (e.g. &lt;code&gt;get_or_create&lt;/code&gt; raising &lt;code&gt;IntegrityError&lt;/code&gt; then the object not appearing in &lt;code&gt;get()&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serializable requires retry logic&lt;/strong&gt; (it aborts conflicting transactions). And for specific races (lost updates, limit checks), prefer targeted tools (&lt;code&gt;F()&lt;/code&gt;, &lt;code&gt;select_for_update()&lt;/code&gt;, constraints) over cranking the global isolation dial.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Next time a number is "sometimes wrong" under load, don't reach for a random sleep. &lt;strong&gt;Name the anomaly.&lt;/strong&gt; Is a value changing mid-transaction, or are new rows appearing? The anomaly tells you whether you need a higher isolation level or a sharper tool like a row lock.&lt;/p&gt;

&lt;p&gt;Have you ever changed the isolation level in production? Tell me in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>postgres</category>
      <category>database</category>
    </item>
    <item>
      <title>How Django Actually Manages Database Connections</title>
      <dc:creator>Anas Issath</dc:creator>
      <pubDate>Mon, 26 Oct 2026 01:46:05 +0000</pubDate>
      <link>https://dev.to/anas_issath/how-django-actually-manages-database-connections-2a0h</link>
      <guid>https://dev.to/anas_issath/how-django-actually-manages-database-connections-2a0h</guid>
      <description>&lt;p&gt;&lt;em&gt;Per-request, persistent, or pooled: the three modes, the setting everyone misconfigures, and the 2026 feature that changes the default advice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's a question most Django developers can't answer confidently: &lt;strong&gt;when your view runs a query, where does the database connection come from, and what happens to it when the request ends?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We write &lt;code&gt;Model.objects.get(...)&lt;/code&gt; thousands of times without ever thinking about the connection underneath. And that's fine, until the day your app is under load, connections become the bottleneck, and you're staring at settings named &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; and &lt;code&gt;pool&lt;/code&gt; with no mental model for which one you need or why they conflict.&lt;/p&gt;

&lt;p&gt;Let's build that model. By the end you'll understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exactly how Django opens, holds, and closes database connections&lt;/li&gt;
&lt;li&gt;The three modes you can run in&lt;/li&gt;
&lt;li&gt;The one setting people constantly misconfigure&lt;/li&gt;
&lt;li&gt;The 2026 change (native connection pooling) that shifts the standard advice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is straight from the official docs, current for Django 6.x.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Foundation: A Connection Is Expensive, and Django Opens One Lazily
&lt;/h2&gt;

&lt;p&gt;First, why any of this matters. &lt;strong&gt;A database connection isn't free to create.&lt;/strong&gt; Opening one means a network handshake, authentication, and session setup on the database server. On PostgreSQL, each connection is backed by a real server-side process holding memory.&lt;/p&gt;

&lt;p&gt;Establishing a connection can take meaningful milliseconds before a single query runs. So how often Django opens connections directly affects your latency and your database's load.&lt;/p&gt;

&lt;p&gt;Now, how Django gets one. Per the docs: &lt;strong&gt;Django opens a connection to the database when it first makes a database query.&lt;/strong&gt; Not when the request starts, but lazily, the moment a query actually needs it. A view that never touches the database never opens a connection. Once open, Django keeps that connection and uses it for the rest of the work that needs it.&lt;/p&gt;

&lt;p&gt;The real question, the one that defines the three modes, is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What happens to that connection when the request finishes?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Mode 1: Per-Request Connections (the Historical Default)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fch0sp02cn299sii29ip1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fch0sp02cn299sii29ip1.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By default, Django's answer is blunt: &lt;strong&gt;close the connection at the end of every request.&lt;/strong&gt; Open it when the first query runs, use it for the duration of that request, throw it away when the response is sent. Next request? Open a brand-new one.&lt;/p&gt;

&lt;p&gt;This is controlled by a setting called &lt;code&gt;CONN_MAX_AGE&lt;/code&gt;, and its default value is &lt;code&gt;0&lt;/code&gt;, which per the docs preserves the historical behavior of closing the database connection at the end of each request.&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 default - a fresh connection per request, closed at the end
&lt;/span&gt;&lt;span class="n"&gt;DATABASES&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;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENGINE&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;django.db.backends.postgresql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# CONN_MAX_AGE defaults to 0 → open, use, close, every request
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's simple and safe: no stale connections, no leaks across requests. But it's also &lt;strong&gt;wasteful under load&lt;/strong&gt;. If you're serving hundreds of requests per second, you're paying that connection-setup cost (handshake, auth, session) hundreds of times per second, for connections you use for a few milliseconds and discard.&lt;/p&gt;

&lt;p&gt;That repeated open/close is pure overhead. Which leads to the first optimization.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mode 2: Persistent Connections (&lt;code&gt;CONN_MAX_AGE&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The idea: instead of throwing the connection away at the end of each request, &lt;strong&gt;keep it open and reuse it for the next request.&lt;/strong&gt; That's a persistent connection, and per the docs it exists precisely to avoid the overhead of re-establishing a connection to the database in each HTTP request.&lt;/p&gt;

&lt;p&gt;You enable it by setting &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; to a positive number of seconds: the maximum lifetime of a connection before Django closes and replaces it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DATABASES&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;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENGINE&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;django.db.backends.postgresql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONN_MAX_AGE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;# reuse each connection for up to 60s
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONN_HEALTH_CHECKS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# revalidate a reused connection per request
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now Django opens a connection, and at the start of each request it checks: &lt;em&gt;has this connection exceeded its max age?&lt;/em&gt; If not, reuse it. If yes (or if it's broken), close it and open a fresh one.&lt;/p&gt;

&lt;p&gt;You've turned "open a connection per request" into "open a connection per minute," eliminating the vast majority of that setup overhead.&lt;/p&gt;

&lt;p&gt;Two things the docs are firm about, and both bite people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each thread keeps its own connection.&lt;/strong&gt; A persistent connection belongs to one worker thread. So your database must allow at least as many simultaneous connections as you have worker threads across all your app processes. Twenty Gunicorn workers with &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; set means twenty held-open connections sitting on your database even when idle. Do the math against your database's connection limit (PostgreSQL defaults to 100).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;CONN_HEALTH_CHECKS = True&lt;/code&gt;.&lt;/strong&gt; A held-open connection can go dead: the database restarted, a proxy killed an idle connection. Health checks revalidate a reused connection once per request, so you don't hand a stale connection to your query and get an error. And if your database kills idle connections after some time, keep &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; &lt;em&gt;below&lt;/em&gt; that timeout so Django doesn't try to use a connection the server already dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Persistent connections were the standard performance win for years. But they have a limitation that native pooling now addresses, and this is the 2026 part.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mode 3: Native Connection Pooling (the 2026 Change)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23mlw7sewszp6l6mzi2g.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23mlw7sewszp6l6mzi2g.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the limitation of persistent connections: they reduce how &lt;em&gt;often&lt;/em&gt; you open connections, but they don't &lt;em&gt;share&lt;/em&gt; connections intelligently.&lt;/p&gt;

&lt;p&gt;Each thread still monopolizes its own connection for the entire request, &lt;strong&gt;including the parts of the request that aren't touching the database at all.&lt;/strong&gt; A request that runs one quick &lt;code&gt;SELECT&lt;/code&gt; and then spends 200ms calling an external API keeps its database connection pinned that whole 200ms, doing nothing, unavailable to anyone else.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;connection pool&lt;/strong&gt; fixes that. A small, shared set of connections is opened once and kept warm. A request borrows one only for the moment it needs it and returns it immediately, so a handful of connections can serve far more concurrent requests than persistent connections could.&lt;/p&gt;

&lt;p&gt;Since &lt;strong&gt;Django 5.1&lt;/strong&gt;, this is built in for PostgreSQL (via psycopg 3). No third-party library needed. You enable it in &lt;code&gt;OPTIONS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DATABASES&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;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ENGINE&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;django.db.backends.postgresql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONN_MAX_AGE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;# REQUIRED - see the trap below
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPTIONS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min_size&lt;/span&gt;&lt;span class="sh"&gt;"&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;# keep this many connections warm
&lt;/span&gt;                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# cap under load
&lt;/span&gt;            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;"pool": True&lt;/code&gt; accepts sensible defaults if you don't want to tune it.)&lt;/p&gt;

&lt;p&gt;Reported latency improvements are real. Teams cite cutting tens of milliseconds off database-touching requests by not paying setup cost per request and not pinning connections during non-database work.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Trap Everyone Hits: Pooling and Persistent Connections Are Mutually Exclusive
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0e5b1xeio03kawhhxthk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0e5b1xeio03kawhhxthk.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the single most important configuration fact in the article, because &lt;strong&gt;it crashes apps on startup.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You cannot use native pooling and persistent connections at the same time. If you set &lt;code&gt;"pool"&lt;/code&gt; in &lt;code&gt;OPTIONS&lt;/code&gt; without also setting &lt;code&gt;CONN_MAX_AGE = 0&lt;/code&gt;, Django refuses to start and throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ImproperlyConfigured: Pooling doesn't support persistent connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason is clean once you see it. The pool manages connection lifetime itself (via its own &lt;code&gt;max_lifetime&lt;/code&gt; / &lt;code&gt;max_idle&lt;/code&gt; settings), so Django's persistent-connection lifetime management (&lt;code&gt;CONN_MAX_AGE&lt;/code&gt;) would be a second, conflicting manager fighting over the same connections. They're two different strategies for the same job, so you pick exactly one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;&lt;code&gt;CONN_MAX_AGE&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;pool&lt;/code&gt; in &lt;code&gt;OPTIONS&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Persistent connections&lt;/td&gt;
&lt;td&gt;Positive number&lt;/td&gt;
&lt;td&gt;Not set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native pooling&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Set &lt;code&gt;pool&lt;/code&gt; and forget to zero out &lt;code&gt;CONN_MAX_AGE&lt;/code&gt;, and your app won't boot. It's a five-second fix once you know the rule, and a baffling crash if you don't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Other Trap: ASGI Changes the Answer
&lt;/h2&gt;

&lt;p&gt;One more current caveat that's easy to miss. Everything above about persistent connections assumes the classic synchronous (WSGI) model, where one thread handles one request at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Under ASGI (async Django), the docs are explicit: persistent connections should be disabled.&lt;/strong&gt; The async model handles many requests concurrently in ways that don't map cleanly onto "one thread holds one connection," so &lt;code&gt;CONN_MAX_AGE&lt;/code&gt;-style persistence is the wrong tool.&lt;/p&gt;

&lt;p&gt;Instead, per the docs, use your database backend's built-in connection pooling (the &lt;code&gt;pool&lt;/code&gt; option) or a third-party pooler. So if you're running async, pooling isn't just an option. It's the recommended path, and persistent connections are off the table.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing, in Practice
&lt;/h2&gt;

&lt;p&gt;Putting it together, here's the honest 2026 guidance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small app / low traffic / development:&lt;/strong&gt; leave the default (&lt;code&gt;CONN_MAX_AGE = 0&lt;/code&gt;). The per-request overhead is negligible at low volume, and it's the simplest, safest behavior. (Don't bother enabling persistence on the dev server. It spawns a thread per request and gains nothing.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moderate traffic, synchronous (WSGI):&lt;/strong&gt; the classic win is persistent connections: &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; to something like &lt;code&gt;60&lt;/code&gt;, plus &lt;code&gt;CONN_HEALTH_CHECKS = True&lt;/code&gt;. Easy, effective, well-proven. Just make sure your database's connection limit covers your total worker-thread count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher concurrency, or you want the best latency, on Postgres + psycopg 3 / Django 5.1+:&lt;/strong&gt; native pooling (&lt;code&gt;"pool"&lt;/code&gt; in &lt;code&gt;OPTIONS&lt;/code&gt;, &lt;code&gt;CONN_MAX_AGE = 0&lt;/code&gt;). It reuses connections &lt;em&gt;and&lt;/em&gt; avoids pinning them during non-database work. The strongest option, and now built in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async (ASGI):&lt;/strong&gt; pooling, not persistent connections. The docs say so directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many app servers against a small connection budget&lt;/strong&gt; (e.g. serverless, or a database with a tight limit): consider an external pooler like &lt;strong&gt;PgBouncer&lt;/strong&gt; in front of everything, because even native pools multiply across processes and can overwhelm a small connection limit.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Understanding This Makes You Better at Django
&lt;/h2&gt;

&lt;p&gt;Database connection management is one of those areas that stays completely invisible until it suddenly becomes the thing standing between your app and scaling. Then, if you don't have the model, you're copy-pasting settings from blog posts and hoping.&lt;/p&gt;

&lt;p&gt;The developer who understands it reads their traffic and their deployment (WSGI vs ASGI, how many workers, how tight the connection budget) and picks the right mode deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-request&lt;/strong&gt; for simplicity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent&lt;/strong&gt; for a quick synchronous win&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pooling&lt;/strong&gt; for real concurrency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The developer who doesn't either leaves free performance on the table (paying connection setup on every request forever) or pastes a pool config next to a nonzero &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; and can't figure out why the app won't boot.&lt;/p&gt;

&lt;p&gt;The model itself is simple once the fog clears. A connection is expensive to open. Django opens one lazily on first query. And the only real question is what happens at request's end: throw it away (per-request), keep it for reuse (persistent, &lt;code&gt;CONN_MAX_AGE&lt;/code&gt;), or return it to a shared warm pool (pooling, &lt;code&gt;pool&lt;/code&gt; in &lt;code&gt;OPTIONS&lt;/code&gt;). The hard rules: persistence and pooling are mutually exclusive, and ASGI wants pooling.&lt;/p&gt;

&lt;p&gt;Hold that, and connection settings stop being mysterious incantations and become a deliberate choice you make based on how your app actually runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-Second Takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ni3h2nozbslt2zzbws0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ni3h2nozbslt2zzbws0.webp" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A DB connection is expensive to open&lt;/strong&gt; (handshake, auth, a server-side process on Postgres). Django opens one lazily on the first query of a request. The key question is what happens to it at the request's end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mode 1, per-request&lt;/strong&gt; (default, &lt;code&gt;CONN_MAX_AGE = 0&lt;/code&gt;): open, use, close every request. Simple and safe, but pays setup cost on every request. Wasteful under load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mode 2, persistent&lt;/strong&gt; (&lt;code&gt;CONN_MAX_AGE&lt;/code&gt; = positive seconds): keep the connection open and reuse it across requests. Add &lt;code&gt;CONN_HEALTH_CHECKS = True&lt;/code&gt; to avoid stale connections. Caveat: each thread holds its own connection, so your DB's connection limit must cover your total worker threads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mode 3, native pooling&lt;/strong&gt; (Django 5.1+, &lt;code&gt;"OPTIONS": {"pool": {...}}&lt;/code&gt;): a shared warm pool. Requests borrow a connection only while needed, so they aren't pinned during non-DB work (e.g. slow external API calls). Built in for Postgres/psycopg 3, with real latency wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The crash-on-startup trap:&lt;/strong&gt; pooling and persistent connections are mutually exclusive. Set &lt;code&gt;pool&lt;/code&gt; and leave &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; nonzero and Django raises &lt;code&gt;ImproperlyConfigured: Pooling doesn't support persistent connections&lt;/code&gt;. Pooling requires &lt;code&gt;CONN_MAX_AGE = 0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ASGI (async):&lt;/strong&gt; disable persistent connections. The docs say use pooling instead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Low traffic / development&lt;/td&gt;
&lt;td&gt;Default (&lt;code&gt;CONN_MAX_AGE = 0&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moderate traffic, WSGI&lt;/td&gt;
&lt;td&gt;Persistent + health checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High concurrency / best latency&lt;/td&gt;
&lt;td&gt;Native pooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Async (ASGI)&lt;/td&gt;
&lt;td&gt;Native pooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiny connection budget across many servers&lt;/td&gt;
&lt;td&gt;PgBouncer in front&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Open your &lt;code&gt;settings.py&lt;/code&gt; and look at your &lt;code&gt;DATABASES&lt;/code&gt; block. If there's no &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; and no &lt;code&gt;pool&lt;/code&gt;, you're opening a fresh connection on every single request. Depending on your traffic, one of the two upgrades above might be the easiest performance win you'll find all week.&lt;/p&gt;

&lt;p&gt;Which mode is your app running in right now? Tell me in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, follow me here for more stories from the backend trenches, and subscribe to &lt;a href="https://thebackendtechlead.substack.com/" rel="noopener noreferrer"&gt;The Backend Tech Lead&lt;/a&gt; on Substack for weekly blueprints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>postgres</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
