<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Explain, Measured (PostgreSQL)</title>
<link>https://explainmeasured.com/</link>
<atom:link href="https://explainmeasured.com/feed-postgresql.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>Four PostgreSQL arguments that came out even</title>
<link>https://explainmeasured.com/posts/arguments-that-came-out-even/</link>
<guid>https://explainmeasured.com/posts/arguments-that-came-out-even/</guid>
<pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate>
<description>&lt;p&gt;Some advice gets repeated in code review because it sounds like it should matter. I picked four I have seen argued about and measured them. I expected at least two clear winners. I got three ties and one result that went the opposite way from the advice.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;
&lt;p&gt;PostgreSQL 18.6, default settings. 2020 13-inch MacBook Pro, Intel i5-8257U, 8 GB RAM. A 2,000,000-row table &lt;code&gt;u&lt;/code&gt; with an id, an email, a status and a timestamp. Times are execution times from &lt;code&gt;EXPLAIN (ANALYZE, TIMING OFF)&lt;/code&gt;, warm cache, first run discarded, median of the rest.&lt;/p&gt;
&lt;h2 id=&quot;in-any-or-a-values-join-for-1-000-ids&quot;&gt;IN, ANY or a VALUES join for 1,000 ids&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;median&lt;/th&gt;&lt;th&gt;runs&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;buffers&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;id IN (1999, 3998, ...)&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;26 ms&lt;/td&gt;&lt;td&gt;15 to 54 ms&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;3,001&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;id = ANY('{...}'::bigint[])&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;19 ms&lt;/td&gt;&lt;td&gt;15 to 20 ms&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;3,001&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;JOIN (VALUES ...) v(i) ON id = i&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;20 ms&lt;/td&gt;&lt;td&gt;16 to 21 ms&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;3,001&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;All three read exactly the same pages. The medians differ, but one IN run took 54 ms and another 15 ms, so I would not read anything into the gap. Use whichever form your driver makes easy. ANY has one practical advantage when the array is passed as a single parameter: the statement text stays the same whatever the number of ids, which is kinder to prepared statements and to &lt;code&gt;pg_stat_statements&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;text-smallint-or-enum-for-a-status-column&quot;&gt;text, smallint or enum for a status column&lt;/h2&gt;
&lt;p&gt;Three values, two million rows, each stored three ways in its own one-column table.&lt;/p&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;table&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;index&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;text&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;72.6 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;13.9 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;smallint&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;73.0 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;13.9 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;enum&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;72.7 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;13.9 MB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Every row has a 24-byte header and is padded to 8 bytes, so a 2-byte smallint and a 7-byte &lt;code&gt;'active'&lt;/code&gt; end up the same size. The indexes are identical because B-tree deduplication stores each of the three values once with a list of row pointers. In a wider table the column order can change the padding, but the type of a short status column is not where the space goes. Choose it for what it lets you check.&lt;/p&gt;
&lt;h2 id=&quot;columns-that-are-usually-empty&quot;&gt;Columns that are usually empty&lt;/h2&gt;
&lt;p&gt;One million rows with an id and ten more integer columns. In one table the ten columns are all NULL, in the other all filled.&lt;/p&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;table&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;per row&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;ten NULL columns&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;44 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;36 bytes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;ten filled columns&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;77 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;68 bytes&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;A NULL takes no space in the row. It costs one bit in the row's null bitmap. Optional columns that are mostly empty are close to free, which is worth knowing before splitting a table to save space.&lt;/p&gt;
&lt;h2 id=&quot;count-distinct-against-group-by&quot;&gt;count(DISTINCT) against GROUP BY&lt;/h2&gt;
&lt;p&gt;The advice I have seen most often: &lt;code&gt;count(DISTINCT x)&lt;/code&gt; is slow, rewrite it as a count over a GROUP BY subquery. On two million unique emails:&lt;/p&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;median&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;count(DISTINCT email)&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;1.95 s&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;count(*) FROM (SELECT email FROM u GROUP BY email)&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;3.12 s&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The rewrite was slower. I am not claiming it always is. Which one wins depends on the plan the GROUP BY gets and on &lt;code&gt;work_mem&lt;/code&gt;, and both change between versions and settings. That is the point: this is advice to check with EXPLAIN on your own data, not to apply.&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;IN lists with tens of thousands of values.&lt;/li&gt;&lt;li&gt;Status columns inside a wider row, where alignment can differ.&lt;/li&gt;&lt;li&gt;&lt;code&gt;count(DISTINCT)&lt;/code&gt; with more &lt;code&gt;work_mem&lt;/code&gt;, or on columns with few distinct values.&lt;/li&gt;&lt;li&gt;Other hardware. Compare the ratios, not the seconds.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;reproduce-it&quot;&gt;Reproduce it&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;./run2.py&quot;&gt;run2.py&lt;/a&gt; and &lt;a href=&quot;./results2.json&quot;&gt;results2.json&lt;/a&gt;. Sections 1, 9, 10 and 12 of the script.&lt;/p&gt;</description>
</item>
<item>
<title>What deleting rows does to a PostgreSQL table</title>
<link>https://explainmeasured.com/posts/deleting-rows/</link>
<guid>https://explainmeasured.com/posts/deleting-rows/</guid>
<pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate>
<description>&lt;p&gt;A table that shrinks in rows does not shrink on disk. Everyone who has run PostgreSQL for a while knows this in general. I wanted the actual numbers, because &amp;quot;VACUUM reclaims space&amp;quot; means something narrower than it sounds.&lt;/p&gt;
&lt;p&gt;Short answer: I deleted every second row of a 2,000,000-row table. The table was 137 MB before, 137 MB after the DELETE, 137 MB after VACUUM, and 68 MB after VACUUM FULL.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;
&lt;p&gt;PostgreSQL 18.6, default settings, autovacuum on. 2020 13-inch MacBook Pro, Intel i5-8257U, 8 GB RAM. Sizes are &lt;code&gt;pg_table_size&lt;/code&gt;. Times are wall clock for the statement, median of three runs, each on a freshly built table.&lt;/p&gt;
&lt;h2 id=&quot;deleting-half-the-rows&quot;&gt;Deleting half the rows&lt;/h2&gt;
&lt;div class=&quot;table&quot;&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;step&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;table size&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;2M rows, after VACUUM ANALYZE&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;137 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;after &lt;code&gt;DELETE FROM b1 WHERE id % 2 = 0&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;137 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;after &lt;code&gt;VACUUM b1&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;137 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;after &lt;code&gt;VACUUM FULL b1&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;68 MB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The DELETE marks rows dead. VACUUM makes their space reusable, so the next million inserts will fill those gaps instead of growing the file. It does not hand the space back to the operating system, with one exception: empty pages at the very end of the file get cut off. I deleted every second row, so every page kept half its rows and nothing at the end was empty.&lt;/p&gt;
&lt;p&gt;VACUUM FULL writes a new copy of the table and drops the old one. That is why it gets the space back, and also why it takes a lock that blocks reads and writes while it runs.&lt;/p&gt;
&lt;h2 id=&quot;delete-or-truncate-to-empty-a-table&quot;&gt;DELETE or TRUNCATE to empty a table&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;time&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;WAL written&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DELETE FROM d1&lt;/code&gt; (1M rows)&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;3.5 s&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;125 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;TRUNCATE d1&lt;/code&gt;&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;0.05 s&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;44 kB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;DELETE touches and logs every row. TRUNCATE gives the table a new, empty file. The difference grows with the table, and the WAL column matters if you have replicas or WAL archiving: 125 MB of WAL is 125 MB that has to travel.&lt;/p&gt;
&lt;p&gt;TRUNCATE is not a drop-in replacement. It takes an ACCESS EXCLUSIVE lock, it does not fire ON DELETE triggers, and it refuses to run on a table that other tables reference with foreign keys unless you truncate those too or add CASCADE.&lt;/p&gt;
&lt;h2 id=&quot;updates-and-fillfactor&quot;&gt;Updates and fillfactor&lt;/h2&gt;
&lt;p&gt;An update in PostgreSQL writes a new version of the row. If the new version fits on the same page and no indexed column changed, it can skip the indexes entirely. That is a HOT update.&lt;/p&gt;
&lt;p&gt;I updated a non-indexed column on all 200,000 rows of a table, three times, once with the default fillfactor of 100 and once with 90, which leaves 10% of each page empty at load time.&lt;/p&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;HOT updates&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;table after&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;index on &lt;code&gt;k&lt;/code&gt; after&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;fillfactor 100&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;64 of 600,000&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;61 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;10.4 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;fillfactor 90&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;127,353 of 600,000&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;47 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;9.0 MB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;With full pages almost nothing could stay on its page. With 10% free, about a fifth of the updates did, and the table ended 24% smaller.&lt;/p&gt;
&lt;h2 id=&quot;what-surprised-me&quot;&gt;What surprised me&lt;/h2&gt;
&lt;p&gt;The HOT numbers were lower than I expected for fillfactor 90. Three full passes over every row use up the free space faster than a real workload that updates a few rows at a time, and autovacuum probably had no chance to clean up between passes. On a table where updates are spread out, the share of HOT updates should be higher. I did not measure that here.&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;Autovacuum doing its work between updates.&lt;/li&gt;&lt;li&gt;&lt;code&gt;pg_repack&lt;/code&gt; or the new REPACK command in PostgreSQL 19 as alternatives to VACUUM FULL.&lt;/li&gt;&lt;li&gt;Tables with TOAST data, where some of the space lives in a separate table.&lt;/li&gt;&lt;li&gt;Other hardware. Compare the ratios, not the seconds.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;reproduce-it&quot;&gt;Reproduce it&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;./run2.py&quot;&gt;run2.py&lt;/a&gt; and &lt;a href=&quot;./results2.json&quot;&gt;results2.json&lt;/a&gt;. Sections 5, 6 and 11 of the script.&lt;/p&gt;</description>
</item>
<item>
<title>Five PostgreSQL queries that did more work than I asked for</title>
<link>https://explainmeasured.com/posts/queries-doing-extra-work/</link>
<guid>https://explainmeasured.com/posts/queries-doing-extra-work/</guid>
<pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate>
<description>&lt;p&gt;Most slow queries I have looked at are not doing anything clever. They are doing extra work that nobody asked for, because one word in the SQL told PostgreSQL to.&lt;/p&gt;
&lt;p&gt;I took five of those words and measured each one against the version without it, on the same table. The biggest gap was UNION: 13.6 seconds, against 0.68 seconds for UNION ALL on the same two halves of the table.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;
&lt;p&gt;One table, &lt;code&gt;u&lt;/code&gt;, with 2,000,000 rows: a &lt;code&gt;bigint&lt;/code&gt; identity key, an email, a status, a &lt;code&gt;timestamptz&lt;/code&gt; and an md5 note.&lt;/p&gt;
&lt;p&gt;PostgreSQL 18.6, default settings (&lt;code&gt;work_mem&lt;/code&gt; 4MB, &lt;code&gt;shared_buffers&lt;/code&gt; 128MB, up to two parallel workers per query). Machine: 2020 13-inch MacBook Pro, Intel i5-8257U, 8 GB RAM. Every time below is the execution time from &lt;code&gt;EXPLAIN (ANALYZE, TIMING OFF)&lt;/code&gt;, warm cache, first run thrown away, median of the rest.&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;without&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;with&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;UNION ALL / UNION&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;0.68 s&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;13.6 s&lt;/td&gt;&lt;td&gt;two halves that cannot overlap&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CTE inlined / MATERIALIZED&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;0.24 ms&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;1,973 ms&lt;/td&gt;&lt;td&gt;filter on the primary key&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;TABLESAMPLE / ORDER BY random()&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;0.3 ms&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;1,313 ms&lt;/td&gt;&lt;td&gt;one random row&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;one column / SELECT *&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;32.8 ms&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;64.8 ms&lt;/td&gt;&lt;td&gt;about 100,000 rows by date range&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;expression index / no index&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;0.5 ms&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;623 ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;WHERE lower(email) = ...&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;h2 id=&quot;union&quot;&gt;UNION&lt;/h2&gt;
&lt;p&gt;I split the table at &lt;code&gt;id = 1,000,000&lt;/code&gt; and put the halves back together. With &lt;code&gt;UNION ALL&lt;/code&gt; that is just reading the rows. With &lt;code&gt;UNION&lt;/code&gt;, PostgreSQL has to make sure no email appears twice, so it deduplicates two million values before it can count them. It has no way to know the halves cannot overlap. I do, so I should have written ALL.&lt;/p&gt;
&lt;h2 id=&quot;cte-with-materialized&quot;&gt;CTE with MATERIALIZED&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;WITH x AS (SELECT * FROM u) SELECT * FROM x WHERE id = 777777&lt;/code&gt; took 0.24 ms. PostgreSQL 12 and later fold a CTE like this into the outer query, so the filter reaches the primary key index.&lt;/p&gt;
&lt;p&gt;Add &lt;code&gt;MATERIALIZED&lt;/code&gt; and it builds the whole CTE first, then filters it: 1,973 ms and every page of the table. The keyword is there for the cases where you want that fence. Here I did not.&lt;/p&gt;
&lt;h2 id=&quot;one-random-row&quot;&gt;One random row&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ORDER BY random() LIMIT 1&lt;/code&gt; reads all two million rows and sorts them by a random key to return one: 1,313 ms.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;TABLESAMPLE SYSTEM (0.01) LIMIT 1&lt;/code&gt; read one page and took 0.3 ms. It is not the same thing, though. SYSTEM picks whole pages, so rows that sit together come back together, and a sample this small can come back empty. For a quick look at some data it is fine. For anything that has to be fair, it is not.&lt;/p&gt;
&lt;h2 id=&quot;select&quot;&gt;SELECT *&lt;/h2&gt;
&lt;p&gt;About 100,000 rows by a &lt;code&gt;created_at&lt;/code&gt; range, ordered by the same column, with an index on it. Asking for only &lt;code&gt;created_at&lt;/code&gt; let PostgreSQL answer from the index alone: 279 buffers, 32.8 ms. &lt;code&gt;SELECT *&lt;/code&gt; had to visit the table for every row: 1,718 buffers, 64.8 ms.&lt;/p&gt;
&lt;p&gt;EXPLAIN ANALYZE does not send rows to the client, so none of this is network time. It is the table visits.&lt;/p&gt;
&lt;h2 id=&quot;a-function-in-where&quot;&gt;A function in WHERE&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;WHERE lower(email) = 'user1234567@example.com'&lt;/code&gt; with no matching index read the whole table in parallel: 623 ms. &lt;code&gt;CREATE INDEX ON u (lower(email))&lt;/code&gt; brought it to 0.5 ms and 4 buffers. An index on plain &lt;code&gt;email&lt;/code&gt; would not have helped; the index has to be on the same expression the query uses. This one was 81 MB.&lt;/p&gt;
&lt;h2 id=&quot;what-surprised-me&quot;&gt;What surprised me&lt;/h2&gt;
&lt;p&gt;How small the fixes are. Four of the five are one keyword or one column list. The fifth is one index.&lt;/p&gt;
&lt;p&gt;And how forgiving the slow versions look in development. On a table of a few thousand rows every one of these finishes in a few milliseconds, and the difference only appears when the table grows.&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;Larger &lt;code&gt;work_mem&lt;/code&gt;. UNION would have deduplicated in memory with more of it, and the gap would be smaller.&lt;/li&gt;&lt;li&gt;Tables bigger than RAM. Everything here was in the OS cache.&lt;/li&gt;&lt;li&gt;Fair random sampling. &lt;code&gt;TABLESAMPLE BERNOULLI&lt;/code&gt; samples rows rather than pages, which means reading every page; I did not time it.&lt;/li&gt;&lt;li&gt;Other hardware. Compare the ratios, not the seconds.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;reproduce-it&quot;&gt;Reproduce it&lt;/h2&gt;
&lt;p&gt;The script and the raw numbers: &lt;a href=&quot;./run2.py&quot;&gt;run2.py&lt;/a&gt;, &lt;a href=&quot;./results2.json&quot;&gt;results2.json&lt;/a&gt;. The same script also measures the next two posts in this series.&lt;/p&gt;</description>
</item>
<item>
<title>UUIDs stored as text: what it costs in PostgreSQL 18</title>
<link>https://explainmeasured.com/posts/uuid-as-text/</link>
<guid>https://explainmeasured.com/posts/uuid-as-text/</guid>
<pubDate>Tue, 06 Oct 2026 00:00:00 GMT</pubDate>
<description>&lt;p&gt;Last week I compared UUIDv4 and UUIDv7 as primary keys. There is a third case I left out: the key is a UUID, but the column is &lt;code&gt;text&lt;/code&gt;. Some ORMs and JSON-first codebases end up there because a string is the easy path.&lt;/p&gt;
&lt;p&gt;So I kept the same test and changed only the column type. 5 million rows, UUIDv7 and UUIDv4, each stored as &lt;code&gt;uuid&lt;/code&gt; and as &lt;code&gt;text&lt;/code&gt;. Short answer: the text index was 88% bigger, one level deeper, and every random lookup read one more page.&lt;/p&gt;
&lt;h2 id=&quot;setup&quot;&gt;Setup&lt;/h2&gt;
&lt;p&gt;Same as last time. 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; PRIMARY KEY, 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;uuid DEFAULT uuidv7()&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;code&gt;text DEFAULT uuidv7()::text&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;code&gt;uuid DEFAULT gen_random_uuid()&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;code&gt;text DEFAULT gen_random_uuid()::text&lt;/code&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;5,000,000 rows in 50 committed batches of 100,000, then &lt;code&gt;VACUUM ANALYZE&lt;/code&gt;, a checkpoint, and two read queries. Three runs each on a fresh table, medians below. Machine: 2020 13-inch MacBook Pro, Intel i5-8257U, 8 GB RAM, SSD. PostgreSQL 18.6 with OpenSSL (I checked this time), default settings, database collation &lt;code&gt;C&lt;/code&gt;.&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;v7 uuid&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;v7 text&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;v4 uuid&lt;/th&gt;&lt;th class=&quot;num&quot;&gt;v4 text&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;26.6 s&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;32.5 s&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;46.7 s&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;79.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;857 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;1,104 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;1,005 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;1,545 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;403 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;482 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;403 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;482 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;150 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;282 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;193 MB&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;364 MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Index tree level&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;2&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;3&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;2&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;3&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,626&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;45,033&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;34,623&lt;/td&gt;&lt;td class=&quot;num&quot;&gt;44,999&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;. Tree level is &lt;code&gt;tree_level&lt;/code&gt; from &lt;code&gt;pgstatindex&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 &lt;code&gt;uuid&lt;/code&gt; is 16 bytes. The same value as &lt;code&gt;text&lt;/code&gt; is 36 characters plus a length byte: 37 bytes. Every index entry carries the key, so the text index holds more than twice the key data. It came out 88% bigger with v7 and 89% bigger with v4.&lt;/p&gt;
&lt;p&gt;At 5 million rows, that was enough to add a level to the B-tree. The uuid index had a tree level of 2, the text index 3. A lookup walks from the root to a leaf, one page per level, so every lookup reads one more page. 10,000 lookups read about 10,400 more buffers, a little over one extra page each.&lt;/p&gt;
&lt;p&gt;The table grew too, by 79 MB, because the heap stores the longer key as well.&lt;/p&gt;
&lt;h2 id=&quot;what-surprised-me&quot;&gt;What surprised me&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Text made the bad choice worse, not just bigger.&lt;/strong&gt; With v7 keys, text cost 6 seconds on insert. With v4 keys, it cost 33. My reading: random inserts into a bigger index split more pages and write more full-page images to WAL. The numbers point that way: 540 MB more WAL for v4, against 247 MB more for v7.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The cost I measured was size, not comparisons.&lt;/strong&gt; I expected text comparisons to be the cost. Here the collation is &lt;code&gt;C&lt;/code&gt;, which compares bytes, so comparisons are cheap. The cost I could measure was size: more pages, one more level.&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;Other collations. This database uses &lt;code&gt;C&lt;/code&gt;. Most servers use something like &lt;code&gt;en_US.UTF-8&lt;/code&gt;, where text comparison goes through the locale and costs more. I could not reproduce that faithfully on macOS, so the text numbers here are a best case.&lt;/li&gt;&lt;li&gt;Joins on text keys, and foreign keys referencing them. Every child table would carry the 37-byte value too.&lt;/li&gt;&lt;li&gt;Tables larger than RAM. The whole data set fit in the OS page cache.&lt;/li&gt;&lt;li&gt;The smaller sizes where both indexes have the same tree level. The extra page per lookup only appears once the text index is big enough to grow a level.&lt;/li&gt;&lt;li&gt;Other hardware. Compare the ratios, not the seconds.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;If your keys are already &lt;code&gt;text&lt;/code&gt;, changing the column to &lt;code&gt;uuid&lt;/code&gt; is an &lt;code&gt;ALTER TABLE ... TYPE uuid USING id::uuid&lt;/code&gt;, which rewrites the table and its indexes and takes a strong lock while it runs. I did not measure that here.&lt;/p&gt;
&lt;h2 id=&quot;reproduce-it&quot;&gt;Reproduce it&lt;/h2&gt;
&lt;p&gt;The script and every raw number: &lt;a href=&quot;./run.py&quot;&gt;run.py&lt;/a&gt;, &lt;a href=&quot;./results.json&quot;&gt;results.json&lt;/a&gt;. It expects a PostgreSQL 18 server and the &lt;code&gt;pgstattuple&lt;/code&gt; extension.&lt;/p&gt;</description>
</item>
<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>
