<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Explain, Measured</title>
<link>https://explainmeasured.com/</link>
<atom:link href="https://explainmeasured.com/feed.xml" rel="self" type="application/rss+xml"/>
<description>PostgreSQL performance, measured. Every number comes from a run you can repeat.</description>
<language>en</language>
<item>
<title>UUIDv4 or UUIDv7 for a primary key? 5 million rows on PostgreSQL 18</title>
<link>https://explainmeasured.com/posts/uuidv4-vs-uuidv7/</link>
<guid>https://explainmeasured.com/posts/uuidv4-vs-uuidv7/</guid>
<pubDate>Tue, 29 Sep 2026 00:00:00 GMT</pubDate>
<description>&lt;p&gt;PostgreSQL 18 ships &lt;code&gt;uuidv7()&lt;/code&gt; in core. No extension, no application code. So the old question comes back: if you want UUID keys, does the version matter?&lt;/p&gt;
&lt;p&gt;I loaded 5 million rows three ways and measured. Short answer: UUIDv7 inserted in 27 seconds, UUIDv4 in 46. The v4 index came out 29% bigger. Random lookups by key cost the same.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;
&lt;p&gt;One table per run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;lang-sql&quot;&gt;CREATE TABLE t (id &amp;lt;key&amp;gt;, payload text NOT NULL);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key was one of:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;code&gt;bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;code&gt;uuid DEFAULT gen_random_uuid() PRIMARY KEY&lt;/code&gt; (v4)&lt;/li&gt;&lt;li&gt;&lt;code&gt;uuid DEFAULT uuidv7() PRIMARY KEY&lt;/code&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Each run inserted 5,000,000 rows in 50 committed batches of 100,000. &lt;code&gt;payload&lt;/code&gt; is an md5 string, 32 characters. After loading I ran &lt;code&gt;VACUUM ANALYZE&lt;/code&gt; and a checkpoint, then two read queries. Every configuration ran three times on a fresh table. The numbers below are medians. The spread between runs was small, except insert time, which moved by up to 3 seconds.&lt;/p&gt;
&lt;p&gt;Machine: a 2020 13-inch MacBook Pro, Intel i5-8257U, 8 GB RAM, internal SSD. PostgreSQL 18.6 built from source, default settings (&lt;code&gt;shared_buffers&lt;/code&gt; 128 MB, &lt;code&gt;max_wal_size&lt;/code&gt; 1 GB, &lt;code&gt;full_page_writes&lt;/code&gt; on).&lt;/p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;
&lt;div class=&quot;table&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;bigint&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;UUIDv7&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;UUIDv4&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Insert 5M rows&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;23.2 s&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;27.0 s&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;46.3 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;WAL written&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;790 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;857 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;1,004 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Table size&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;365 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;403 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;403 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Primary key index&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;107 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;150 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;193 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index leaf density&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;90%&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;90%&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;70%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;10,000 random lookups (buffers)&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;34,228&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;34,650&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;34,632&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Last 1,000 rows by key (buffers)&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;16&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;17&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;1,008&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Buffers are shared hit plus read from the top plan node of &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;where-the-difference-comes-from&quot;&gt;Where the difference comes from&lt;/h2&gt;
&lt;p&gt;A UUIDv7 starts with a millisecond timestamp, so each new key is larger than the last one. New index entries land on the rightmost leaf page, the same way a &lt;code&gt;bigint&lt;/code&gt; sequence does. When that page fills, PostgreSQL starts a new one and leaves the old one full.&lt;/p&gt;
&lt;p&gt;A UUIDv4 is random. Each insert lands somewhere in the middle of the index. Pages split, and after a split both halves are partly empty. That is the 70% leaf density, and it is most of why the v4 index is 43 MB bigger than the v7 one at the same row count.&lt;/p&gt;
&lt;p&gt;The WAL number follows from the same thing. With random inserts, more distinct pages get touched between checkpoints, and the first change to each page after a checkpoint writes the whole 8 kB page to WAL. The v4 run wrote 147 MB more WAL than v7 for the same data.&lt;/p&gt;
&lt;h2 id=&quot;what-surprised-me&quot;&gt;What surprised me&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;My first run was wrong.&lt;/strong&gt; I built PostgreSQL without &lt;code&gt;--with-openssl&lt;/code&gt;. Without it, PostgreSQL falls back to a slower way of getting random bytes, and generating the UUIDs alone took 68 seconds for 5 million values. That made v4 and v7 look equally slow, and both looked five times slower than &lt;code&gt;bigint&lt;/code&gt;. With OpenSSL, generation takes about 5 seconds for either version. Packaged builds (Homebrew, Debian, the Docker image) use OpenSSL, so the second set of numbers is the one that matches what you run. If you build your own, check.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Random lookups did not care.&lt;/strong&gt; Fetching 10,000 random existing keys touched about 34,000 buffers for all three key types. Each lookup walks the index down to one leaf and then reads one heap page. The key's order does not change that walk much. I expected the bigger v4 index to cost more here, and at this size it didn't.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;Newest rows&amp;quot; is where v4 hurts.&lt;/strong&gt; &lt;code&gt;ORDER BY id DESC LIMIT 1000&lt;/code&gt; read 17 buffers with v7 and 1,008 with v4. With v4 those are not the newest rows at all. They are 1,000 rows scattered across the table, one heap page each. If your application lists recent records, a v4 key needs a separate timestamp column and index to do it well. A v7 key already sorts by creation time.&lt;/p&gt;
&lt;h2 id=&quot;what-i-did-not-test&quot;&gt;What I did not test&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;Concurrent inserts from many sessions. This was one session.&lt;/li&gt;&lt;li&gt;Tables larger than RAM. The whole data set here fit in the OS page cache, so reads from &amp;quot;disk&amp;quot; were mostly memory. The gap on insert time may grow when the index no longer fits.&lt;/li&gt;&lt;li&gt;Updates, deletes, and foreign keys pointing at the key.&lt;/li&gt;&lt;li&gt;Replication. More WAL means more to ship, but I did not measure a replica.&lt;/li&gt;&lt;li&gt;Other hardware. The absolute seconds are laptop seconds. Compare the ratios, not the times.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;One thing to know before switching: a UUIDv7 contains the time it was created. Anyone who can see the key can read that timestamp. If your keys are public and creation time is sensitive, that matters more than any number above.&lt;/p&gt;
&lt;h2 id=&quot;reproduce-it&quot;&gt;Reproduce it&lt;/h2&gt;
&lt;p&gt;The script creates the tables, runs the inserts, and writes every raw number to &lt;code&gt;results.json&lt;/code&gt;: &lt;a href=&quot;./run.py&quot;&gt;run.py&lt;/a&gt;. It expects a PostgreSQL 18 server and the &lt;code&gt;pgstattuple&lt;/code&gt; extension.&lt;/p&gt;</description>
</item>
</channel>
</rss>
