<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Anti-Patterns on EXPLAIN ANALYZE</title>
        <link>https://explainanalyze.com/categories/anti-patterns/</link>
        <description>Recent content in Anti-Patterns on EXPLAIN ANALYZE</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-us</language>
        <lastBuildDate>Wed, 22 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://explainanalyze.com/categories/anti-patterns/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>Covering Index Traps: When Adding One Column Breaks Your Query</title>
            <link>https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/</link>
            <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post Covering Index Traps: When Adding One Column Breaks Your Query&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;An index-only scan is the fastest way a relational database can answer a query — the engine reads the index and never touches the table. Adding a single column to the SELECT list that isn&amp;rsquo;t in the index silently breaks that optimization, and the query that ran in a millisecond now takes seconds on the same data. The fix isn&amp;rsquo;t &amp;ldquo;never SELECT extra columns&amp;rdquo; — it&amp;rsquo;s knowing that the SELECT list is part of the query&amp;rsquo;s performance contract with the index.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;Here&amp;rsquo;s a query that ran in production for a year with sub-millisecond latency:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;customer_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;42&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;orders&lt;/code&gt; table has a composite index on &lt;code&gt;(customer_id, status, created_at)&lt;/code&gt;. Every column the query needs — &lt;code&gt;customer_id&lt;/code&gt; for the filter, &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;created_at&lt;/code&gt; for the output — is in that index. The database reads the index, returns the results, and never touches the table. This is an &lt;strong&gt;index-only scan&lt;/strong&gt;: one of the most significant optimizations a relational engine makes, and the mechanism behind &amp;ldquo;covering&amp;rdquo; queries.&lt;/p&gt;&#xA;&lt;p&gt;Then a feature request: &amp;ldquo;show the order total on this page.&amp;rdquo; The change looks trivial.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;total_cents&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;customer_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;42&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;One column added. The query is still correct. The index still matches the filter. But &lt;code&gt;total_cents&lt;/code&gt; isn&amp;rsquo;t in the index — so for every matching row, the engine now follows a pointer back to the table to fetch that one extra column. On a table with millions of rows, that&amp;rsquo;s a random I/O per match. The query that was 0.4 ms is now 1243 ms.&lt;/p&gt;&#xA;&lt;p&gt;The obvious fix is &amp;ldquo;just don&amp;rsquo;t add columns to queries.&amp;rdquo; That doesn&amp;rsquo;t work — features need data. The slightly-less-obvious fix is &amp;ldquo;always project the minimum columns,&amp;rdquo; which is fine as advice and ignored in practice because every ORM defaults to &lt;code&gt;SELECT *&lt;/code&gt;. The actual fix is to treat the SELECT list as part of the query&amp;rsquo;s performance contract with the index, and to know what that contract is before changing it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;whats-actually-happening&#34;&gt;What&amp;rsquo;s actually happening&#xA;&lt;/h2&gt;&lt;p&gt;The execution plan tells the whole story:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Before: index-only scan&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;EXPLAIN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ANALYZE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;customer_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;42&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Index Only Scan using idx_orders_cust_status_created on orders&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Heap Fetches: 0&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Execution Time: 0.4 ms&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- After: index scan + table lookups&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;EXPLAIN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ANALYZE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;total_cents&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;customer_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;42&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Index Scan using idx_orders_cust_status_created on orders&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Execution Time: 1243.7 ms&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;Same index. Same filter. Same rows returned. The only difference is the select list, and it moves the query from a pure index walk to an index walk plus one random I/O per matching row.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Buffer pool pollution compounds the damage.&lt;/strong&gt; When the engine fetches full rows from the table instead of reading compact index entries, it loads entire data pages into the buffer pool. Those pages — carrying every column of every matched row, most of which the query doesn&amp;rsquo;t need — evict pages that other queries do need. On a busy system with a finite buffer pool, one query losing its covering index degrades performance for unrelated queries across the database. The slow query you noticed is rarely the only thing getting slower.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Nothing in the query results tells you.&lt;/strong&gt; The rows come back correctly. The response looks the same. A &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; returns the same count. The only place the degradation is visible is in the execution plan — and nobody checks the execution plan when the feature ships.&lt;/p&gt;&#xA;&lt;div class=&#34;note-box&#34;&gt;&#xA;    &lt;strong&gt;ORM defaults&lt;/strong&gt;&#xA;    &lt;div&gt;Most ORMs emit &lt;code&gt;SELECT *&lt;/code&gt; unless explicitly told otherwise. ActiveRecord needs &lt;code&gt;.select(:id, :status)&lt;/code&gt;; Django needs &lt;code&gt;.only(&#39;id&#39;, &#39;status&#39;)&lt;/code&gt;; SQLAlchemy needs explicit column specification; Prisma needs an explicit &lt;code&gt;select&lt;/code&gt; block. On a high-traffic table, a one-line change to project only the needed columns is one of the highest-leverage optimizations available. Worth checking what your ORM actually generates on the query paths that matter — the generated SQL is the contract, not the method call.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;the-fix-match-the-index-or-extend-it&#34;&gt;The fix: match the index or extend it&#xA;&lt;/h2&gt;&lt;p&gt;There are two workable fixes when a query loses its covering index, and they trade different costs:&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Project only what the index covers.&lt;/strong&gt; If the new column isn&amp;rsquo;t worth fetching from the table on every row, don&amp;rsquo;t fetch it. Split the query: one covered query for the list view, a targeted lookup for the detail row the user actually wants. Most feature requests that &amp;ldquo;need&amp;rdquo; an extra column on a list page are actually fine with lazy-loading the value on click.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Extend the index to include the new column.&lt;/strong&gt; If the column is genuinely needed on every row, add it to the index — either as an additional indexed column or (in PostgreSQL) as an &lt;code&gt;INCLUDE&lt;/code&gt; clause that adds the value to the leaf pages without making it part of the B-tree ordering:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- PostgreSQL: add total_cents as a non-key included column&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;INDEX&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;idx_orders_cust_status_created_total&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;customer_id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;INCLUDE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;total_cents&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;&lt;code&gt;INCLUDE&lt;/code&gt; is the right tool when you need the column covered but don&amp;rsquo;t want it affecting the sort order or filter path. The trade-off is write cost: the index is now larger, and every update to &lt;code&gt;total_cents&lt;/code&gt; has to update the index entry. On a write-heavy table that&amp;rsquo;s meaningful; on a read-heavy table it&amp;rsquo;s usually negligible compared to the read speedup.&lt;/p&gt;&#xA;&lt;p&gt;MySQL (InnoDB) doesn&amp;rsquo;t support &lt;code&gt;INCLUDE&lt;/code&gt; but has a natural equivalent: every secondary index already contains the primary key at its leaves, and you can extend the secondary index to cover additional columns by adding them as regular key columns. The planner is smart enough to use the covered form when the column is present.&lt;/p&gt;&#xA;&lt;h2 id=&#34;when-covering-isnt-the-right-call&#34;&gt;When covering isn&amp;rsquo;t the right call&#xA;&lt;/h2&gt;&lt;p&gt;Covering indexes aren&amp;rsquo;t a universal good. Three cases where chasing a covering index is the wrong move:&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Low-selectivity filters.&lt;/strong&gt; If &lt;code&gt;customer_id = 42&lt;/code&gt; matches 80% of the table, the planner won&amp;rsquo;t use the index at all — a sequential scan is cheaper. Index-only scans matter when the filter is selective. On a low-selectivity predicate, covering changes nothing.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Write-heavy tables.&lt;/strong&gt; Every index slows writes. A table taking 50,000 inserts per second with five secondary indexes already pays a real cost for every index entry. Adding a covering variant of an existing index to shave read latency from 15 ms to 3 ms is a bad trade if the table is write-dominated — the write penalty compounds on every row, and only the reads benefit.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Rapidly changing projections.&lt;/strong&gt; If the feature team is adding and removing columns from the list view every sprint, chasing the covering index is a losing game. Freeze the list-view columns as a contract, document them in the schema, and let the index match that contract — or don&amp;rsquo;t bother indexing for coverage at all.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&lt;/h2&gt;&lt;p&gt;The SELECT list is a performance contract in most code reviewers&amp;rsquo; blind spot. WHERE clauses get scrutinized because they&amp;rsquo;re obviously performance-relevant. JOINs get scrutinized because cardinality mistakes are visible. The SELECT list gets waved through because &amp;ldquo;it&amp;rsquo;s just what we display&amp;rdquo; — and then a one-column addition drops a query from 0.4 ms to 1243 ms with no code-review signal to catch it.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is the only authority here. Reading execution plans isn&amp;rsquo;t glamorous, but it&amp;rsquo;s the difference between a query that works and a query that works at scale — and between a select-list change that&amp;rsquo;s free and one that silently broke the optimization the index existed to enable. On the queries that carry the most traffic, the execution plan belongs in code review alongside the query itself.&lt;/p&gt;&#xA;</description>
        </item><item>
            <title>God Tables: 150 Columns and the Quiet Cost of &#39;Just Add a Column&#39;</title>
            <link>https://explainanalyze.com/p/god-tables-150-columns-and-the-quiet-cost-of-just-add-a-column/</link>
            <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/god-tables-150-columns-and-the-quiet-cost-of-just-add-a-column/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post God Tables: 150 Columns and the Quiet Cost of &#39;Just Add a Column&#39;&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;A wide table looks cheap because every column was added for a real reason — the expensive part is that rows grow, every write amplifies, and every secondary index inherits the bloat. The fix isn&amp;rsquo;t aggressive normalization (which trades one wide table for six-way joins on every read) but splitting by access pattern: columns read together stay together, rarely-touched columns move out.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;The schema started clean four years ago: &lt;code&gt;users(id, email, password_hash, created_at)&lt;/code&gt; — four columns. Today the table is renamed &lt;code&gt;customers&lt;/code&gt; and has 184 columns. Billing address. Shipping address. Three additional shipping addresses numbered 2 through 4. &lt;code&gt;preferences_json&lt;/code&gt; for user settings. Twelve feature-flag &lt;code&gt;TINYINT&lt;/code&gt;s. Three Stripe identifiers from three processor migrations. &lt;code&gt;last_login_at&lt;/code&gt;, &lt;code&gt;last_seen_at&lt;/code&gt;, &lt;code&gt;last_purchase_at&lt;/code&gt;, &lt;code&gt;last_notification_sent_at&lt;/code&gt;. Forty more columns whose meaning lives in Confluence, if anywhere. No single &lt;code&gt;ALTER TABLE ADD COLUMN&lt;/code&gt; was unreasonable at the time. The accumulated result is an average row size of 6KB, an UPDATE to &lt;code&gt;last_login_at&lt;/code&gt; that rewrites every byte of it, and a buffer pool holding four customer rows per page instead of forty.&lt;/p&gt;&#xA;&lt;p&gt;The obvious fix is to normalize it — split into &lt;code&gt;customer_profile&lt;/code&gt;, &lt;code&gt;customer_billing&lt;/code&gt;, &lt;code&gt;customer_addresses&lt;/code&gt;, &lt;code&gt;customer_preferences&lt;/code&gt;, &lt;code&gt;customer_feature_flags&lt;/code&gt;, &lt;code&gt;customer_audit&lt;/code&gt;. That&amp;rsquo;s the textbook answer and it&amp;rsquo;s the one that breaks the moment you look at the dominant read. The list view on the admin page needs name, email, status, last login, Stripe status, and total spent — now it&amp;rsquo;s a six-way join on every page load. The fix that looked clean in the migration doc makes the most-frequent query more expensive, not less. The read cost moves to the place it&amp;rsquo;s paid most often, and somebody — usually a few months later — proposes a materialized view to &amp;ldquo;just flatten it back out,&amp;rdquo; which is the god table returning through a different door.&lt;/p&gt;&#xA;&lt;h2 id=&#34;how-a-row-store-actually-reads-a-row&#34;&gt;How a row-store actually reads a row&#xA;&lt;/h2&gt;&lt;p&gt;Before the cost math makes sense: OLTP engines like InnoDB and PostgreSQL&amp;rsquo;s heap store complete rows laid out contiguously on fixed-size pages — typically 16KB in InnoDB, 8KB in PostgreSQL. A page holds as many rows as fit. When a query needs one column of one row, the engine doesn&amp;rsquo;t read that column alone; it locates the row&amp;rsquo;s page via an index lookup or scan, loads the whole page into the buffer pool, and reads the requested column out of the in-memory row image.&lt;/p&gt;&#xA;&lt;p&gt;The one exception is the index-only scan: if every column the query projects and filters on is already present inside an index, the base table doesn&amp;rsquo;t have to be touched and only the index pages are loaded. See &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/&#34; &gt;Covering Index Traps&lt;/a&gt; for how quickly this optimization disappears — usually the moment a SELECT list grows by one column. Every other read path goes through the row, which means the row&amp;rsquo;s width sets the floor on how much data the engine moves per lookup. Reading &lt;code&gt;email&lt;/code&gt; from a 184-column customer row loads 6KB into memory to return 50 bytes; reading the same column from an 800-byte row loads 800 bytes. The buffer pool is a fixed size and every byte of unused column data in it is displacing something another query needs.&lt;/p&gt;&#xA;&lt;p&gt;Column stores (ClickHouse, BigQuery, Parquet-backed warehouses) invert this entirely — data is laid out by column, so reading one column reads only that column&amp;rsquo;s storage. The wide-table cost math doesn&amp;rsquo;t apply there, which is why this anti-pattern is specifically a row-store OLTP problem and why denormalized fact tables in analytical warehouses are fine at 300 columns.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-150-columns-actually-costs&#34;&gt;What 150 columns actually costs&#xA;&lt;/h2&gt;&lt;p&gt;The individual cost of one column is negligible. The system-level cost shows up in several places at once, and none of them are visible in a diff that adds one more.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Row size and write amplification.&lt;/strong&gt; InnoDB stores full rows on disk pages, and an UPDATE rewrites the entire row even if only one column changed. On a 184-column table averaging 6KB per row, updating &lt;code&gt;last_login_at&lt;/code&gt; on every sign-in rewrites 6KB, not 8 bytes. PostgreSQL doesn&amp;rsquo;t rewrite in place — MVCC creates a new tuple for every UPDATE and marks the old one dead — but the new tuple is 6KB too, and &lt;code&gt;VACUUM&lt;/code&gt; has that much more to reclaim. Either engine, the write cost per logical change scales with row width.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Buffer pool density.&lt;/strong&gt; The page-per-read mechanism above means buffer-pool efficiency scales inversely with row width. At 6KB per row, an InnoDB 16KB page holds two rows; at 400 bytes per row it holds forty. A database with 10GB of buffer pool has the effective working set of a much smaller instance once rows get wide — queries that used to run hot start touching disk for no reason other than that the rows they cared about no longer fit in memory alongside the rows other queries cared about.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Secondary indexes inherit the width problem.&lt;/strong&gt; Every secondary index in InnoDB carries a copy of the primary key at its leaves; every index entry is a key-columns + PK-copy record. A wide table tends to accumulate indexes — you index email, Stripe ID, last-login, phone, region, account-manager-ID, each for a different query path. Six secondary indexes on a 184-column table isn&amp;rsquo;t unusual, and each of them is physically larger than it would be on a narrow table, because the PK copy and fill-factor choices interact with row density. &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/&#34; &gt;Covering indexes&lt;/a&gt; are also harder to arrange: the list view wants eight columns projected, and indexing eight columns of a 184-column table to cover one query is an expensive trade.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Lock and transaction width.&lt;/strong&gt; Every UPDATE acquires a row-level lock. Transactions that touch a wide row hold that lock for the duration of the transaction, and because the row spans many concerns — billing, preferences, audit timestamps — transactions from unrelated code paths contend on the same row. A background job updating &lt;code&gt;last_seen_at&lt;/code&gt; now serializes against a billing job updating &lt;code&gt;stripe_customer_id&lt;/code&gt; on the same customer, because both paths lock the same row. In the split-by-concern shape, they&amp;rsquo;d contend on different rows of different tables.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Schema migrations get more expensive.&lt;/strong&gt; &lt;code&gt;ALTER TABLE ADD COLUMN&lt;/code&gt; on a 184-column table is slower, holds metadata locks longer, and has a larger blast radius if it fails. MySQL&amp;rsquo;s online DDL is usually fine for NULL-default additions; PostgreSQL is generally fast for the same case. But any migration that needs to rewrite rows (changing a column type, adding NOT NULL with a backfill) scales with row size, and a 6KB row rewrite on 200 million rows is a different operation than an 800-byte row rewrite on the same count.&lt;/p&gt;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;Every column is a commitment&lt;/strong&gt;&#xA;    &lt;div&gt;The cost of adding a column is small and immediate. The cost of having 150 columns is systemic and deferred — buffer-pool density, index size, write amplification, lock contention, migration cost. None of the deferred costs are visible in the PR that adds one more column, which is why they accumulate uncorrected until the table is painful.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;why-llms-make-this-worse&#34;&gt;Why LLMs make this worse&#xA;&lt;/h2&gt;&lt;p&gt;Schema drift in the wide-table direction is what language models reinforce by default. A model generating &lt;code&gt;ALTER TABLE&lt;/code&gt; for a feature request reads the current schema and proposes the smallest change that makes the feature work — which is almost always adding columns to the table that already holds the related data. Proposing a split requires understanding the access pattern, the transaction boundaries, and the write frequency of the new columns versus the existing ones. None of that is in the &lt;code&gt;CREATE TABLE&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;The loop reinforces itself: the wider the table gets, the more natural it is for the next change to widen it further. &amp;ldquo;Where do loyalty tier and tier expiry go?&amp;rdquo; The model sees &lt;code&gt;customers&lt;/code&gt; has every other user-attached concept in it and adds two columns. The alternative — &lt;code&gt;CREATE TABLE customer_loyalty (customer_id PK FK, tier, expires_at)&lt;/code&gt; — requires the model to argue for a split, and splits are rare in the training data compared to additions because splits are rare in real codebases for the same reason: they&amp;rsquo;re harder to ship than additions. The model is correctly pattern-matching on what humans actually do, which is exactly the problem.&lt;/p&gt;&#xA;&lt;p&gt;ORMs compound this. One model equals one table is the default shape in ActiveRecord, Django ORM, Prisma, SQLAlchemy, and Ecto. Refactoring a &lt;code&gt;Customer&lt;/code&gt; model into three co-owned tables is a change that touches every query, every serializer, every test. The ORM makes &amp;ldquo;add a column to the existing model&amp;rdquo; a five-line change and &amp;ldquo;split the model&amp;rdquo; a project. Engineers pick the cheap option every time, and the wide table ratchets.&lt;/p&gt;&#xA;&lt;h2 id=&#34;split-by-access-pattern-not-by-concept&#34;&gt;Split by access pattern, not by concept&#xA;&lt;/h2&gt;&lt;p&gt;&amp;ldquo;Normalize it&amp;rdquo; isn&amp;rsquo;t the fix because normalization is a property of data shape, not query cost. The fix is to look at what columns are actually read and written together, and keep those co-located; the rest moves out.&lt;/p&gt;&#xA;&lt;p&gt;A workable decomposition for the &lt;code&gt;customers&lt;/code&gt; example:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Core hot table&lt;/strong&gt; — the columns read on nearly every query: &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;tier&lt;/code&gt;, &lt;code&gt;stripe_customer_id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;. Maybe twenty columns. This is what the list view, the auth path, and most API responses need.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;1:1 cold tables&lt;/strong&gt; — concerns that are read rarely or in specific flows: &lt;code&gt;customer_audit&lt;/code&gt; for login/seen/purchase timestamps, &lt;code&gt;customer_preferences&lt;/code&gt; for user settings, &lt;code&gt;customer_feature_flags&lt;/code&gt; for the twelve TINYINT flags. Each is a separate table with &lt;code&gt;customer_id&lt;/code&gt; as PK and FK, joined only when the flow actually needs it. Writes to &lt;code&gt;last_login_at&lt;/code&gt; stop rewriting the billing row.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;1:N tables for repeating groups&lt;/strong&gt; — addresses, payment methods, anything that was modeled as &lt;code&gt;shipping_address_2&lt;/code&gt;, &lt;code&gt;shipping_address_3&lt;/code&gt;, &lt;code&gt;shipping_address_4&lt;/code&gt; is an &lt;code&gt;addresses&lt;/code&gt; table with a FK and a type. This collapses polymorphic-ish schema decisions that shouldn&amp;rsquo;t have been made at the column level in the first place; see &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/&#34; &gt;Polymorphic References&lt;/a&gt; for the related pattern where doing this without a FK goes wrong.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The trade-off is that some queries now join two or three tables instead of reading one. On the hot path this is fine — the joins are on PK-equals-FK, the join tables are small, and the read is usually cheaper than scanning a fat row. The cold path is where it matters: the audit screen now joins &lt;code&gt;customers&lt;/code&gt; to &lt;code&gt;customer_audit&lt;/code&gt;, which costs one indexed lookup and nobody notices. The place to be careful is the query that reads from three of the split tables on every request — if that&amp;rsquo;s dominant, one of those tables probably belongs merged back in.&lt;/p&gt;&#xA;&lt;h2 id=&#34;when-a-wide-table-is-actually-fine&#34;&gt;When a wide table is actually fine&#xA;&lt;/h2&gt;&lt;p&gt;Not every 100-column table is a god table. Three cases where width is defensible:&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Analytical and reporting tables on columnar storage.&lt;/strong&gt; As noted above, warehouses like ClickHouse, BigQuery, and Redshift invert the cost calculus — reading one column doesn&amp;rsquo;t load the rest, and the normalization pressure flips: denormalize aggressively because joins are expensive and per-column reads are cheap. This anti-pattern is specifically a row-store OLTP problem.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Small tables that stay small.&lt;/strong&gt; A &lt;code&gt;tenants&lt;/code&gt; table with 80 columns and 500 rows fits entirely in the buffer pool. The write amplification is paid a few thousand times a day, not a few million. The secondary-index cost is negligible because the indexes are small. Width matters when row count is large enough for the per-row cost to dominate — on small tables it doesn&amp;rsquo;t.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Every query reads every column.&lt;/strong&gt; Uncommon but real. If the dominant read is &amp;ldquo;fetch the full customer record for display&amp;rdquo; and the split would produce a join that runs on every request anyway, the split doesn&amp;rsquo;t help. The test is whether the queries you actually run touch disjoint column sets — if they do, the split has a real win; if they don&amp;rsquo;t, it&amp;rsquo;s architecture for its own sake.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&lt;/h2&gt;&lt;p&gt;Relational databases aren&amp;rsquo;t built for developer convenience. They&amp;rsquo;re built for storage efficiency and retrieval speed — narrow rows, well-placed indexes, joins on indexed keys, query plans that read only what they need. Normalization isn&amp;rsquo;t an academic ideal; it&amp;rsquo;s the shape that lines up with how the engine actually pays its bills. Every cost mechanism in this post — buffer-pool density, write amplification, index bloat, row-lock width — is the engine reporting the same thing in different dialects: the shape you&amp;rsquo;re asking it to hold isn&amp;rsquo;t the shape it was optimized for. The SELECT-*-and-done dream is the developer&amp;rsquo;s cost model, not the database&amp;rsquo;s.&lt;/p&gt;&#xA;&lt;p&gt;God tables aren&amp;rsquo;t designed; they&amp;rsquo;re the limit of a sequence of rational local decisions where the global cost is invisible at each step. The column count of a mature production table is usually a decent proxy for how long the team has been making the cheap choice, which is most teams most of the time — and that is not by itself a failure. The failure is that the cost goes uncounted. A 6KB row is a write-amplification multiplier on every UPDATE, a buffer-pool multiplier on every read, and an index-size multiplier on every secondary index. None of those costs are on the PR that adds a column; all of them are on the dashboard that shows p99 drifting up quarter after quarter.&lt;/p&gt;&#xA;&lt;p&gt;The lever is to count the cost at the system level when the table hits a certain width — pick a threshold, sixty columns, a hundred, whatever fits — and make the next column addition a conversation about whether this concern belongs here, not a line in a migration. The answer is often still yes, but it shouldn&amp;rsquo;t be the default answer. When it&amp;rsquo;s no, the split is far cheaper at column sixty than at column one-eighty; the table doesn&amp;rsquo;t care, but every caller of the table does, and the rewrite&amp;rsquo;s blast radius scales with how long the drift went uncorrected.&lt;/p&gt;&#xA;</description>
        </item><item>
            <title>Non-SARGable Predicates: How a Function in WHERE Kills Your Index</title>
            <link>https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/</link>
            <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post Non-SARGable Predicates: How a Function in WHERE Kills Your Index&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;A predicate is SARGable — Search ARGument able — if the database can use an index to evaluate it. Wrapping a column in a function makes the predicate non-SARGable: the engine has to compute the function on every row before it can filter, which means a full table scan no matter what indexes exist. The fix isn&amp;rsquo;t always to rewrite the predicate — sometimes the column&amp;rsquo;s type or collation is wrong and the code is masking it — but every non-SARGable predicate on a hot path is a performance bug waiting for the table to grow.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;Here are two queries that return the exact same rows:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;7&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Version A&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;events&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;YEAR&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;2025&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Version B&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;events&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;2025-01-01&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;2026-01-01&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;On a 10,000-row &lt;code&gt;events&lt;/code&gt; table, both run in under a millisecond and nobody notices the difference. On a 200-million-row &lt;code&gt;events&lt;/code&gt; table with an index on &lt;code&gt;created_at&lt;/code&gt;, version A does a sequential scan and takes 45 seconds; version B does an index range scan and takes 12 milliseconds. Neither query is wrong. They don&amp;rsquo;t even disagree about the answer. One just does the same work in a way the planner can&amp;rsquo;t optimize.&lt;/p&gt;&#xA;&lt;p&gt;The obvious fix is &amp;ldquo;rewrite every function-wrapped predicate as a range.&amp;rdquo; That works for the date-extraction case and a few others. For &lt;code&gt;WHERE LOWER(email) = &#39;alice@example.com&#39;&lt;/code&gt;, the rewrite needs to know whether the column&amp;rsquo;s collation is case-insensitive — and if it isn&amp;rsquo;t, there&amp;rsquo;s no direct equivalent, only a functional index or a schema change. The fix depends on &lt;em&gt;why&lt;/em&gt; the function is there, and &amp;ldquo;why&amp;rdquo; usually points back at something in the schema that&amp;rsquo;s pretending to be something it isn&amp;rsquo;t.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-sargable-means-in-practice&#34;&gt;What SARGable means in practice&#xA;&lt;/h2&gt;&lt;p&gt;An index on &lt;code&gt;created_at&lt;/code&gt; is a sorted structure: the engine can jump to any date range in O(log n) time by walking the B-tree. For the planner to use that index on a predicate, the predicate has to be expressible as &amp;ldquo;the column is in this range&amp;rdquo; — a direct comparison between the column and a constant or parameter.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;created_at &amp;gt;= &#39;2025-01-01&#39;&lt;/code&gt; meets that contract. The planner translates it to &amp;ldquo;walk the index to the first entry ≥ 2025-01-01, read forward from there.&amp;rdquo; That&amp;rsquo;s a range scan.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;YEAR(created_at) = 2025&lt;/code&gt; doesn&amp;rsquo;t meet the contract. The value being compared isn&amp;rsquo;t &lt;code&gt;created_at&lt;/code&gt;; it&amp;rsquo;s the output of &lt;code&gt;YEAR()&lt;/code&gt; applied to &lt;code&gt;created_at&lt;/code&gt;. The index on &lt;code&gt;created_at&lt;/code&gt; doesn&amp;rsquo;t know the output of &lt;code&gt;YEAR()&lt;/code&gt; for any row without computing it. So the planner falls back to evaluating the function on every row — a sequential scan — and only then filtering.&lt;/p&gt;&#xA;&lt;p&gt;Common forms of the same mistake:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Non-SARGable: function on column → full scan&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;LOWER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;alice@example.com&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;DATE&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;2025-01-15&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CAST&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;INT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;100&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;CONCAT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;first_name&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39; &amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;last_name&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;Alice Smith&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- SARGable equivalents&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;alice@example.com&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;              &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;-- if collation is case-insensitive&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;2025-01-15&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;2025-01-16&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;price&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;100&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;                              &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;-- fix the type at the schema level&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;first_name&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;Alice&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;last_name&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;Smith&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;Three of the four non-SARGable forms have clean rewrites. The first one — &lt;code&gt;LOWER(email)&lt;/code&gt; — depends on collation, which is where a lot of real-world cases live.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-collation-case&#34;&gt;The collation case&#xA;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;WHERE LOWER(email) = &#39;alice@example.com&#39;&lt;/code&gt; is almost always a tell that the &lt;code&gt;email&lt;/code&gt; column has a case-sensitive collation and the application is hiding it at query time. Two real fixes, one cosmetic fix:&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Fix the column.&lt;/strong&gt; If the data should be matched case-insensitively, give the column a case-insensitive collation. In PostgreSQL that&amp;rsquo;s &lt;code&gt;CITEXT&lt;/code&gt; or a &lt;code&gt;COLLATE &amp;quot;und-x-icu&amp;quot;&lt;/code&gt; with the ICU provider; in MySQL it&amp;rsquo;s a &lt;code&gt;_ci&lt;/code&gt; collation (which is usually the default anyway). Once the column&amp;rsquo;s collation handles the case folding, &lt;code&gt;WHERE email = &#39;alice@example.com&#39;&lt;/code&gt; is SARGable and fast. This is the right fix when case-insensitivity is a property of the data.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Add a functional (expression) index.&lt;/strong&gt; If you can&amp;rsquo;t change the column&amp;rsquo;s collation — there&amp;rsquo;s a case-sensitive comparison elsewhere in the schema that depends on the current behavior — index the expression itself:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- PostgreSQL: functional index&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;INDEX&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;idx_users_email_lower&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;LOWER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;));&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Now WHERE LOWER(email) = &amp;#39;...&amp;#39; uses the index&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- MySQL 8.0+: expression index (requires the same constant-folding fix)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;ALTER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ADD&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;INDEX&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;idx_email_lower&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;((&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;LOWER&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)));&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;This works, with caveats. The index&amp;rsquo;s storage and write cost is real. The predicate has to match the indexed expression exactly — &lt;code&gt;LOWER(email)&lt;/code&gt; is indexed, but &lt;code&gt;UPPER(email)&lt;/code&gt; isn&amp;rsquo;t, and the planner won&amp;rsquo;t translate between them. Every non-SARGable expression you want fast needs its own index.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Cosmetic fix: case-fold at write time.&lt;/strong&gt; Store the email as already-lowercased. &lt;code&gt;WHERE email = &#39;alice@example.com&#39;&lt;/code&gt; is now SARGable directly, no expression index needed. This usually requires application changes — whoever&amp;rsquo;s writing has to remember to case-fold — which is why the functional index is more popular even though it&amp;rsquo;s heavier. &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/&#34; &gt;Where business logic lives&lt;/a&gt; covers the general shape of this decision; case-folding at the database with a generated column (&lt;code&gt;GENERATED ALWAYS AS (LOWER(email)) STORED&lt;/code&gt;) is often the cleanest answer when the application can&amp;rsquo;t be trusted to normalize consistently.&lt;/p&gt;&#xA;&lt;h2 id=&#34;implicit-type-conversions-are-the-subtler-version&#34;&gt;Implicit type conversions are the subtler version&#xA;&lt;/h2&gt;&lt;p&gt;The function isn&amp;rsquo;t always in the query. Sometimes the planner is adding one:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- account_id is VARCHAR, literal is numeric&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;account_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;12345&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;MySQL will silently cast every &lt;code&gt;account_id&lt;/code&gt; value to a number for comparison — a per-row function call that kills index usage just as effectively as an explicit &lt;code&gt;CAST()&lt;/code&gt;. PostgreSQL is stricter and usually errors, but can still do implicit conversions between compatible types that undermine indexes.&lt;/p&gt;&#xA;&lt;p&gt;The fix is matching types in both directions: the column type should be what the column is (a numeric ID should be &lt;code&gt;BIGINT&lt;/code&gt;, not &lt;code&gt;VARCHAR&lt;/code&gt;), and the query should write the literal in the column&amp;rsquo;s type (&lt;code&gt;WHERE account_id = &#39;12345&#39;&lt;/code&gt; if the column is genuinely a string). Either fix works; matching the column type to the data&amp;rsquo;s real shape is usually the durable answer.&lt;/p&gt;&#xA;&lt;p&gt;This is also where &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/&#34; &gt;mixed PK strategies&lt;/a&gt; show up — joining a BIGINT &lt;code&gt;id&lt;/code&gt; to a UUID &lt;code&gt;id&lt;/code&gt; doesn&amp;rsquo;t just return wrong results; on MySQL it coerces one side to a string, which is the same implicit-function problem dressed up as a join.&lt;/p&gt;&#xA;&lt;h2 id=&#34;when-non-sargable-is-acceptable&#34;&gt;When non-SARGable is acceptable&#xA;&lt;/h2&gt;&lt;p&gt;Not every non-SARGable predicate is a bug. Three cases where it&amp;rsquo;s fine:&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Small tables.&lt;/strong&gt; A 5,000-row lookup table with a function-wrapped predicate scans in microseconds. The planner isn&amp;rsquo;t going to use an index on that size anyway. &lt;code&gt;WHERE UPPER(code) = &#39;NY&#39;&lt;/code&gt; on a 50-row &lt;code&gt;states&lt;/code&gt; table is not worth worrying about.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;One-off analytical queries.&lt;/strong&gt; A one-time data extract that scans a large table is going to scan it regardless. If the query will never run again, the function call isn&amp;rsquo;t the bottleneck — the table size is — and adding a functional index to optimize one query isn&amp;rsquo;t worth the write cost on every future insert.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;When the function genuinely can&amp;rsquo;t be avoided.&lt;/strong&gt; Some predicates legitimately need to compute. &lt;code&gt;WHERE haversine_distance(lat, lng, user_lat, user_lng) &amp;lt; 10&lt;/code&gt; on a geospatial query can&amp;rsquo;t be rewritten as a simple range; you need a spatial index (PostGIS, MySQL spatial extensions) to make it SARGable in the geometric sense. The fix is a different kind of index, not a rewrite.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&lt;/h2&gt;&lt;p&gt;Non-SARGable predicates are easy to write, and they come from somewhere — almost always a schema decision that&amp;rsquo;s being papered over at query time. &lt;code&gt;LOWER(email)&lt;/code&gt; hides a collation mismatch. &lt;code&gt;CAST(price AS INT)&lt;/code&gt; hides a type that should have been &lt;code&gt;NUMERIC&lt;/code&gt; from the start. &lt;code&gt;DATE(created_at)&lt;/code&gt; hides the fact that the query is answering a date-range question but written in a way that reads more naturally as an equality. Every one of these is a query-level workaround for a schema-level issue, and every one of them costs an index when the table grows large enough to care.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is the diagnostic. If the plan shows a sequential scan on a predicate that should hit an index, the predicate is almost certainly non-SARGable — look at what&amp;rsquo;s wrapping the column. Fix the schema if you can, add a functional index if you can&amp;rsquo;t, and treat non-SARGable predicates on hot paths as latent performance bugs, not style issues.&lt;/p&gt;&#xA;</description>
        </item><item>
            <title>Random UUIDs as Primary Keys: The B-Tree Penalty</title>
            <link>https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/</link>
            <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post Random UUIDs as Primary Keys: The B-Tree Penalty&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;UUIDv4 primary keys are globally unique and coordination-free, and the cost is paid every time you write a row: random B-tree positions, page splits, secondary indexes bloated with 16- or 36-byte key copies, and a working set that stops fitting in the buffer pool once the table is large enough. UUIDv7 fixes the insert-locality problem (time-ordered, sortable) without changing storage size; the full fix is picking v7, storing as &lt;code&gt;BINARY(16)&lt;/code&gt; or native &lt;code&gt;uuid&lt;/code&gt;, and keeping UUIDs at the API boundary rather than internal to every join.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;A table configured like this on day one looks unremarkable:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;CHAR&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;36&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;-- UUIDv4, generated by the application&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;...&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;Inserts are fast, reads are fast, the ORM is happy. At 100,000 rows, it&amp;rsquo;s still fine. At 10 million, the nightly ingest job gets noticeably slower. At 200 million, inserts take 50 ms each instead of 2 ms, the buffer pool is constantly churning, and the secondary indexes are three to four times the size they&amp;rsquo;d be with a &lt;code&gt;BIGINT&lt;/code&gt; primary key. Nothing about the schema changed. The table just got large enough for a design decision to start charging rent.&lt;/p&gt;&#xA;&lt;p&gt;The obvious fix is &amp;ldquo;use BIGINT auto-increment.&amp;rdquo; That&amp;rsquo;s the right answer in a lot of cases and the wrong one in others — it reintroduces coordination requirements, leaks row counts through URL-exposed IDs, and doesn&amp;rsquo;t work for schemas that need to be generated offline or across shards. UUIDs exist because those constraints are real. The sharper question is: what exactly is UUIDv4 costing you at scale, and which of those costs have cheaper alternatives?&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-random-keys-do-to-a-b-tree&#34;&gt;What random keys do to a B-tree&#xA;&lt;/h2&gt;&lt;p&gt;B-tree indexes are sorted structures. When the primary key is an auto-incrementing integer, every new row goes to the end — the rightmost leaf page is the only one that gets written to, and the rest of the index stays in cache undisturbed. Inserts are sequential and cheap.&lt;/p&gt;&#xA;&lt;p&gt;UUIDv4 is random by design. Every new row lands at a random position in the B-tree. Instead of appending to one page, the engine has to:&lt;/p&gt;&#xA;&lt;ol&gt;&#xA;&lt;li&gt;Find the right page somewhere in the middle of the tree.&lt;/li&gt;&#xA;&lt;li&gt;Load it into the buffer pool if it isn&amp;rsquo;t already (on a large table, it usually isn&amp;rsquo;t).&lt;/li&gt;&#xA;&lt;li&gt;Split it if it&amp;rsquo;s full.&lt;/li&gt;&#xA;&lt;li&gt;Write both halves back.&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;p&gt;On a table with hundreds of millions of rows, the index doesn&amp;rsquo;t fit in memory, so most inserts trigger a random disk read before they can do anything else. The write amplification is real and measurable — factor of 5 to 10× versus sequential inserts isn&amp;rsquo;t unusual.&lt;/p&gt;&#xA;&lt;p&gt;The damage doesn&amp;rsquo;t stop at the primary-key index. In InnoDB (MySQL), every secondary index includes a copy of the primary key at its leaves. A 36-byte &lt;code&gt;CHAR(36)&lt;/code&gt; UUID embedded in every secondary index entry means larger indexes, more pages, more I/O — compared to an 8-byte &lt;code&gt;BIGINT&lt;/code&gt;. Secondary indexes on a UUID-keyed table are routinely 3–4× the size of the same indexes on a &lt;code&gt;BIGINT&lt;/code&gt;-keyed table. Every lookup through a secondary index reads more pages to cover the same rows.&lt;/p&gt;&#xA;&lt;p&gt;PostgreSQL handles storage differently — its heap means the primary key is just another index, so the physical table isn&amp;rsquo;t ordered by it. But the primary-key index still suffers the same random-insertion pathology, and the write amplification from random page loads still applies.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Page splits compound over time.&lt;/strong&gt; When a new UUID lands in a full page, InnoDB splits the page in two, each roughly half full. Over millions of inserts, the index develops internal fragmentation — pages allocated but only partially used. The index is physically larger than it needs to be, and scans read more pages for the same row count. &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; (MySQL) or &lt;code&gt;REINDEX&lt;/code&gt; (PostgreSQL) can repack the index, but on a busy table it&amp;rsquo;s a maintenance window you have to schedule.&lt;/p&gt;&#xA;&lt;h2 id=&#34;uuidv7-the-insert-locality-fix&#34;&gt;UUIDv7: the insert-locality fix&#xA;&lt;/h2&gt;&lt;p&gt;UUIDv7 is the version most new code should reach for when UUIDs are the right answer. It encodes a Unix millisecond timestamp into the high 48 bits, with random bits filling the rest. Two practical consequences:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Sortable.&lt;/strong&gt; Sequential generation means new IDs land at the end of the B-tree, not scattered across it. Insert locality is close to a &lt;code&gt;BIGINT&lt;/code&gt;&amp;rsquo;s. The pathological page-split behaviour of v4 goes away.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Time-parseable.&lt;/strong&gt; The creation time is embedded in the ID, recoverable from the primary key alone — useful for log correlation, rough time-range filtering, and debugging without reaching for &lt;code&gt;created_at&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;14&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- UUIDv7: time-ordered, so inserts are roughly sequential&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- PostgreSQL 18 ships a built-in uuidv7() function&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;UUID&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DEFAULT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;uuidv7&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;...&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Recover creation time from the ID — no created_at column needed&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;uuid_extract_timestamp&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;uuid_extract_timestamp&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)::&lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;date&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_date&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;ORDER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;BY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DESC&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;-- v7 sorts chronologically, newest first&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;LIMIT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;10&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;&lt;code&gt;uuid_extract_timestamp()&lt;/code&gt; has existed in PostgreSQL since 17 but only returned a value for UUIDv1. PG 18 extended it to support v7 alongside the new &lt;code&gt;uuidv7()&lt;/code&gt; generator. One caveat: calling it in a &lt;code&gt;WHERE&lt;/code&gt; clause (&lt;code&gt;WHERE uuid_extract_timestamp(id) &amp;gt;= &#39;2026-04-01&#39;&lt;/code&gt;) is non-SARGable and forces a scan — see &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/&#34; &gt;Non-SARGable Predicates&lt;/a&gt;. For indexed time-range filtering, keep a &lt;code&gt;created_at&lt;/code&gt; column as the query target, or compare against a boundary UUID generated at the target timestamp.&lt;/p&gt;&#xA;&lt;p&gt;MySQL 8 doesn&amp;rsquo;t ship a v7 generator or a timestamp extractor, so application-side generation is the norm there — libraries exist in every major language, and most modern ORMs default to v7 if you ask for UUIDs. Extraction is manual: for &lt;code&gt;BINARY(16)&lt;/code&gt; storage (the recommended form), the first 6 bytes hold the millisecond timestamp.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;7&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- MySQL: manually parse v7&amp;#39;s timestamp prefix (BINARY(16) storage)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;FROM_UNIXTIME&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;CONV&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;HEX&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;SUBSTRING&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;6&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;16&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;10&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;/&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1000&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;DATE&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;FROM_UNIXTIME&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;CONV&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;HEX&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;SUBSTRING&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;6&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;16&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;10&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;/&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1000&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_date&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;ORDER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;BY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DESC&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;-- v7 sorts chronologically&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;LIMIT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;10&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;For &lt;code&gt;CHAR(36)&lt;/code&gt; storage, the extraction strips hyphens first: &lt;code&gt;CONCAT(SUBSTRING(id, 1, 8), SUBSTRING(id, 10, 4))&lt;/code&gt; gives the 12 hex characters of the timestamp prefix. If your v1 UUIDs were stored with &lt;code&gt;UUID_TO_BIN(id, 1)&lt;/code&gt; (the swap flag that reorders bytes for v1 index locality), the byte layout differs and the substring offsets change. Most v7-generating libraries skip the swap because v7 is already time-ordered without it — check what yours does before trusting the extraction.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;What v7 doesn&amp;rsquo;t change.&lt;/strong&gt; It&amp;rsquo;s still 16 bytes on disk, and still 36 if you stored it as &lt;code&gt;CHAR(36)&lt;/code&gt; — the insert-locality win doesn&amp;rsquo;t come with a storage discount, so the overhead versus a &lt;code&gt;BIGINT&lt;/code&gt; is the same as v4. The readable creation timestamp is usually a feature and occasionally a problem: in systems where row-creation time is sensitive (order IDs revealing traffic patterns to competitors, user IDs exposing signup timing), it&amp;rsquo;s the one property v4 had that v7 gives up.&lt;/p&gt;&#xA;&lt;div class=&#34;note-box&#34;&gt;&#xA;    &lt;strong&gt;CHAR(36) is the silent tax&lt;/strong&gt;&#xA;    &lt;div&gt;The worst-case UUID storage — &lt;code&gt;CHAR(36)&lt;/code&gt; — is what most ORM-generated schemas default to, because it&amp;rsquo;s the portable representation. &lt;code&gt;BINARY(16)&lt;/code&gt; in MySQL or the native &lt;code&gt;uuid&lt;/code&gt; type in PostgreSQL cuts storage by more than half and keeps comparisons on fixed-width integers instead of strings. Pick the narrow form on day one; retrofitting it later is a full-table rewrite that touches every secondary index.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;uuid-to-integer-mapping-keep-uuids-at-the-edge&#34;&gt;UUID-to-integer mapping: keep UUIDs at the edge&#xA;&lt;/h2&gt;&lt;p&gt;The other workable fix is structural: expose UUIDs externally, use integers internally. A single lookup table maps the external UUID to an internal &lt;code&gt;BIGINT&lt;/code&gt;, and every other table in the database uses the &lt;code&gt;BIGINT&lt;/code&gt; as its foreign key. The UUID lookup happens once — at the API boundary — and everything downstream is fast, compact, 8-byte integer joins.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;14&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;15&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;16&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;external_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BINARY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;16&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;UNIQUE&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;c1&#34;&gt;-- the UUID the outside world sees&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;...&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Every other table references the BIGINT, not the UUID&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;user_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;...&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- API request comes in with a UUID — one indexed lookup to resolve it&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;external_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;UUID_TO_BIN&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;a1b2c3d4-...&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- From here on, everything uses the BIGINT&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;The UUID column has a unique index, so the lookup is a single index seek — sub-millisecond regardless of table size. The rest of the schema gets 8-byte keys everywhere: smaller indexes, faster joins, no page splits, no secondary-index bloat. The external-facing API still uses UUIDs, so you don&amp;rsquo;t leak sequence information or row counts.&lt;/p&gt;&#xA;&lt;p&gt;The trade-off is an extra layer of indirection. Every inbound request resolves the UUID before anything else; in practice this is negligible (one indexed lookup), but it means the schema has two identity systems to maintain. For long-lived OLTP applications where every join on every table pays the UUID cost, this structure is often worth the extra lookup.&lt;/p&gt;&#xA;&lt;h2 id=&#34;when-random-uuids-are-actually-fine&#34;&gt;When random UUIDs are actually fine&#xA;&lt;/h2&gt;&lt;p&gt;Not every schema needs to bend. Three cases where UUIDv4 as a primary key is a defensible choice:&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Small tables that stay small.&lt;/strong&gt; A configuration table, a lookup table, a feature-flag table. At 50,000 rows the page-split pathology doesn&amp;rsquo;t show up, secondary indexes are tiny, and the convenience of client-generated IDs outweighs any cost.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Write rates low enough that random I/O doesn&amp;rsquo;t matter.&lt;/strong&gt; An admin tool recording 50 events per minute doesn&amp;rsquo;t care about write amplification. The index fits in cache, every page is warm, page splits happen rarely enough that fragmentation stays manageable. &amp;ldquo;Doesn&amp;rsquo;t survive scale&amp;rdquo; is only a problem at scale.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Information-leak concerns that outweigh performance.&lt;/strong&gt; If hiding creation-order is a hard requirement (competitive, privacy, or security), v7&amp;rsquo;s embedded timestamp is a non-starter and v4 is the only UUID version that meets the requirement. Pay the write-amplification cost and use the UUID-to-integer mapping to contain the damage.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&lt;/h2&gt;&lt;p&gt;UUIDv4 is a tool that solved a coordination problem — distributed ID generation without central authority — and accidentally became the default for everything, including the cases where coordination wasn&amp;rsquo;t a problem and the cost of random writes is non-trivial. &amp;ldquo;Pick a UUID for your PK&amp;rdquo; is a decision most schemas make without ever being explicit about what they&amp;rsquo;re trading.&lt;/p&gt;&#xA;&lt;p&gt;The decision matrix is short. Do you need globally unique, coordination-free IDs? If no, use &lt;code&gt;BIGINT&lt;/code&gt;. If yes, use UUIDv7 and store it as &lt;code&gt;BINARY(16)&lt;/code&gt; or native &lt;code&gt;uuid&lt;/code&gt; — never &lt;code&gt;CHAR(36)&lt;/code&gt;. If v7&amp;rsquo;s embedded timestamp is a problem, use v4 but keep it at the API boundary and use integers inside the schema. Each of those decisions costs almost nothing on day one and saves a lot of rework at 100 million rows — which is the point where &amp;ldquo;UUIDs as primary keys&amp;rdquo; stops being a default and starts being a choice with real consequences.&lt;/p&gt;&#xA;</description>
        </item></channel>
</rss>
