<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>performance on Saurabh Kumar</title><link>https://saurabh-kumar.com/tags/performance/</link><description>Recent content in performance on Saurabh Kumar</description><generator>Hugo</generator><language>en-US</language><copyright>Copyright © 2025, Saurabh Kumar.</copyright><lastBuildDate>Fri, 24 Jul 2026 12:00:00 +0000</lastBuildDate><atom:link href="https://saurabh-kumar.com/tags/performance/index.xml" rel="self" type="application/rss+xml"/><item><title>marcelroed/gigatoken</title><link>https://saurabh-kumar.com/til/1784894400/</link><pubDate>Fri, 24 Jul 2026 12:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/til/1784894400/</guid><description>&lt;p&gt;Embedding pipelines do not feed whole documents straight into the model. They first use the embedding model&amp;rsquo;s tokenizer to measure and split text into token-bounded, overlapping chunks, then embed each chunk and store the vectors. Tokenizer must match the model. Tools like &lt;a href="https://github.com/marcelroed/gigatoken" rel="noopener external"&gt;marcelroed/gigatoken&lt;/a&gt; accelerate this preparation at corpus scale but do not create embeddings themselves.&lt;/p&gt;&#10;</description></item><item><title>How Prompt Caching Works</title><link>https://saurabh-kumar.com/articles/2026/06/how-prompt-caching-works/</link><pubDate>Sun, 21 Jun 2026 00:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/articles/2026/06/how-prompt-caching-works/</guid><description>&lt;p&gt;Today I learned how prompt caching actually works under the hood in LLM APIs&#10;(Anthropic, OpenAI, Bedrock, etc.), and why it makes long agent loops 5–10×&#10;cheaper and noticeably faster.&lt;/p&gt;&#10;&lt;h2 id="the-problem-it-solves"&gt;The problem it solves&lt;/h2&gt;&#10;&lt;p&gt;In an agent / chat loop, every request to the model looks almost identical to&#10;the previous one:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The same big &lt;strong&gt;system prompt&lt;/strong&gt; (rules, tool descriptions, skills…)&lt;/li&gt;&#10;&lt;li&gt;The same set of &lt;strong&gt;tool definitions&lt;/strong&gt;&lt;/li&gt;&#10;&lt;li&gt;The same &lt;strong&gt;history&lt;/strong&gt; of past user/assistant/tool messages, with only a few&#10;new tokens appended at the end&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Without caching, the provider has to re-tokenize and run a full attention&#10;pre-fill over all of that prefix on every turn. For a 100k-token context, that&#10;prefill dominates both cost and latency.&lt;/p&gt;&#10;&lt;h2 id="what-gets-cached-the-kv-state"&gt;What gets cached: the KV state&lt;/h2&gt;&#10;&lt;p&gt;When a transformer processes a prompt, it computes a &lt;strong&gt;key/value tensor pair&#10;for every token in every attention layer&lt;/strong&gt;. That KV state is what attention&#10;queries against on the next decoded token.&lt;/p&gt;&#10;&lt;p&gt;Prompt caching is, mechanically, &amp;ldquo;save those KV tensors for a prefix on the&#10;server, and reuse them next time the same prefix shows up.&amp;rdquo; Output tokens are&#10;never cached — only the prompt-side KV state.&lt;/p&gt;&#10;&lt;h2 id="the-lifecycle"&gt;The lifecycle&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Mark a cache breakpoint.&lt;/strong&gt; The client places a &lt;code&gt;cache_control&lt;/code&gt; marker&#10;(Anthropic) or relies on automatic prefix detection (OpenAI). Everything&#10;&lt;em&gt;before&lt;/em&gt; the marker is the cacheable prefix.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;First request → cache write.&lt;/strong&gt; The provider hashes the cacheable prefix&#10;(model id + tools + messages + cache key, byte-exact), runs the prefill,&#10;and stores the resulting KV tensors keyed by that hash. You&amp;rsquo;re billed&#10;&lt;code&gt;cache_write&lt;/code&gt; tokens, usually a bit &lt;em&gt;more&lt;/em&gt; than normal input&#10;(~1.25× for Anthropic 5-minute cache, ~2× for the 1-hour &amp;ldquo;extended&amp;rdquo;&#10;cache).&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Next request → cache read.&lt;/strong&gt; If the new request starts with exactly the&#10;same prefix, the provider looks up the hash, loads the stored KV state,&#10;and only runs fresh compute for the suffix after the last hit. You&amp;rsquo;re&#10;billed &lt;code&gt;cache_read&lt;/code&gt; tokens — often &lt;strong&gt;~10% of the normal input price&lt;/strong&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;TTL.&lt;/strong&gt; Entries expire after a TTL (Anthropic: 5 min default, 1 hour&#10;opt-in; OpenAI: a few minutes, automatic). Each hit typically refreshes&#10;the TTL, so an active session keeps the cache warm.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="what-invalidates-the-cache"&gt;What invalidates the cache&lt;/h2&gt;&#10;&lt;p&gt;The hash is over the &lt;strong&gt;exact bytes&lt;/strong&gt; of the prefix. Anything that changes&#10;those bytes forces a fresh &lt;code&gt;cache_write&lt;/code&gt;:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Editing the system prompt (even reordering sentences or adjusting&#10;whitespace)&lt;/li&gt;&#10;&lt;li&gt;Adding, removing, or reordering a tool definition&lt;/li&gt;&#10;&lt;li&gt;Inserting/editing/reordering an earlier message in the history&lt;/li&gt;&#10;&lt;li&gt;Switching model id or provider&lt;/li&gt;&#10;&lt;li&gt;Changing the cache key (some providers expose one for tenant isolation)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The corollary: to maximize hit rate, agents should &lt;strong&gt;only append at the end&lt;/strong&gt;&#10;and keep system prompt and tools byte-stable across turns.&lt;/p&gt;&#10;&lt;h2 id="reading-the-usage-block"&gt;Reading the usage block&lt;/h2&gt;&#10;&lt;p&gt;Most providers return a usage object on every response with at least these&#10;fields:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;input&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;312&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// fresh, non-cached prompt tokens this turn&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;845&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// generated tokens&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;cacheRead&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;47210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// prompt tokens served from cache&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;cacheWrite&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="c1"&gt;// prompt tokens written to cache this turn&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A useful per-turn metric is the &lt;strong&gt;cache hit ratio&lt;/strong&gt;:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;cacheHitRate = cacheRead / (input + cacheRead + cacheWrite)&#10;&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;0%&lt;/code&gt; → nothing reused. Normal on the very first turn, or right after the&#10;prefix changed (tool list updated, history compacted, model swapped).&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;70–95%&lt;/code&gt; → healthy steady state for a long agent loop.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;cacheWrite &amp;gt; 0, cacheRead = 0&lt;/code&gt; → you just primed the cache; the next turn&#10;should be cheap if you don&amp;rsquo;t break the prefix.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="pricing-back-of-the-envelope"&gt;Pricing back-of-the-envelope&lt;/h2&gt;&#10;&lt;p&gt;Rough Anthropic numbers (Claude Sonnet, illustrative):&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Token type&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Price multiplier vs. input&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;input&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1×&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;cache_write&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;~1.25× (5 min) / ~2× (1h)&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;cache_read&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;~0.1×&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;output&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;~5×&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;So a 100k-token prefix that&amp;rsquo;s hit 20 times across a session costs roughly:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;1 × 1.25 × 100k (one write)&#10;+ 19 × 0.1 × 100k (nineteen reads)&#10;= 1.25 × 100k + 1.9 × 100k&#10;≈ 3.15 × 100k input-equivalent&#10;&#10;vs. uncached:&#10;20 × 1 × 100k = 20 × 100k&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A ~6× saving on the prompt side, plus a big latency win because the server&#10;skips the prefill.&lt;/p&gt;&#10;&lt;h2 id="gotchas"&gt;Gotchas&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Minimum prefix size.&lt;/strong&gt; Most providers require the cacheable prefix to be&#10;above some threshold (e.g. 1024 tokens for Anthropic) before caching kicks&#10;in at all.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Position of the breakpoint matters.&lt;/strong&gt; Putting it after volatile content&#10;(like the latest user message) means nothing ever gets reused.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Tool definitions are part of the prefix.&lt;/strong&gt; Dynamically generated tool&#10;lists with shuffled order will silently destroy your hit rate.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Streaming vs. non-streaming&lt;/strong&gt; use the same cache; cache fields show up&#10;in the final usage event.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Cost accounting:&lt;/strong&gt; &lt;code&gt;cache_write&lt;/code&gt; is more expensive than &lt;code&gt;input&lt;/code&gt;, so a&#10;flaky prefix that constantly re-writes can be &lt;em&gt;worse&lt;/em&gt; than no caching at&#10;all.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Django ORM: From sync_to_async Threads to Native psycopg3</title><link>https://saurabh-kumar.com/articles/2026/06/django-orm-from-sync_to_async-threads-to-native-psycopg3/</link><pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/articles/2026/06/django-orm-from-sync_to_async-threads-to-native-psycopg3/</guid><description>&lt;p&gt;Django shipped async ORM methods back in 3.1. The &lt;code&gt;a&lt;/code&gt;-prefixed methods, including&#10;&lt;code&gt;aget()&lt;/code&gt;, &lt;code&gt;afilter()&lt;/code&gt;, and &lt;code&gt;acreate()&lt;/code&gt;, let calling code use coroutines, but the&#10;database work still blocked an OS thread. I want to explain that distinction&#10;and how the execution path changed.&lt;/p&gt;&#10;&lt;p&gt;Django 6.0 fixed it for real in December 2025. I want to walk through the path&#10;from &amp;ldquo;async via threads&amp;rdquo; to &amp;ldquo;native async via psycopg3,&amp;rdquo; because it is not&#10;a story about a feature landing. It is a story about three constraints that&#10;boxed Django in for five years: &lt;code&gt;psycopg2&lt;/code&gt; blocks, connection state lived in&#10;thread-locals, and the entire ORM assumed DB-API2. Once you see those three, every&#10;design decision Django made falls out of them almost mechanically.&lt;/p&gt;&#10;&lt;h2 id="the-thread-based-async-path"&gt;The Thread-Based Async Path&lt;/h2&gt;&#10;&lt;p&gt;When async views arrived in 3.1, the ORM underneath them was entirely synchronous.&#10;&lt;code&gt;psycopg2&lt;/code&gt; — the default PostgreSQL driver for most of Django&amp;rsquo;s life — wraps&#10;&lt;code&gt;libpq&lt;/code&gt;, a C library with no async I/O surface at all. There is no flag you set to&#10;make &lt;code&gt;psycopg2&lt;/code&gt; async. You either replace the driver or you find a workaround.&lt;/p&gt;&#10;&lt;p&gt;Django found the workaround: &lt;code&gt;asgiref.sync.sync_to_async&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified — what aget() actually did under the hood in Django 5.x&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;asgiref.sync&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_to_async&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;aget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&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;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;sync_to_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_sensitive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;sync_to_async&lt;/code&gt; pushes the callable into a &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;. The event loop is&#10;not blocked, because the coroutine awaits the thread&amp;rsquo;s future. But a real OS thread&#10;is blocked, sitting on a TCP socket waiting for Postgres to answer. You moved the&#10;blocking, you did not remove it.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;thread_sensitive=True&lt;/code&gt; flag is the part that gives the game away. Django&amp;rsquo;s&#10;database connections lived in thread-locals:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# django/db/backends/base/base.py (pre-6.0)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;threading&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_thread_local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without &lt;code&gt;thread_sensitive=True&lt;/code&gt;, two consecutive &lt;code&gt;sync_to_async&lt;/code&gt; calls from the same&#10;request might land on different threads, see different thread-locals, and tear your&#10;transaction state in half. The flag pins every call within the same coroutine&#10;context to one dedicated thread, so the connection still looks like a single&#10;connection per request. This preserves the connection’s thread affinity.&lt;/p&gt;&#10;&lt;p&gt;Put it together and the cost is obvious. Five hundred concurrent async requests, each&#10;touching the database, need five hundred threads. That is the thread-per-connection&#10;model, even though the calling code uses coroutines.&lt;/p&gt;&#10;&lt;h2 id="why-psycopg2-was-a-dead-end"&gt;Why psycopg2 Was a Dead End&lt;/h2&gt;&#10;&lt;p&gt;You cannot retrofit async I/O onto &lt;code&gt;psycopg2&lt;/code&gt;. It calls into &lt;code&gt;libpq&lt;/code&gt; synchronously&#10;and blocks the calling thread until the query returns. The only way to run it in an&#10;async context is exactly what Django did: park it in a thread and await that thread.&#10;There was never a clever escape here.&lt;/p&gt;&#10;&lt;p&gt;The obvious alternative was &lt;code&gt;asyncpg&lt;/code&gt;, a fully async PostgreSQL driver that beats&#10;psycopg3 on raw throughput. Django ruled it out, and the reasons are worth their own&#10;section further down. The short version is that &lt;code&gt;asyncpg&lt;/code&gt; does not implement DB-API2&#10;(PEP 249)&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;, and Django&amp;rsquo;s ORM is built on DB-API2 from the ground up — cursor&#10;objects, &lt;code&gt;execute()&lt;/code&gt;, &lt;code&gt;fetchone()&lt;/code&gt;, &lt;code&gt;fetchall()&lt;/code&gt;. You do not bolt a different&#10;contract onto twenty years of ORM and call it a driver swap.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;psycopg3&lt;/code&gt; is the answer that fits, because it does not force that choice.&lt;/p&gt;&#10;&lt;h2 id="psycopg3-does-async-and-db-api2-at-the-same-time"&gt;psycopg3 Does Async And DB-API2 At The Same Time&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;psycopg3&lt;/code&gt; — the package is just &lt;code&gt;psycopg&lt;/code&gt;, not &lt;code&gt;psycopg3&lt;/code&gt; — was redesigned to support&#10;both synchronous and asynchronous execution through one shared core&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;. The same query&#10;compilation logic runs both paths. What changes is the connection and cursor type you&#10;hand it.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# psycopg3 — synchronous path&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;SELECT * FROM posts WHERE id = &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&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;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# psycopg3 — async path (same SQL, same parameters, different objects)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncConnection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&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;aconn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;aconn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&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;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;SELECT * FROM posts WHERE id = &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;row&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;AsyncConnection&lt;/code&gt; and &lt;code&gt;AsyncCursor&lt;/code&gt; use &lt;code&gt;asyncio&lt;/code&gt; directly. The &lt;code&gt;await cur.execute(...)&lt;/code&gt;&#10;suspends the coroutine and releases the event loop while the network I/O happens. No&#10;thread sits idle on a socket. That is the whole difference, and it is the difference&#10;that matters.&lt;/p&gt;&#10;&lt;p&gt;Django 6.0 made psycopg3 the recommended PostgreSQL driver and wired&#10;&lt;code&gt;AsyncConnection&lt;/code&gt; and &lt;code&gt;AsyncCursor&lt;/code&gt; into the backend.&lt;/p&gt;&#10;&lt;h2 id="what-django-60-actually-changed"&gt;What Django 6.0 Actually Changed&lt;/h2&gt;&#10;&lt;p&gt;Three refactors enabled the switch. None of them are glamorous, and that is the point.&lt;/p&gt;&#10;&lt;h3 id="1-contextvar-replaces-thread-locals"&gt;1. ContextVar Replaces Thread-Locals&lt;/h3&gt;&#10;&lt;p&gt;Thread-locals are indexed by thread ID. Coroutines do not have thread IDs of their&#10;own — they share threads and switch between each other at &lt;code&gt;await&lt;/code&gt; points. So&#10;thread-local connection storage is not merely awkward under async, it is structurally&#10;wrong. Think of a thread-local as a coat hook bolted to a single chair: it works only&#10;as long as one person stays in that chair the whole time. Coroutines stand up and swap&#10;chairs constantly.&lt;/p&gt;&#10;&lt;p&gt;The fix is &lt;code&gt;contextvars.ContextVar&lt;/code&gt;, which arrived in Python 3.7. A &lt;code&gt;ContextVar&lt;/code&gt; is&#10;scoped to the current execution context, and each asyncio &lt;code&gt;Task&lt;/code&gt; gets its own context,&#10;copied from its parent when it is created. Django 6.0 moved connection storage off&#10;&lt;code&gt;threading.local()&lt;/code&gt; and onto a &lt;code&gt;ContextVar&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# django/db/backends/base/base.py (Django 6.0)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;contextvars&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;django_db_connection&amp;#39;&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="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two concurrent requests, both awaiting a query, each live in their own asyncio Task,&#10;each with their own context copy, each seeing their own connection. No locking, no&#10;thread affinity, no &lt;code&gt;thread_sensitive&lt;/code&gt; gymnastics. The coat hook now travels with the&#10;person.&lt;/p&gt;&#10;&lt;h3 id="2-databasewrapper-gets-an-async-connection-path"&gt;2. DatabaseWrapper Gets an Async Connection Path&lt;/h3&gt;&#10;&lt;p&gt;&lt;code&gt;DatabaseWrapper&lt;/code&gt; is the Django object that represents a database connection. Before&#10;6.0 it exposed one path, &lt;code&gt;self.connection&lt;/code&gt;, a synchronous psycopg2 connection. After&#10;6.0 it also exposes &lt;code&gt;async_connection()&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Conceptual — the async context manager on DatabaseWrapper&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@asynccontextmanager&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;async_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;psycopg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncConnection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_connection_params&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_connection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Django&amp;rsquo;s connection-handling code detects whether it is running inside an asyncio event&#10;loop and routes to the right path. You do not pick the path. The runtime does.&lt;/p&gt;&#10;&lt;h3 id="3-sqlcompiler-gets-async_execute"&gt;3. SQLCompiler Gets async_execute&lt;/h3&gt;&#10;&lt;p&gt;The query compiler, &lt;code&gt;SQLCompiler&lt;/code&gt; and its subclasses, is where Django turns a &lt;code&gt;QuerySet&lt;/code&gt;&#10;into SQL and sends it to the database. Before 6.0 there was one path, &lt;code&gt;execute_sql()&lt;/code&gt;.&#10;Django 6.0 adds &lt;code&gt;async_execute_sql()&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;async_execute_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MULTI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunked_fetch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GET_ITERATOR_CHUNK_SIZE&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;async_connection&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&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;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_sql&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;MULTI&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&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;async&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;cursor&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;aget()&lt;/code&gt;, &lt;code&gt;afilter()&lt;/code&gt;, &lt;code&gt;aiterator()&lt;/code&gt; and the rest now route through &lt;code&gt;async_execute_sql()&lt;/code&gt;&#10;instead of wrapping &lt;code&gt;execute_sql()&lt;/code&gt; in &lt;code&gt;sync_to_async&lt;/code&gt;. The thread pool drops out of the&#10;picture entirely.&lt;/p&gt;&#10;&lt;h2 id="the-new-execution-stack"&gt;The New Execution Stack&lt;/h2&gt;&#10;&lt;p&gt;Here is how &lt;code&gt;await Post.objects.aget(id=1)&lt;/code&gt; flows through Django 6.0:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;await Post.objects.aget(id=1)&#10; └── QuerySet.aget()&#10; └── SQLCompiler.async_execute_sql()&#10; └── DatabaseWrapper.async_connection() # ContextVar-scoped&#10; └── psycopg.AsyncConnection&#10; └── AsyncCursor.execute()&#10; └── asyncio TCP socket write&#10; └── await socket read ← event loop released here&#10; └── row data returned&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And here is the same call under Django 5.x:&lt;/p&gt;&#10;&lt;pre tabindex="0"&gt;&lt;code&gt;await Post.objects.aget(id=1)&#10; └── QuerySet.aget()&#10; └── sync_to_async(thread_sensitive=True)(QuerySet.get)&#10; └── ThreadPoolExecutor — OS thread blocked&#10; └── SQLCompiler.execute_sql()&#10; └── psycopg2 connection (thread-local)&#10; └── libpq blocking TCP call&#10; └── thread wakes, result returned&#10;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The event loop is no longer handing work off to a thread. It suspends the coroutine,&#10;does other work, and resumes when the socket is readable. That is the entire reform,&#10;and it is enough.&lt;/p&gt;&#10;&lt;h2 id="what-this-means-for-concurrency"&gt;What This Means For Concurrency&lt;/h2&gt;&#10;&lt;p&gt;The practical payoff is in connection count. With the old thread approach, five hundred&#10;concurrent async requests meant up to five hundred threads and up to five hundred&#10;Postgres connections. Threads are cheap but not free — each costs roughly 8MB of stack&#10;by default, plus scheduler overhead.&lt;/p&gt;&#10;&lt;p&gt;With native async, those same five hundred requests can be multiplexed over a&#10;connection pool sized to what your database can actually handle, typically 10 to 50&#10;connections, with the event loop running the wait queue. You use far fewer threads,&#10;one or a handful for the event loop workers, and far fewer database connections.&lt;/p&gt;&#10;&lt;p&gt;Django 6.0 also ships native connection pooling for the async path&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;, built on&#10;&lt;code&gt;psycopg_pool.AsyncConnectionPool&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# settings.py — async pooling in Django 6.0&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;default&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;ENGINE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;django.db.backends.postgresql&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;OPTIONS&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;pool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;min_size&amp;#34;&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;max_size&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;CONN_MAX_AGE&amp;#34;&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is the same &lt;code&gt;pool&lt;/code&gt; option I covered in &lt;a href="https://saurabh-kumar.com/articles/2025/06/django-native-postgresql-connection-pooling/"&gt;Django Native PostgreSQL Connection Pooling&lt;/a&gt;, with one&#10;difference that matters. In that post I warned against native pooling under ASGI —&#10;Django&amp;rsquo;s own docs recommended PgBouncer instead — because the sync pool and the event&#10;loop did not cooperate. Django 6.0&amp;rsquo;s &lt;code&gt;AsyncConnectionPool&lt;/code&gt; is what closes that gap. The&#10;pool itself is now async-aware, so the ASGI caveat no longer applies on the native&#10;psycopg3 path. I was right to warn you then. You can stop worrying about it now.&lt;/p&gt;&#10;&lt;h2 id="is-sync_to_async-gone"&gt;Is sync_to_async Gone?&lt;/h2&gt;&#10;&lt;p&gt;No, and it should not be. It is the fallback for drivers that cannot go native async:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Driver&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Async path&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;psycopg3 (PostgreSQL)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Native &lt;code&gt;AsyncCursor&lt;/code&gt; — no threads&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;psycopg2 (PostgreSQL)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;sync_to_async&lt;/code&gt; thread wrap — still blocked&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;mysqlclient / MySQL&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;sync_to_async&lt;/code&gt; thread wrap&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;sqlite3&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;sync_to_async&lt;/code&gt; thread wrap&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Run Django 6.0 against &lt;code&gt;psycopg2&lt;/code&gt; or MySQL in an async context and Django still falls&#10;back to the thread pool. The native path only exists when psycopg3 is installed and&#10;configured. The thread trick did not become wrong. It became the path of last resort.&lt;/p&gt;&#10;&lt;p&gt;The way forward for other databases is psycopg3-equivalent async drivers. MySQL has&#10;&lt;code&gt;aiomysql&lt;/code&gt;, SQLite has &lt;code&gt;aiosqlite&lt;/code&gt;. Django contributions for those backends are in&#10;progress but not stable in 6.0, so for now PostgreSQL is the database that gets the&#10;real thing.&lt;/p&gt;&#10;&lt;h2 id="asyncpg-is-still-faster-and-still-not-supported"&gt;asyncpg Is Still Faster, And Still Not Supported&lt;/h2&gt;&#10;&lt;p&gt;Benchmark &lt;code&gt;asyncpg&lt;/code&gt; against psycopg3 on raw async throughput and asyncpg wins, often&#10;by 20 to 40 percent on bulk read workloads. Django will not adopt it. The reasons are&#10;structural, not political, and I think Django is right.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;No DB-API2.&lt;/strong&gt; asyncpg uses positional &lt;code&gt;$1, $2&lt;/code&gt; parameters, not &lt;code&gt;%s&lt;/code&gt;. The entire&#10;ORM query compiler assumes &lt;code&gt;%s&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No synchronous fallback.&lt;/strong&gt; Management commands, migrations, and sync views would&#10;need a whole separate code path.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A different transaction model.&lt;/strong&gt; asyncpg handles transactions differently from the&#10;DB-API2 autocommit and explicit model Django manages internally.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Using asyncpg would require changes to the ORM, not just a driver swap.&#10;A driver benchmark alone does not measure that work. If you genuinely need asyncpg throughput with a Django-like&#10;ORM, look at SQLAlchemy 2.0 with its async session support and asyncpg backend, running&#10;under Starlette or FastAPI. For Django, psycopg3 is the correct answer.&lt;/p&gt;&#10;&lt;h2 id="upgrading"&gt;Upgrading&lt;/h2&gt;&#10;&lt;p&gt;To get the native async path today:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pip install &lt;span class="s2"&gt;&amp;#34;psycopg[binary,pool]&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# settings.py&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;default&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;ENGINE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;django.db.backends.postgresql&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;NAME&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mydb&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;USER&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;myuser&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;PASSWORD&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mypassword&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;HOST&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;localhost&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;CONN_MAX_AGE&amp;#34;&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;OPTIONS&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;pool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# or dict with min_size/max_size&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you are on Django 5.2 LTS with psycopg3, you already get the synchronous psycopg3&#10;path and connection pooling. The native async query path needs Django 6.0. You can&#10;upgrade the driver without upgrading Django and still get the pooling benefits in sync&#10;contexts, which is a fine thing to do while you wait.&lt;/p&gt;&#10;&lt;h2 id="why-it-took-five-years"&gt;Why It Took Five Years&lt;/h2&gt;&#10;&lt;p&gt;Django&amp;rsquo;s async ORM story ran from &amp;ldquo;async with threads&amp;rdquo; in 3.1 in 2020 to &amp;ldquo;native&#10;async with psycopg3&amp;rdquo; in 6.0 in 2025. Five years sounds slow until you look at what had&#10;to be true first:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;code&gt;psycopg3&lt;/code&gt; had to be stable enough to depend on, which it reached around 2022 to 2023.&lt;/li&gt;&#10;&lt;li&gt;Thread-local connection state was woven through the codebase, and the ContextVar&#10;migration touched hundreds of files.&lt;/li&gt;&#10;&lt;li&gt;The Django project is conservative about breaking changes, especially in database&#10;handling, where bugs are not cosmetic.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The result is correct and maintainable rather than the absolute fastest thing money can&#10;buy, and I do not think that was the wrong trade. For most Django applications — web&#10;services handling thousands of concurrent HTTP requests — native async with psycopg3 is&#10;already more than fast enough, and it integrates cleanly with everything else in the&#10;ecosystem.&lt;/p&gt;&#10;&lt;h2 id="related-reading"&gt;Related reading&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://saurabh-kumar.com/articles/2025/06/django-native-postgresql-connection-pooling/"&gt;Django Native PostgreSQL Connection Pooling&lt;/a&gt; — the sync side of the same story: configuring the psycopg3 pool, sizing &lt;code&gt;min_size&lt;/code&gt;/&lt;code&gt;max_size&lt;/code&gt;, and avoiding the &lt;code&gt;CONN_MAX_AGE&lt;/code&gt; foot-gun.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;&lt;a href="https://peps.python.org/pep-0249/" rel="noopener external"&gt;PEP 249 — Python Database API Specification v2.0&lt;/a&gt; — The DB-API2 spec that Django&amp;rsquo;s ORM is built on top of; asyncpg deliberately does not implement it.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:2"&gt;&#10;&lt;p&gt;&lt;a href="https://www.psycopg.org/psycopg3/docs/advanced/async.html" rel="noopener external"&gt;psycopg3 — Async operations&lt;/a&gt; — Official psycopg documentation on the async connection and cursor API.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:3"&gt;&#10;&lt;p&gt;&lt;a href="https://docs.djangoproject.com/en/6.0/releases/6.0/" rel="noopener external"&gt;Django 6.0 Release Notes&lt;/a&gt; — Full changelog including async ORM changes, background tasks, and template partials.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>karpathy/autoresearch</title><link>https://saurabh-kumar.com/til/1780920000/</link><pubDate>Mon, 08 Jun 2026 12:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/til/1780920000/</guid><description>&lt;p&gt;[karpathy/autoresearch] is a concept/framework that allows one to leverage LLM to run an optimization loops based on a criteria. &lt;a href="https://github.com/davebcn87/pi-autoresearch" rel="noopener external"&gt;davebcn87/pi-autoresearch&lt;/a&gt; takes this further has built a generic optimization long run loop method on top of pi agent. &lt;a href="https://gemini.google.com/app/7dcee9714f7955ce" rel="noopener external"&gt;gemini&lt;/a&gt;&lt;/p&gt;&#10;</description></item><item><title>How I Optimized My Zsh Startup Time</title><link>https://saurabh-kumar.com/articles/2026/05/how-i-optimized-my-zsh-startup-time/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/articles/2026/05/how-i-optimized-my-zsh-startup-time/</guid><description>&lt;p&gt;My zsh prompt was taking close to a second to appear on a fresh terminal. Not catastrophic. But you feel it every time tmux spawns a new pane, and for me that is constantly. The config had grown for years, one &lt;code&gt;eval&lt;/code&gt; and one plugin at a time, and nobody had been auditing it, least of all me.&lt;/p&gt;&#10;&lt;p&gt;What follows is the audit, and the eight things I changed. The numbers are estimates, not measurements, and I will say so again at the end.&lt;/p&gt;&#10;&lt;h2 id="measure-first"&gt;Measure first&lt;/h2&gt;&#10;&lt;p&gt;Before I touched anything, I measured. Two tools did the work:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Cold-start benchmark (run a few times, watch the variance):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; i in &lt;span class="o"&gt;{&lt;/span&gt;1..5&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; zsh -i -c exit&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# In-shell flamegraph:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 1. Uncomment `zmodload zsh/zprof` at the top of .zshrc&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 2. Add `zprof` at the bottom&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 3. Open a new shell&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zprof &lt;span class="p"&gt;|&lt;/span&gt; head -40&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;zprof&lt;/code&gt; is the one that helped most. It prints which function calls and &lt;code&gt;eval&lt;/code&gt;s are burning time, sorted by self-time and call count. That is enough to stop guessing.&lt;/p&gt;&#10;&lt;h2 id="1-stop-forking-brew---prefix-five-times-per-shell"&gt;1. Stop forking &lt;code&gt;brew --prefix&lt;/code&gt; five times per shell&lt;/h2&gt;&#10;&lt;p&gt;My macOS config had this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix coreutils&lt;span class="k"&gt;)&lt;/span&gt;/libexec/gnubin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix findutils&lt;span class="k"&gt;)&lt;/span&gt;/libexec/gnubin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix gnu-sed&lt;span class="k"&gt;)&lt;/span&gt;/libexec/gnubin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix grep&lt;span class="k"&gt;)&lt;/span&gt;/libexec/gnubin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;brew --prefix curl&lt;span class="k"&gt;)&lt;/span&gt;/bin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Five forks of &lt;code&gt;brew&lt;/code&gt;, which is a Ruby program, on every shell, just to resolve paths that always end up under &lt;code&gt;${HOMEBREW_PREFIX}/opt/&amp;lt;formula&amp;gt;&lt;/code&gt; anyway. Each fork is about 35 ms on Apple Silicon, so that is roughly 180 ms gone before I have typed a single character.&lt;/p&gt;&#10;&lt;p&gt;Fix:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;_brew_opt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOMEBREW_PREFIX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/opt/homebrew&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/opt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[&lt;/span&gt; -d &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;_brew_opt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/usr/local/opt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/coreutils/libexec/gnubin:&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/findutils/libexec/gnubin:&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/gnu-sed/libexec/gnubin:&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/grep/libexec/gnubin:&lt;/span&gt;&lt;span class="nv"&gt;$_brew_opt&lt;/span&gt;&lt;span class="s2"&gt;/curl/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;unset&lt;/span&gt; _brew_opt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A path-prefix lookup costs nothing. The &lt;code&gt;/usr/local/opt&lt;/code&gt; fallback is there for Intel macOS, where &lt;code&gt;HOMEBREW_PREFIX&lt;/code&gt; may be unset, for example in bash without &lt;code&gt;brew shellenv&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Saves about 180 ms.&lt;/p&gt;&#10;&lt;h2 id="2-defer-plugins-with-zinits-wait-ice"&gt;2. Defer plugins with zinit&amp;rsquo;s &lt;code&gt;wait&lt;/code&gt; ice&lt;/h2&gt;&#10;&lt;p&gt;If you use a plugin manager, this is probably the biggest single win on the table. Syntax highlighting, autosuggestions, history-substring-search, completion add-ons: none of these need to load before the first prompt renders. They only need to be there by the time you type something.&lt;/p&gt;&#10;&lt;p&gt;With &lt;a href="https://github.com/zdharma-continuum/zinit" rel="noopener external"&gt;zinit&lt;/a&gt;&amp;rsquo;s &lt;code&gt;wait&lt;/code&gt; ice, you defer them until after the first prompt:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zinit &lt;span class="nb"&gt;wait&lt;/span&gt; lucid light-mode &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; atinit&lt;span class="s2"&gt;&amp;#34;ZINIT[COMPINIT_OPTS]=-C; zicompinit; zicdreplay&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; zdharma-continuum/fast-syntax-highlighting &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; atload&lt;span class="s2"&gt;&amp;#34;_zsh_autosuggest_start&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; zsh-users/zsh-autosuggestions &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; blockf atpull&lt;span class="s1"&gt;&amp;#39;zinit creinstall -q .&amp;#39;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; zsh-users/zsh-completions &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; atload&lt;span class="s2"&gt;&amp;#34;bindkey &amp;#39;^[[A&amp;#39; history-substring-search-up; bindkey &amp;#39;^[[B&amp;#39; history-substring-search-down&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; zsh-users/zsh-history-substring-search &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; wfxr/forgit&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;atinit&lt;/code&gt; ice on the first plugin runs &lt;code&gt;compinit&lt;/code&gt; once and replays cached completions via &lt;code&gt;zicdreplay&lt;/code&gt;. One &lt;code&gt;compinit&lt;/code&gt; covers every turbo-loaded plugin. The &lt;code&gt;atload&amp;quot;_zsh_autosuggest_start&amp;quot;&lt;/code&gt; line re-initializes autosuggestions after deferred loading. Without it the plugin silently no-ops, which took me an embarrassing amount of time to work out.&lt;/p&gt;&#10;&lt;p&gt;Saves 100-300 ms.&lt;/p&gt;&#10;&lt;h2 id="3-compile-zcompdump-to-bytecode"&gt;3. Compile &lt;code&gt;.zcompdump&lt;/code&gt; to bytecode&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;compinit&lt;/code&gt; parses thousands of completion functions on every startup. Compiling its dump file to bytecode means subsequent shells skip the parser:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;zcompdump&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ZDOTDIR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.zcompdump&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[[&lt;/span&gt; -s &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$zcompdump&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;! -s &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;zcompdump&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.zwc&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$zcompdump&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -nt &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;zcompdump&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.zwc&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zcompile &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$zcompdump&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It recompiles only when the source is newer than the cached &lt;code&gt;.zwc&lt;/code&gt;. Saves 30-80 ms.&lt;/p&gt;&#10;&lt;h2 id="4-pick-one-version-manager"&gt;4. Pick one version manager&lt;/h2&gt;&#10;&lt;p&gt;I had both &lt;code&gt;fnm&lt;/code&gt; and &lt;code&gt;mise&lt;/code&gt; active, which is one more than anyone needs. Mise manages everything I actually use, Node, Python, Go; &lt;code&gt;fnm env&lt;/code&gt; in &lt;code&gt;.zshrc&lt;/code&gt; was a leftover that nothing depended on. Deleting it saved another 30-80 ms per shell.&lt;/p&gt;&#10;&lt;p&gt;Mise itself has a second optimization, &lt;code&gt;--shims&lt;/code&gt; mode:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;~/.local/bin/mise activate zsh --shims&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Default &lt;code&gt;mise activate&lt;/code&gt; installs a &lt;code&gt;precmd&lt;/code&gt; hook that runs on every prompt to keep tool versions in sync with &lt;code&gt;.mise.toml&lt;/code&gt;. Shim mode replaces that hook with symlinks on &lt;code&gt;PATH&lt;/code&gt; that resolve the right version the first time you call each tool. That is about 17 ms saved at startup, paid back as 10-30 ms on the first invocation per shell. For interactive use it is a fine trade.&lt;/p&gt;&#10;&lt;h2 id="5-cache-the-slow-evals"&gt;5. Cache the slow &lt;code&gt;eval&lt;/code&gt;s&lt;/h2&gt;&#10;&lt;p&gt;A lot of tools want you to run &lt;code&gt;eval &amp;quot;$(tool init zsh)&amp;quot;&lt;/code&gt; at startup. Each one forks the tool, runs its setup, and pipes the result back. You pay that cost every shell, even though the output rarely changes between runs.&lt;/p&gt;&#10;&lt;p&gt;A small helper fixes it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;_cache_eval&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nv"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.zsh/cache/&lt;span class="nv"&gt;$name&lt;/span&gt;.zsh&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;[[&lt;/span&gt; -s &lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt; -nt &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;/dev/null&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; mkdir -p ~/.zsh/cache&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &amp;gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;_cache_eval zoxide zoxide init zsh --cmd j&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;_cache_eval uv uv generate-shell-completion zsh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;$cache -nt ${commands[$1]:-/dev/null}&lt;/code&gt; test compares mtimes. When brew or mise upgrades the binary, the cache invalidates on its own. No stale-cache surprises after a &lt;code&gt;brew upgrade&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Keep &lt;code&gt;starship init zsh&lt;/code&gt; eager. You need it for the prompt anyway, and it is already fast.&lt;/p&gt;&#10;&lt;p&gt;Saves 50-100 ms.&lt;/p&gt;&#10;&lt;h2 id="6-audit-plugins-cut-what-you-dont-use"&gt;6. Audit plugins; cut what you don&amp;rsquo;t use&lt;/h2&gt;&#10;&lt;p&gt;I was loading the &lt;a href="https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/git" rel="noopener external"&gt;Oh My Zsh git plugin&lt;/a&gt; via zinit&amp;rsquo;s snippet feature. The plugin ships roughly 150 aliases.&lt;/p&gt;&#10;&lt;p&gt;So I grepped my last few months of shell history against its alias list. Five OMZ-only aliases came back: &lt;code&gt;gsw&lt;/code&gt;, &lt;code&gt;glo&lt;/code&gt;, &lt;code&gt;gf&lt;/code&gt;, &lt;code&gt;gclean&lt;/code&gt;, &lt;code&gt;gss&lt;/code&gt;. Everything else I either did not use or had already redefined to my own taste. I copied those five into &lt;code&gt;shell/.aliases&lt;/code&gt; and dropped the &lt;code&gt;OMZP::git&lt;/code&gt; snippet from zinit.&lt;/p&gt;&#10;&lt;p&gt;There is a second reason to do this, and it has nothing to do with startup time. Because the OMZ plugin loaded in turbo mode, after my eager &lt;code&gt;shell/.aliases&lt;/code&gt;, it was quietly overwriting aliases I had defined myself. My &lt;code&gt;gc='git commit -m'&lt;/code&gt; was getting clobbered to &lt;code&gt;git commit --verbose&lt;/code&gt;, and &lt;code&gt;gg='lazygit'&lt;/code&gt; to &lt;code&gt;git gui&lt;/code&gt;. The plugin &amp;ldquo;worked&amp;rdquo; because turbo-loaded aliases land before you type, but the behavior was not the one I had configured. Dropping the plugin put my own aliases back in charge.&lt;/p&gt;&#10;&lt;p&gt;While I was at it I found &lt;code&gt;zsh/.zsh/plugins/git/git.plugin.zsh&lt;/code&gt; in my dotfiles, 150 lines of git aliases, sourced from nowhere. Years of dead code I had been carrying around. Gone.&lt;/p&gt;&#10;&lt;h2 id="7-move-path-and-env-to-zshenv"&gt;7. Move PATH and env to &lt;code&gt;.zshenv&lt;/code&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;.zshrc&lt;/code&gt; runs only for interactive shells. If you set &lt;code&gt;PATH&lt;/code&gt; there, then cron jobs, &lt;code&gt;zsh -c &amp;quot;command&amp;quot;&lt;/code&gt; invocations, and IDE-spawned non-interactive shells do not see your tools. That is how you end up burning half a day on &amp;ldquo;works in my terminal, breaks in CI.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;The right home is &lt;code&gt;.zshenv&lt;/code&gt;, which runs for every zsh invocation:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ~/.zshenv&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;PNPM_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/pnpm&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;typeset&lt;/span&gt; -U path PATH &lt;span class="c1"&gt;# auto-dedupe&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.local/bin&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$PNPM_HOME&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;[&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cargo/env&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; . &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cargo/env&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;typeset -U path PATH&lt;/code&gt; ties the &lt;code&gt;path&lt;/code&gt; array to the &lt;code&gt;PATH&lt;/code&gt; scalar and deduplicates automatically. No more 8 KB &lt;code&gt;PATH&lt;/code&gt; strings with the same entry six times because nested shells kept prepending.&lt;/p&gt;&#10;&lt;p&gt;This is not a cold-start win, since &lt;code&gt;.zshenv&lt;/code&gt; still runs. It is a correctness fix. A shorter &lt;code&gt;PATH&lt;/code&gt; does make command lookups faster, so you get a little of both.&lt;/p&gt;&#10;&lt;h2 id="8-fix-the-things-you-didnt-know-were-broken"&gt;8. Fix the things you didn&amp;rsquo;t know were broken&lt;/h2&gt;&#10;&lt;p&gt;While I was in there, &lt;code&gt;zprof&lt;/code&gt; and a slow re-read of the config surfaced a stack of small bugs that had nothing to do with cold start. They were just wrong:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;~/.profile&lt;/code&gt; was being sourced twice, once from &lt;code&gt;.zshrc&lt;/code&gt; and once from &lt;code&gt;.zshenv&lt;/code&gt;. 20-80 ms of pure waste.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;compinit&lt;/code&gt;&amp;rsquo;s cache-staleness check had inverted-looking logic and a misleading comment. It happened to do the right thing, but only by accident.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;zsh/.zsh/lib/completions.zsh&lt;/code&gt; referenced &lt;code&gt;$ZSH&lt;/code&gt;, an Oh My Zsh leftover that was unset in my setup. The completion cache-path was silently expanding to &lt;code&gt;/cache/&lt;/code&gt;, the filesystem root. Nothing got written there because the directory was not writable, so completions had no cache at all. Caching had quietly been off the whole time.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;compdef&lt;/code&gt; calls from eager tool initializations were arriving &lt;em&gt;before&lt;/em&gt; turbo-mode &lt;code&gt;compinit&lt;/code&gt; had run, so completions for those tools were not registering. I fixed it with a small shim that queues the calls until &lt;code&gt;compinit&lt;/code&gt; is ready:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; ! &lt;span class="o"&gt;((&lt;/span&gt; &lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="p"&gt;+functions[compdef]&lt;/span&gt;&lt;span class="si"&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;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;typeset&lt;/span&gt; -ga __deferred_compdefs&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; compdef&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;__deferred_compdefs&lt;/span&gt;&lt;span class="o"&gt;+=(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="p"&gt;(j: :)@&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then in the turbo block&amp;rsquo;s &lt;code&gt;atinit&lt;/code&gt;, after &lt;code&gt;zicompinit; zicdreplay&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;((&lt;/span&gt; &lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;__deferred_compdefs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; c in &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;__deferred_compdefs&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;compdef &lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;unset&lt;/span&gt; __deferred_compdefs&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;None of those moved the startup number. They were still bugs, and now they are not.&lt;/p&gt;&#10;&lt;h2 id="doing-this-on-your-own-config"&gt;Doing this on your own config&lt;/h2&gt;&#10;&lt;p&gt;Measure before you touch anything. &lt;code&gt;time zsh -i -c exit&lt;/code&gt; for cold start, &lt;code&gt;zprof&lt;/code&gt; for the in-shell breakdown. Skip that step and you will spend an afternoon optimizing the wrong thing. I have done exactly that.&lt;/p&gt;&#10;&lt;p&gt;Then grep your shell history against the alias and function lists of whatever plugins you load. You will usually find you are loading hundreds of definitions to use a handful.&lt;/p&gt;&#10;&lt;p&gt;Defer what can be deferred. First-prompt latency is what you feel, and almost nothing actually has to load before you can type. Cache expensive &lt;code&gt;eval&lt;/code&gt;s whose output only changes when the binary changes, and key the cache on the binary&amp;rsquo;s mtime so upgrades invalidate it for you. Hardcode the constants: &lt;code&gt;brew --prefix coreutils&lt;/code&gt; is always &lt;code&gt;${HOMEBREW_PREFIX}/opt/coreutils&lt;/code&gt;. Forking a Ruby program to ask is silly.&lt;/p&gt;&#10;&lt;p&gt;And keep environment variables in &lt;code&gt;.zshenv&lt;/code&gt;, not &lt;code&gt;.zshrc&lt;/code&gt;. Future-you, debugging why something works in iTerm but not in a cron job, will be grateful.&lt;/p&gt;&#10;&lt;h2 id="estimated-total"&gt;Estimated total&lt;/h2&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Change&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Savings&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Stop forking &lt;code&gt;brew --prefix&lt;/code&gt; × 5&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;~180 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Defer plugins with &lt;code&gt;zinit wait&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;100-300 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Cache remaining &lt;code&gt;eval&lt;/code&gt;s&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;50-100 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Compile &lt;code&gt;.zcompdump&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;30-80 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Drop &lt;code&gt;fnm&lt;/code&gt; (mise covers it)&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;30-80 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;strong&gt;~400-700 ms&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;These are estimates, not measurements. Your actual numbers depend on your hardware, your plugin set, and how much cruft you had accumulated over the years. Under 200 ms on Apple Silicon is doable with stock zsh and a small, well-deferred plugin set.&lt;/p&gt;&#10;&lt;p&gt;My dotfiles, with all of this applied, are at &lt;a href="https://github.com/theskumar/dotfiles" rel="noopener external"&gt;github.com/theskumar/dotfiles&lt;/a&gt;. The &lt;code&gt;zsh/OPTIMIZATION.md&lt;/code&gt; file there is the running log of what is done and what is still open.&lt;/p&gt;&#10;&lt;p&gt;I plan to repeat the audit as the config changes.&lt;/p&gt;&#10;</description></item><item><title>Hono</title><link>https://saurabh-kumar.com/til/1770724800/</link><pubDate>Tue, 10 Feb 2026 12:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/til/1770724800/</guid><description>&lt;p&gt;&lt;a href="https://hono.dev/" rel="noopener external"&gt;Hono&lt;/a&gt; based on web standards is a great option for js runtimes esp. if you plan to deploy your code to cloudflare workers, bun or deno. You get faster startup time and cross-platform compatibility.&lt;/p&gt;&#10;</description></item><item><title>Redis’s License Change and the Valkey Fork</title><link>https://saurabh-kumar.com/articles/2026/01/rediss-license-change-and-the-valkey-fork/</link><pubDate>Mon, 12 Jan 2026 00:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/articles/2026/01/rediss-license-change-and-the-valkey-fork/</guid><description>&lt;h2 id="the-license-change"&gt;The License Change&lt;/h2&gt;&#10;&lt;p&gt;On March 20, 2024, Redis Inc. announced a switch from the BSD 3-clause license to dual source-available licensing: RSALv2 and SSPLv1. The new licensing takes effect with Redis 7.4 and restricts how the code can be offered as a service.&lt;/p&gt;&#10;&lt;p&gt;I see this as a loss for the open-source community, even if Redis Inc. considers it a business necessity.&lt;/p&gt;&#10;&lt;h2 id="the-thing-about-open-source"&gt;The Thing About Open Source&lt;/h2&gt;&#10;&lt;p&gt;Redis was created by Salvatore Sanfilippo in 2009, known as &amp;ldquo;antirez&amp;rdquo; in the community. He built Redis to solve a simple problem: web applications were slow because databases couldn&amp;rsquo;t keep up. What if, instead of storing data on disk, you kept everything in memory? What if you made it blazingly fast?&lt;/p&gt;&#10;&lt;p&gt;Redis became legendary. By 2024, it was everywhere—caching layers for social media platforms, session stores for e-commerce sites, real-time analytics for financial systems. Developers loved it. Not just because it was fast, but because it was &lt;em&gt;theirs&lt;/em&gt;. They could use it, modify it, deploy it however they wanted.&lt;/p&gt;&#10;&lt;p&gt;Then Redis Inc. changed the rules.&lt;/p&gt;&#10;&lt;h2 id="the-rebellion"&gt;The Rebellion&lt;/h2&gt;&#10;&lt;p&gt;Kyle Davis, a longtime Redis contributor, said something interesting after the split: &amp;ldquo;From this point forward, Redis and Valkey are two different pieces of software.&amp;rdquo; Not just different licenses—different software.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Just eight days later, on March 28, 2024&lt;/strong&gt;, something remarkable happened. The Linux Foundation announced Valkey, a fork starting from Redis 7.2.4, the last truly open version. But this wasn&amp;rsquo;t some ragtag group of angry developers. This was AWS, Google Cloud, Oracle, Ericsson, and Snap Inc.—companies with deep pockets and deeper technical expertise.&lt;/p&gt;&#10;&lt;p&gt;They weren&amp;rsquo;t just copying Redis and giving it a new name. They were going to &lt;em&gt;improve&lt;/em&gt; it.&lt;/p&gt;&#10;&lt;h2 id="the-performance-revolution"&gt;The Performance Revolution&lt;/h2&gt;&#10;&lt;p&gt;Here&amp;rsquo;s where the story gets interesting, because it stops being about philosophy and starts being about engineering.&lt;/p&gt;&#10;&lt;p&gt;Redis, for all its speed, had a design constraint: it was single-threaded. Salvatore made this choice deliberately. Single-threading avoids all the complexity of concurrent programming—no locks, no race conditions, no debugging nightmares. For years, this was fine. Processors were getting faster, and Redis was fast enough.&lt;/p&gt;&#10;&lt;p&gt;But by 2024, we&amp;rsquo;re not making processors faster anymore—we&amp;rsquo;re making them wider. Your laptop has eight cores, maybe sixteen. Your cloud server has dozens. And Redis, with its elegant single-threaded design, was using&amp;hellip; one.&lt;/p&gt;&#10;&lt;p&gt;The Valkey team saw their opportunity.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Valkey 8.0, released on September 15, 2024&lt;/strong&gt;, introduced enhanced multi-threaded I/O. Not for the core data operations—those stayed single-threaded to preserve Redis&amp;rsquo;s simplicity and safety—but for network operations, for handling connections, for moving data in and out.&lt;/p&gt;&#10;&lt;p&gt;The results were dramatic. In benchmarks on AWS hardware, Valkey 8.0 hit 1.19 million requests per second. That&amp;rsquo;s 230% more than Valkey 7.2. That&amp;rsquo;s the kind of improvement that makes CTOs take notice.&lt;/p&gt;&#10;&lt;p&gt;But the engineers didn&amp;rsquo;t stop there.&lt;/p&gt;&#10;&lt;h2 id="the-memory-game"&gt;The Memory Game&lt;/h2&gt;&#10;&lt;p&gt;&lt;strong&gt;Valkey 8.1, released on March 31, 2025&lt;/strong&gt;, arrived with something even more impressive: a complete redesign of how data is stored in memory.&lt;/p&gt;&#10;&lt;p&gt;The team rebuilt the hash table—the fundamental data structure at the heart of any key-value store—using modern algorithms inspired by Google&amp;rsquo;s &amp;ldquo;Swiss Tables&amp;rdquo; design. Every key-value pair now uses about 20-30 bytes less memory. That might not sound like much, but scale it up.&lt;/p&gt;&#10;&lt;p&gt;When researchers tested Valkey 8.1 against Redis 8.2 with 50 million entries, Valkey consumed 3.77 GB while Redis used 4.83 GB. That&amp;rsquo;s a 28% reduction. For a company running terabytes of cached data across hundreds of servers, that&amp;rsquo;s not just impressive—it&amp;rsquo;s millions of dollars in annual savings.&lt;/p&gt;&#10;&lt;h2 id="the-feature-race"&gt;The Feature Race&lt;/h2&gt;&#10;&lt;p&gt;Of course, efficiency isn&amp;rsquo;t everything. Redis Inc. didn&amp;rsquo;t just sit back and watch. &lt;strong&gt;In May 2025, Redis released version 8.0&lt;/strong&gt;, bundling their enterprise features—JSON support, vector search for AI applications, time series databases—directly into the core product. These are sophisticated capabilities that took years to develop.&lt;/p&gt;&#10;&lt;p&gt;Valkey&amp;rsquo;s roadmap is playing catch-up here. Version 8.1 added JSON and Bloom filters. Vector search support became available through the valkey-search module. Time series support is planned. But there&amp;rsquo;s still a gap.&lt;/p&gt;&#10;&lt;p&gt;This is the classic innovator&amp;rsquo;s dilemma: Redis has more features &lt;em&gt;today&lt;/em&gt;. Valkey has better fundamentals and a trajectory that suggests they&amp;rsquo;ll have those features &lt;em&gt;tomorrow&lt;/em&gt;—and they&amp;rsquo;ll run faster when they do.&lt;/p&gt;&#10;&lt;h2 id="the-cloud-complication"&gt;The Cloud Complication&lt;/h2&gt;&#10;&lt;p&gt;Here&amp;rsquo;s where it gets messy. When you go to AWS and spin up what was formerly called &amp;ldquo;Redis,&amp;rdquo; you might now get Valkey. Same with Google Cloud&amp;rsquo;s Memorystore.&lt;/p&gt;&#10;&lt;p&gt;These managed services have their own quirks and limitations, shaped by what the cloud providers choose to support. The result? More fragmentation.&lt;/p&gt;&#10;&lt;p&gt;Managed Redis-compatible services now include both projects, with different feature sets and provider support.&lt;/p&gt;&#10;&lt;h2 id="the-choice"&gt;The Choice&lt;/h2&gt;&#10;&lt;p&gt;So where does this leave developers in 2026?&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re starting a new project, Valkey offers true open-source freedom, better performance on multi-core systems, and superior memory efficiency. You sacrifice some advanced features, but its BSD license permits use, modification, and redistribution under its terms.&lt;/p&gt;&#10;&lt;p&gt;If you need those advanced features &lt;em&gt;right now&lt;/em&gt;—vector search for AI applications, sophisticated time series analysis, enterprise support—Redis has them. But you&amp;rsquo;re accepting the terms Redis Inc. sets, and those terms have already changed once.&lt;/p&gt;&#10;&lt;p&gt;For existing Redis deployments, the calculation is harder. Migration costs are real. But so is the risk of depending on a vendor that rewrote the rules mid-game.&lt;/p&gt;&#10;&lt;h2 id="my-take"&gt;My Take&lt;/h2&gt;&#10;&lt;p&gt;Valkey started with an established codebase and backing from major companies, including AWS, Google Cloud, Oracle, Ericsson, and Snap Inc. Its performance and memory work give developers reasons to evaluate it beyond the license.&lt;/p&gt;&#10;&lt;p&gt;I prefer Valkey’s permissive licensing and foundation governance. To me, the fork shows why the ability to keep developing an existing open-source codebase matters when a vendor changes terms. Both projects continue to evolve, and the choice still depends on features, migration costs, and workload measurements.&lt;/p&gt;&#10;</description></item><item><title>how uv got so fast</title><link>https://saurabh-kumar.com/til/1766836800/</link><pubDate>Sat, 27 Dec 2025 12:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/til/1766836800/</guid><description>&lt;p&gt;&lt;a href="https://nesbitt.io/2025/12/26/how-uv-got-so-fast.html" rel="noopener external"&gt;how uv got so fast&lt;/a&gt;. UV performance is because of design decisions while rust contributes to micro optimizations.&lt;/p&gt;&#10;</description></item><item><title>Django Native PostgreSQL Connection Pooling</title><link>https://saurabh-kumar.com/articles/2025/06/django-native-postgresql-connection-pooling/</link><pubDate>Wed, 18 Jun 2025 00:00:00 +0000</pubDate><guid>https://saurabh-kumar.com/articles/2025/06/django-native-postgresql-connection-pooling/</guid><description>&lt;p&gt;Opening PostgreSQL connections adds overhead. Django 5.1 (released August 7, 2024) added native connection pooling&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;, which lets an application reuse connections. Measure connection overhead and response times before and after enabling it; the benefit depends on your workload.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;If you&amp;rsquo;re on Django 6+ and running async/ASGI, also see the async pool notes in &lt;a href="https://saurabh-kumar.com/articles/2026/06/django-orm-from-sync_to_async-threads-to-native-psycopg3/"&gt;How Django&amp;rsquo;s ORM Went From&#10;sync_to_async Threads to Native&#10;psycopg3&lt;/a&gt;.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;h2 id="native-pooling-vs-external-pooling"&gt;Native Pooling vs External Pooling&lt;/h2&gt;&#10;&lt;p&gt;PgBouncer runs as a separate pooling server. Django’s native pooling runs in the application process, so it does not require a separate server. It does require psycopg’s pool package. Third-party Django pooling packages and manual connection management are other options, with their own maintenance and configuration needs.&lt;/p&gt;&#10;&lt;h2 id="configure-native-pooling"&gt;Configure Native Pooling&lt;/h2&gt;&#10;&lt;p&gt;&lt;strong&gt;Requirements&lt;/strong&gt;: Django 5.1+ and PostgreSQL (this feature does not work with&#10;psycopg2)&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;&#10;&lt;p&gt;Install the correct package:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pip install &lt;span class="s2"&gt;&amp;#34;psycopg[binary,pool]&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Critical&lt;/strong&gt;: The package is called &lt;code&gt;psycopg&lt;/code&gt; (not &lt;code&gt;psycopg3&lt;/code&gt;). The pool&#10;functionality requires a separate package that this command installs&#10;automatically&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&#10;&lt;p&gt;Add these lines to your &lt;code&gt;settings.py&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;default&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;ENGINE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;django.db.backends.postgresql&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;NAME&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;your_db_name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;USER&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;your_db_user&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;PASSWORD&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;your_db_password&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;HOST&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;your_db_host&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;PORT&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;5432&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;CONN_MAX_AGE&amp;#34;&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 - pooling fails without this&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;OPTIONS&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;pool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Deploy this change. Your app now reuses connections instead of creating new ones&#10;for every request.&lt;/p&gt;&#10;&lt;h2 id="prevent-the-configuration-error-that-breaks-production"&gt;Prevent the Configuration Error That Breaks Production&lt;/h2&gt;&#10;&lt;p&gt;&lt;strong&gt;Must set &lt;code&gt;CONN_MAX_AGE = 0&lt;/code&gt;&lt;/strong&gt;. Without this, Django throws&#10;&lt;code&gt;ImproperlyConfigured: Pooling doesn't support persistent connections&lt;/code&gt; and your&#10;app crashes&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;. The pool manages connection lifetimes, making Django&amp;rsquo;s&#10;persistence redundant.&lt;/p&gt;&#10;&lt;h2 id="scale-for-high-traffic-production"&gt;Scale for High-Traffic Production&lt;/h2&gt;&#10;&lt;p&gt;Replace &lt;code&gt;&amp;quot;pool&amp;quot;: True&lt;/code&gt; with specific parameters for production loads:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;default&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;ENGINE&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;django.db.backends.postgresql&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;NAME&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;your_db_name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;USER&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;your_db_user&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;PASSWORD&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;your_db_password&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;HOST&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;your_db_host&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;PORT&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;5432&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;CONN_MAX_AGE&amp;#34;&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;OPTIONS&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;pool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;min_size&amp;#34;&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;# Keeps connections warm&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;max_size&amp;#34;&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;# Handles traffic spikes&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;timeout&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Fails fast under extreme load&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;max_lifetime&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# 30 minutes maximum connection age&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;max_idle&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Close idle connections after 5 minutes&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Start with &lt;code&gt;max_size&lt;/code&gt; equal to expected concurrent users divided by 10. Scale up&#10;if you see timeout errors in logs.&lt;/p&gt;&#10;&lt;h2 id="monitor-your-performance-gains"&gt;Monitor Your Performance Gains&lt;/h2&gt;&#10;&lt;p&gt;Enable pool monitoring to track your improvements:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# In settings.py&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;logging&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;psycopg.pool&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Watch pool utilization, connection wait times, timeout errors, and response times. Record median and 95th percentile latency before and after the change under comparable load. Use those measurements to size the pool and judge whether pooling helps your application.&lt;/p&gt;&#10;&lt;h2 id="critical-fix-for-threading-and-celery-applications"&gt;Critical Fix for Threading and Celery Applications&lt;/h2&gt;&#10;&lt;p&gt;&lt;strong&gt;Essential for background processing&lt;/strong&gt;: This isn&amp;rsquo;t a pooling bug — it&amp;rsquo;s expected&#10;Django behavior that pooling simply makes painful. Any connection opened outside&#10;Django&amp;rsquo;s request-response cycle (for example, in a thread you spawn yourself)&#10;stays open until you explicitly close it. Without a pool you might not notice;&#10;with one, those un-returned connections exhaust &lt;code&gt;max_size&lt;/code&gt; and you start seeing&#10;&lt;code&gt;PoolTimeout&lt;/code&gt; errors&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt;. Add explicit cleanup:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;threading&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Thread&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DataProcessingThread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Your database operations here&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_seen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Critical: returns the connection to the pool&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For code that may touch multiple database aliases, Django&amp;rsquo;s documented helper&#10;&lt;code&gt;django.db.close_old_connections()&lt;/code&gt; is the more robust choice — it closes all&#10;old or unusable connections across every configured database:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;close_old_connections&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DataProcessingThread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_seen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;close_old_connections&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without this cleanup, each background thread permanently holds a connection from&#10;your pool.&lt;/p&gt;&#10;&lt;h2 id="deployment-scenarios-that-require-different-approaches"&gt;Deployment Scenarios That Require Different Approaches&lt;/h2&gt;&#10;&lt;p&gt;&lt;strong&gt;ASGI applications&lt;/strong&gt;: On Django 5.1–5.2, Django&amp;rsquo;s documentation recommends&#10;against native pooling with ASGI — use PgBouncer or similar external poolers&#10;instead. This changed in Django 6.0, which ships an async-aware&#10;&lt;code&gt;AsyncConnectionPool&lt;/code&gt; that works correctly under ASGI. I cover how that fits&#10;together with the truly-async ORM in &lt;a href="https://saurabh-kumar.com/articles/2026/06/django-orm-from-sync_to_async-threads-to-native-psycopg3/"&gt;How Django&amp;rsquo;s ORM Went From sync_to_async&#10;Threads to Native psycopg3&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Serverless deployments&lt;/strong&gt;: Connection pools don&amp;rsquo;t persist across serverless&#10;invocations. Skip this optimization for Lambda/Cloud Functions.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Multi-tenant applications&lt;/strong&gt;: Each tenant database needs its own pool&#10;configuration in &lt;code&gt;DATABASES&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="when-to-evaluate-pooling"&gt;When to Evaluate Pooling&lt;/h2&gt;&#10;&lt;p&gt;Evaluate pooling when connection establishment is a measurable part of request latency, concurrency is growing, or the application is approaching PostgreSQL connection limits. Compare measurements before and after rather than assuming a fixed latency or cost saving.&lt;/p&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;&lt;a href="https://docs.djangoproject.com/en/5.1/releases/5.1/" rel="noopener external"&gt;Django 5.1 Release Notes&lt;/a&gt; - Official Django documentation announcing native connection pooling support&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:2"&gt;&#10;&lt;p&gt;&lt;a href="https://docs.djangoproject.com/en/5.2/ref/databases/" rel="noopener external"&gt;Django Database Configuration&lt;/a&gt; - Complete guide to Django database settings including pooling requirements&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:3"&gt;&#10;&lt;p&gt;&lt;a href="https://www.psycopg.org/psycopg3/docs/basic/install.html" rel="noopener external"&gt;psycopg Installation Guide&lt;/a&gt; - Official psycopg documentation for package installation and pool dependencies&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:4"&gt;&#10;&lt;p&gt;&lt;a href="https://stackoverflow.com/questions/78879329/django-improperlyconfigured-pooling-doesnt-support-persistent-connections" rel="noopener external"&gt;Stack Overflow: Django Pooling Configuration Error&lt;/a&gt; - Community discussion of the CONN_MAX_AGE requirement&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:5"&gt;&#10;&lt;p&gt;&lt;a href="https://code.djangoproject.com/ticket/35672" rel="noopener external"&gt;Django Ticket #35672&lt;/a&gt; - Closed as &lt;em&gt;invalid&lt;/em&gt;: connections opened in threads must be returned manually. See also the &lt;a href="https://docs.djangoproject.com/en/stable/ref/databases/#caveats" rel="noopener external"&gt;Databases → Caveats&lt;/a&gt; docs, which recommend &lt;code&gt;django.db.close_old_connections()&lt;/code&gt; for connections created outside the request-response cycle.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item></channel></rss>