{"article":{"slug":"how-tablesample-picks-rows","title":"How TABLESAMPLE picks rows","subtitle":null,"summary":"Radim Marek explains how PostgreSQL's TABLESAMPLE really samples: SYSTEM picks whole pages so rows come in bundles, BERNOULLI with REPEATABLE drifts after an UPDATE, and hashing a key gives a stable sample, plus when each approach fits.","content_type":"tutorial","language":"en","canonical_url":"https://boringsql.com/posts/tablesample/","author":{"name":"Radim Marek","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"boringSQL","url":"https://boringsql.com/","listing_slug":null,"listing":null},"topics":[{"name":"PostgreSQL","slug":"postgresql","url":"https://listedarticles.com/topics/postgresql"},{"name":"Databases","slug":"databases","url":"https://listedarticles.com/topics/databases"},{"name":"SQL","slug":"sql","url":"https://listedarticles.com/topics/sql"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2718,"reading_minutes":12,"published_at":"2026-10-11T00:00:00.000Z","added_at":"2026-10-25T11:10:50.620Z","updated_at":"2026-10-25T11:10:50.620Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/how-tablesample-picks-rows","markdown_url":"https://listedarticles.com/articles/how-tablesample-picks-rows.md","example":false,"citation":"Radim Marek, boringSQL. \"How TABLESAMPLE picks rows.\" 11 Oct 2026. https://boringsql.com/posts/tablesample/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://boringsql.com/posts/tablesample/"},"body_markdown":"Most exploratory questions against a large table only need a rough answer, but without an index on `status`, even \"roughly how many shipped orders\" costs a full scan. On the two-million-row `orders` table, Postgres has to read every single 8 kB page, all 18,085 of them (141 MB), just to count matching rows.\n\n```\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT count(*) FROM orders WHERE status = 'shipped';\n```\n\n```\n Aggregate\n   Buffers: shared hit=18085\n   ->  Seq Scan on orders (actual rows=500000.00 loops=1)\n         Filter: (status = 'shipped'::text)\n         Rows Removed by Filter: 1500000\n Execution Time: 56.188 ms\n```\n\n`shared hit=18085` is one buffer access per page, every page found already in memory ([Reading Buffer statistics in EXPLAIN output](https://boringsql.com/posts/explain-buffers/) goes through the counters). At 141 MB of heap size that's nothing. Take a 1 TB table with 134 million pages: a scan reads all of them from disk, every time, however small the answer is.\n\nPostgres reads tables larger than a quarter of `shared_buffers` through a small ring buffer. See more in [introduction to buffers](https://boringsql.com/posts/introduction-to-buffers/).\n\nAt least Postgres avoids flushing its own cache by reading large tables through a small ring buffer. Nevertheless the disk still perform full read. On a primary server handling production traffic is shared with everything else. When all you need is exploratory confirmation \"roughly how many\" or \"are there any representative samples\" reading the whole terabyte is too expensive.\n\nSQL Standard comes with `TABLESAMPLE` to help with that. It goes in the `FROM` clause right after the table name, and the rest of the query runs against the subset of pages it picks.\n\n```\nSELECT\n  id, status, created_at, amount\nFROM orders TABLESAMPLE SYSTEM (1) REPEATABLE (7)\nLIMIT 5;\n```\n\n```\n id  |  status   |       created_at       | amount\n-----+-----------+------------------------+--------\n 429 | delivered | 2024-01-01 00:07:09+00 |  38.46\n 430 | delivered | 2024-01-01 00:07:10+00 | 150.72\n 431 | delivered | 2024-01-01 00:07:11+00 | 295.59\n 432 | delivered | 2024-01-01 00:07:12+00 | 488.23\n 433 | delivered | 2024-01-01 00:07:13+00 | 238.84\n```\n\nThe value `(1)` is a percentage, and the size of the sample isn't fixed: five different seeds returned between 18,107 and 20,416 rows. If you need an exact number of rows, `tsm_system_rows` in contrib adds a `SYSTEM_ROWS (n)` method. The rows come back in physical order, which is why these five are neighbours, ids 429 to 433. And whatever you compute is computed over the sample, so totals have to be scaled back up by hand, `count(*)` and `sum()` times 100, while averages, ratios and extremes are read as they come.\n\nHere's the shipped-orders count again, scaled up, with its buffer numbers:\n\n```\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT count(*) * 100 FROM orders TABLESAMPLE SYSTEM (1)\nWHERE status = 'shipped';\n```\n\n```\n Aggregate\n   Buffers: shared hit=177\n   ->  Sample Scan on orders (actual rows=4920.00 loops=1)\n         Sampling: system ('1'::real)\n         Filter: (status = 'shipped'::text)\n Execution Time: 0.829 ms\n```\n\n177 pages instead of 18,085, and the estimate comes out at 492,000 against the real 500,000. For counts like this one it holds up. The rows are inserted one per second in `created_at` order, though, so the oldest row sits at the very start of the table. Ask the same 1% sample for it:\n\n```\nSELECT min(created_at) FROM orders TABLESAMPLE SYSTEM (1);\n```\n\nSix runs:\n\n```\n 2024-01-01 02:51:13+00\n 2024-01-01 01:36:19+00\n 2024-01-01 01:27:24+00\n 2024-01-01 04:41:47+00\n 2024-01-01 02:51:13+00\n 2024-01-01 00:44:36+00\n```\n\nThe real answer is `00:00:01`. The sample is off by anything from 44 minutes to almost five hours, and runs one and five agree to the second, which a sample of random rows would almost never do. Everything odd about `TABLESAMPLE` comes from one fact: it doesn't pick rows, it picks addresses.\n\n## Setup\n\nEverything in this article was captured on PostgreSQL 18.6 using the `postgres:18` image.`shared_buffers = 1GB` and a warm cache, so the timings are for comparing methods against each other, not for predicting yours.\n\n```\nCREATE EXTENSION IF NOT EXISTS pageinspect;\n\nCREATE TABLE orders (\n    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    customer_id bigint        NOT NULL,\n    status      text          NOT NULL,\n    created_at  timestamptz   NOT NULL,\n    amount      numeric(10,2) NOT NULL\n);\n\nSELECT setseed(0.42);\nINSERT INTO orders (customer_id, status, created_at, amount)\nSELECT (random() * 50000)::bigint + 1,\n       CASE WHEN i <= 1300000 THEN 'delivered'\n            WHEN i <= 1800000 THEN 'shipped'\n            WHEN i <= 1900000 THEN 'cancelled'\n            ELSE 'pending' END,\n       timestamptz '2024-01-01 00:00:00+00' + i * interval '1 second',\n       round((random() * 500)::numeric, 2)\nFROM generate_series(1, 2000000) AS i;\nVACUUM ANALYZE orders;\n\n-- the same rows in random physical order\nCREATE TABLE orders_shuffled AS SELECT * FROM orders ORDER BY random();\nVACUUM ANALYZE orders_shuffled;\n\nCREATE TABLE customers AS SELECT id FROM generate_series(1, 50001) AS id;\nALTER TABLE customers ADD PRIMARY KEY (id);\n\n-- a small copy with room on every page, for the pageinspect step\nCREATE TABLE h (LIKE orders INCLUDING ALL) WITH (fillfactor = 70);\nINSERT INTO h OVERRIDING SYSTEM VALUE\nSELECT * FROM orders WHERE id <= 1000 ORDER BY id;\n```\n\n## Every row has an address\n\nThe pages are numbered from 0, and right after its header each page keeps an array of line pointers, one per row, numbered from 1 ([Inside PostgreSQL's 8KB Page](https://boringsql.com/posts/inside-the-8kb-page/) takes one apart byte by byte). Page number and line pointer together are the row's `ctid`:\n\n```\nSELECT ctid, id, created_at FROM orders ORDER BY id LIMIT 3;\n```\n\n```\n ctid  | id |       created_at\n-------+----+------------------------\n (0,1) |  1 | 2024-01-01 00:00:01+00\n (0,2) |  2 | 2024-01-01 00:00:02+00\n (0,3) |  3 | 2024-01-01 00:00:03+00\n```\n\nBoth built-in sampling methods hash part of that address together with a seed and keep whatever hashes under a 1% cutoff. They differ only in which part. `SYSTEM`, in `src/backend/access/tablesample/system.c`, hashes the page number (trimmed to the lines that matter):\n\n```\nhashinput[0] = nextblock;          /* page */\nhashinput[1] = sampler->seed;\nhash = DatumGetUInt32(hash_any((const unsigned char *) hashinput,\n                               (int) sizeof(hashinput)));\nif (hash < sampler->cutoff)        /* keep the whole page */\n```\n\n`BERNOULLI`, in `bernoulli.c`, hashes the page and the line pointer number:\n\n```\nhashinput[0] = blockno;            /* page */\nhashinput[1] = tupoffset;          /* line pointer */\nhashinput[2] = sampler->seed;\n```\n\nSo `SYSTEM` flips one coin per page and takes every row on a winning page. `BERNOULLI` flips one coin per line pointer, which means it has to open every page to see how many line pointers there are. That's the whole performance story: on this table `SYSTEM` answers in 1.2 ms and `BERNOULLI` in 10.4 ms.\n\n## Pages come as bundles\n\nA page here holds about 107 rows, and since the rows went in by time, one page is 107 consecutive seconds of orders. Group a `SYSTEM` sample by the page half of `ctid` and the bundles are right there:\n\n```\nSELECT (ctid::text::point)[0]::int AS page, count(*) AS rows,\n       min(created_at)::time AS first, max(created_at)::time AS last\nFROM orders TABLESAMPLE SYSTEM (1) REPEATABLE (7)\nGROUP BY 1 ORDER BY 1 LIMIT 4;\n```\n\n```\n page | rows |  first   |   last\n------+------+----------+----------\n    4 |  107 | 00:07:09 | 00:08:55\n   38 |  107 | 01:07:47 | 01:09:33\n   57 |  107 | 01:41:40 | 01:43:26\n  265 |  107 | 07:52:36 | 07:54:22\n```\n\nThe sample isn't 20,000 scattered seconds. It's about 200 short strips of the timeline, and `min()` returns the start of whichever strip the hash kept first. Runs one and five above kept the same first page.\n\nShuffle the table and the problem disappears. `orders_shuffled` holds the same rows written in `ORDER BY random()` order, so every page is a handful of seconds from all over the three weeks, and the same query lands within a few minutes of the truth every time:\n\n```\n 2024-01-01 00:00:06+00\n 2024-01-01 00:01:20+00\n 2024-01-01 00:01:43+00\n 2024-01-01 00:01:24+00\n 2024-01-01 00:02:50+00\n 2024-01-01 00:05:07+00\n```\n\n`BERNOULLI` on the original, time-ordered table does just as well, because a coin per line pointer never bundles anything.\n\nNobody samples for a minimum, but the bundles hurt averages the same way. Across 200 seeds, `avg(created_at)` from a `SYSTEM` sample wanders **ten times** as far as the same average from `BERNOULLI`. The reason is plain once you think in pages: 107 rows from one page tell you about as much about the timeline as one row from that page would, so a `SYSTEM` sample of 20,000 rows has roughly the precision of a 200-row one. A column that has nothing to do with where rows sit, like a random `amount`, averages identically under both methods. Time columns, ever-increasing ids, and anything loaded in batches are the columns where it matters.\n\n## REPEATABLE remembers addresses\n\n`REPEATABLE (42)` fixes the seed. Same seed, same hash, same addresses. Whether those addresses still hold the same rows depends on what happened to the table in between, and the address model predicts every line of this table. Each run captures a sample with seed 42, changes the table, captures again, and counts how many rows from the first sample are still in it:\n\n| after | SYSTEM kept | BERNOULLI kept |\n| --- | --- | --- |\n| append 200k rows | 17,595 of 17,595 | 19,800 of 19,800 |\n| delete half the rows | 8,795 of 8,795 | 10,003 of 10,003 |\n| `VACUUM` | 8,795 of 8,795 | 10,003 of 10,003 |\n| `UPDATE` every row | 8,740 of 8,795 | **109 of 10,003** |\n| `VACUUM FULL` | 113 of 8,795 | 113 of 10,003 |\n| `CLUSTER` on a reversed index | 0 of 8,795 | 95 of 10,003 |\n\nAppends create new addresses and deletes empty old ones; no surviving row moves, so nothing changes. `VACUUM` marks dead line pointers unused and compacts the tuple data inside each page, but it never renumbers a line pointer that still points at a live row ([VACUUM at the Page Level](https://boringsql.com/posts/vacuum-at-the-page-level/) walks through each phase). `VACUUM FULL` and `CLUSTER` build a fresh copy of the table, every row gets a new address, and both samples are effectively redrawn.\n\n`UPDATE` is the interesting line. Postgres never overwrites a row in place: an update writes a new version, and when the page has room and no indexed column changed, it puts that version on the same page without touching any index. That's a HOT update ([HOT Updates in Postgres](https://boringsql.com/posts/hot-updates/)), and here 99% of them were. Same page means `SYSTEM` doesn't notice. But the new version gets a new line pointer, and `pageinspect` shows it for one row of `h`:\n\n```\nSELECT ctid FROM h WHERE id = 3;   -- (0,3)\nUPDATE h SET amount = amount + 1 WHERE id = 3;\nSELECT ctid FROM h WHERE id = 3;   -- (0,76)\n\nSELECT lp, lp_flags, lp_off, t_ctid\nFROM heap_page_items(get_raw_page('h', 0)) WHERE lp IN (3, 76);\n```\n\n```\n lp | lp_flags | lp_off | t_ctid\n----+----------+--------+--------\n  3 |        2 |     76 |\n 76 |        1 |   2792 | (0,76)\n```\n\nLine pointer 3 is now `LP_REDIRECT` (`lp_flags = 2`), a signpost that says \"moved to 76\" so index entries pointing at `(0,3)` still find the row. `BERNOULLI` doesn't read signposts. It hashes line pointer 76, which is a fresh 1% coin flip, so almost every updated row drops out of the sample and a different 1% drops in.\n\n## Hashing the key instead\n\nWhen people pin a seed, they usually want a development or test subset that stays the same from one day to the next. An address can't promise that, because updates and rewrites move rows. A primary key doesn't move, so hash the key:\n\n```\nSELECT * FROM orders WHERE (hashint8extended(id, 42) & 1023) < 10;\n```\n\n`hashint8extended` hashes a bigint with a seed; keeping 10 of 1,024 buckets returns 0.98% of the rows. Through the same changes it kept every surviving row at every step, **9,916 of 9,916** after the delete, `UPDATE`, `VACUUM FULL` and `CLUSTER` included. It has no bundles either: its 200-seed spread on `avg(created_at)` matches `BERNOULLI`.\n\nA key can also be a foreign key. Sample `customers` on `hashint8extended(id, 42)` and `orders` on `hashint8extended(customer_id, 42)`, and you get 484 customers with all 19,432 of their orders. The same two tables sampled with `SYSTEM (1) REPEATABLE (42)` leave 17,517 of the 17,595 sampled orders pointing at a customer who isn't in the sample.\n\nThe price is a full scan, since the hash has to be computed for every row: 44.4 ms serially, 21.5 ms with two parallel workers. For a subset you pull often, an expression index on `(hashint8extended(id, 42) & 1023)` (14 MB here) turns it into a bitmap scan at 8.5 ms, with the seed fixed in the index definition.\n\n## The join doesn't shrink\n\nEverything so far read one table. Join the orders to their customers and count:\n\n```\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT count(*) FROM orders o JOIN customers c ON c.id = o.customer_id;\n```\n\nThe full join answers in 84.4 ms from a parallel hash join with two workers: 18,751 buffer hits, all but 666 of them on `orders`. Sample the orders side:\n\n```\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT count(*) * 100\nFROM orders o TABLESAMPLE SYSTEM (1) REPEATABLE (7)\nJOIN customers c ON c.id = o.customer_id;\n```\n\n```\n Aggregate\n   Buffers: shared hit=424\n   ->  Hash Join (actual rows=22316.00 loops=1)\n         Hash Cond: (o.customer_id = c.id)\n         ->  Sample Scan on orders o (actual rows=22316.00 loops=1)\n               Sampling: system ('1'::real)\n               Buffers: shared hit=202\n         ->  Hash\n               Buckets: 65536  Batches: 1  Memory Usage: 2270kB\n               Buffers: shared hit=222\n               ->  Seq Scan on customers c (actual rows=50001.00 loops=1)\n                     Buffers: shared hit=222\n Execution Time: 25.590 ms\n```\n\nThe sample scan did its part, 202 pages against the full 18,085. The hash over `customers` still reads all 222 of its pages, because `TABLESAMPLE` only shrinks the table it sits on, and building the hash is 11.5 ms of the 25.6. The query gets 3.3× faster, not 90×. Most of what remains is the table the sample never touched. The workers are gone too: a sample scan runs in the leader process, so the plan falls back to serial however big the table is.\n\nForcing the other join shape moves the cost rather than removing it:\n\n```\nSET enable_hashjoin = off;\nSET enable_mergejoin = off;\n```\n\nThe same query then runs 22,316 index-only probes against `customers_pkey`, 44,636 buffer hits against the sample's own 202, and finishes in 21.6 ms. Every sampled row pays full price for its customer lookup.\n\nA predicate on the far side works normally: adding `WHERE c.id <= 500` builds the hash over 500 rows and answers in 1.5 ms. Some questions break outright, though. Every one of the 50,001 customers has orders, but the 1% draw sees:\n\n```\nSELECT count(DISTINCT customer_id) FROM orders TABLESAMPLE SYSTEM (1) REPEATABLE (7);\n```\n\n```\n count\n-------\n 18034\n```\n\nMultiplying by 100 can't bring back a customer whose orders all landed on losing pages. A sample scales sums and counts of what it kept; anything you have to see to count comes out low.\n\nSampling the join output directly isn't an option: `TABLESAMPLE` attaches to one table in the `FROM` clause, and putting it on a join or a subquery is a syntax error. Sampling both sides is as close as you can get, and it's close to useless:\n\n```\nSELECT count(*)\nFROM orders o TABLESAMPLE SYSTEM (1) REPEATABLE (7)\nJOIN customers c TABLESAMPLE SYSTEM (1) REPEATABLE (7) ON c.id = o.customer_id;\n```\n\n```\n count\n-------\n   288\n```\n\nThe real join has two million rows. Two independent address draws keep 288 of them, because a sampled order finds its customer only when both page hashes kept the matching pair. When a sampled pair of tables has to stay joinable, hashing the foreign key on both sides is the way to do it.\n\n## Which to reach for\n\n`SYSTEM` for a quick number over a column that has nothing to do with insert order. `BERNOULLI` when it might, and reading the whole table once is affordable. A hashed key when the subset has to be the same rows tomorrow, or has to keep its foreign keys intact. None of them when you need a filtered subset: the sample is chosen before your `WHERE` runs, so the filter only trims a draw that was already made.\n\n`ANALYZE` builds `pg_stats` from a sample too, with its own block sampler and a seed you don't get to set, which is why the numbers move between runs even when the data hasn't.\n","body_html":"<p>Most exploratory questions against a large table only need a rough answer, but without an index on <code>status</code>, even &quot;roughly how many shipped orders&quot; costs a full scan. On the two-million-row <code>orders</code> table, Postgres has to read every single 8 kB page, all 18,085 of them (141 MB), just to count matching rows.</p>\n<pre><code>EXPLAIN (ANALYZE, BUFFERS)\nSELECT count(*) FROM orders WHERE status = &#39;shipped&#39;;</code></pre>\n<pre><code> Aggregate\n   Buffers: shared hit=18085\n   -&gt;  Seq Scan on orders (actual rows=500000.00 loops=1)\n         Filter: (status = &#39;shipped&#39;::text)\n         Rows Removed by Filter: 1500000\n Execution Time: 56.188 ms</code></pre>\n<p><code>shared hit=18085</code> is one buffer access per page, every page found already in memory (<a href=\"https://boringsql.com/posts/explain-buffers/\" rel=\"nofollow ugc noopener\">Reading Buffer statistics in EXPLAIN output</a> goes through the counters). At 141 MB of heap size that&#39;s nothing. Take a 1 TB table with 134 million pages: a scan reads all of them from disk, every time, however small the answer is.</p>\n<p>Postgres reads tables larger than a quarter of <code>shared_buffers</code> through a small ring buffer. See more in <a href=\"https://boringsql.com/posts/introduction-to-buffers/\" rel=\"nofollow ugc noopener\">introduction to buffers</a>.</p>\n<p>At least Postgres avoids flushing its own cache by reading large tables through a small ring buffer. Nevertheless the disk still perform full read. On a primary server handling production traffic is shared with everything else. When all you need is exploratory confirmation &quot;roughly how many&quot; or &quot;are there any representative samples&quot; reading the whole terabyte is too expensive.</p>\n<p>SQL Standard comes with <code>TABLESAMPLE</code> to help with that. It goes in the <code>FROM</code> clause right after the table name, and the rest of the query runs against the subset of pages it picks.</p>\n<pre><code>SELECT\n  id, status, created_at, amount\nFROM orders TABLESAMPLE SYSTEM (1) REPEATABLE (7)\nLIMIT 5;</code></pre>\n<pre><code> id  |  status   |       created_at       | amount\n-----+-----------+------------------------+--------\n 429 | delivered | 2024-01-01 00:07:09+00 |  38.46\n 430 | delivered | 2024-01-01 00:07:10+00 | 150.72\n 431 | delivered | 2024-01-01 00:07:11+00 | 295.59\n 432 | delivered | 2024-01-01 00:07:12+00 | 488.23\n 433 | delivered | 2024-01-01 00:07:13+00 | 238.84</code></pre>\n<p>The value <code>(1)</code> is a percentage, and the size of the sample isn&#39;t fixed: five different seeds returned between 18,107 and 20,416 rows. If you need an exact number of rows, <code>tsm_system_rows</code> in contrib adds a <code>SYSTEM_ROWS (n)</code> method. The rows come back in physical order, which is why these five are neighbours, ids 429 to 433. And whatever you compute is computed over the sample, so totals have to be scaled back up by hand, <code>count(*)</code> and <code>sum()</code> times 100, while averages, ratios and extremes are read as they come.</p>\n<p>Here&#39;s the shipped-orders count again, scaled up, with its buffer numbers:</p>\n<pre><code>EXPLAIN (ANALYZE, BUFFERS)\nSELECT count(*) * 100 FROM orders TABLESAMPLE SYSTEM (1)\nWHERE status = &#39;shipped&#39;;</code></pre>\n<pre><code> Aggregate\n   Buffers: shared hit=177\n   -&gt;  Sample Scan on orders (actual rows=4920.00 loops=1)\n         Sampling: system (&#39;1&#39;::real)\n         Filter: (status = &#39;shipped&#39;::text)\n Execution Time: 0.829 ms</code></pre>\n<p>177 pages instead of 18,085, and the estimate comes out at 492,000 against the real 500,000. For counts like this one it holds up. The rows are inserted one per second in <code>created_at</code> order, though, so the oldest row sits at the very start of the table. Ask the same 1% sample for it:</p>\n<pre><code>SELECT min(created_at) FROM orders TABLESAMPLE SYSTEM (1);</code></pre>\n<p>Six runs:</p>\n<pre><code> 2024-01-01 02:51:13+00\n 2024-01-01 01:36:19+00\n 2024-01-01 01:27:24+00\n 2024-01-01 04:41:47+00\n 2024-01-01 02:51:13+00\n 2024-01-01 00:44:36+00</code></pre>\n<p>The real answer is <code>00:00:01</code>. The sample is off by anything from 44 minutes to almost five hours, and runs one and five agree to the second, which a sample of random rows would almost never do. Everything odd about <code>TABLESAMPLE</code> comes from one fact: it doesn&#39;t pick rows, it picks addresses.</p>\n<h2 id=\"setup\">Setup</h2>\n<p>Everything in this article was captured on PostgreSQL 18.6 using the <code>postgres:18</code> image.<code>shared_buffers = 1GB</code> and a warm cache, so the timings are for comparing methods against each other, not for predicting yours.</p>\n<pre><code>CREATE EXTENSION IF NOT EXISTS pageinspect;\n\nCREATE TABLE orders (\n    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    customer_id bigint        NOT NULL,\n    status      text          NOT NULL,\n    created_at  timestamptz   NOT NULL,\n    amount      numeric(10,2) NOT NULL\n);\n\nSELECT setseed(0.42);\nINSERT INTO orders (customer_id, status, created_at, amount)\nSELECT (random() * 50000)::bigint + 1,\n       CASE WHEN i &lt;= 1300000 THEN &#39;delivered&#39;\n            WHEN i &lt;= 1800000 THEN &#39;shipped&#39;\n            WHEN i &lt;= 1900000 THEN &#39;cancelled&#39;\n            ELSE &#39;pending&#39; END,\n       timestamptz &#39;2024-01-01 00:00:00+00&#39; + i * interval &#39;1 second&#39;,\n       round((random() * 500)::numeric, 2)\nFROM generate_series(1, 2000000) AS i;\nVACUUM ANALYZE orders;\n\n-- the same rows in random physical order\nCREATE TABLE orders_shuffled AS SELECT * FROM orders ORDER BY random();\nVACUUM ANALYZE orders_shuffled;\n\nCREATE TABLE customers AS SELECT id FROM generate_series(1, 50001) AS id;\nALTER TABLE customers ADD PRIMARY KEY (id);\n\n-- a small copy with room on every page, for the pageinspect step\nCREATE TABLE h (LIKE orders INCLUDING ALL) WITH (fillfactor = 70);\nINSERT INTO h OVERRIDING SYSTEM VALUE\nSELECT * FROM orders WHERE id &lt;= 1000 ORDER BY id;</code></pre>\n<h2 id=\"every-row-has-an-address\">Every row has an address</h2>\n<p>The pages are numbered from 0, and right after its header each page keeps an array of line pointers, one per row, numbered from 1 (<a href=\"https://boringsql.com/posts/inside-the-8kb-page/\" rel=\"nofollow ugc noopener\">Inside PostgreSQL&#39;s 8KB Page</a> takes one apart byte by byte). Page number and line pointer together are the row&#39;s <code>ctid</code>:</p>\n<pre><code>SELECT ctid, id, created_at FROM orders ORDER BY id LIMIT 3;</code></pre>\n<pre><code> ctid  | id |       created_at\n-------+----+------------------------\n (0,1) |  1 | 2024-01-01 00:00:01+00\n (0,2) |  2 | 2024-01-01 00:00:02+00\n (0,3) |  3 | 2024-01-01 00:00:03+00</code></pre>\n<p>Both built-in sampling methods hash part of that address together with a seed and keep whatever hashes under a 1% cutoff. They differ only in which part. <code>SYSTEM</code>, in <code>src/backend/access/tablesample/system.c</code>, hashes the page number (trimmed to the lines that matter):</p>\n<pre><code>hashinput[0] = nextblock;          /* page */\nhashinput[1] = sampler-&gt;seed;\nhash = DatumGetUInt32(hash_any((const unsigned char *) hashinput,\n                               (int) sizeof(hashinput)));\nif (hash &lt; sampler-&gt;cutoff)        /* keep the whole page */</code></pre>\n<p><code>BERNOULLI</code>, in <code>bernoulli.c</code>, hashes the page and the line pointer number:</p>\n<pre><code>hashinput[0] = blockno;            /* page */\nhashinput[1] = tupoffset;          /* line pointer */\nhashinput[2] = sampler-&gt;seed;</code></pre>\n<p>So <code>SYSTEM</code> flips one coin per page and takes every row on a winning page. <code>BERNOULLI</code> flips one coin per line pointer, which means it has to open every page to see how many line pointers there are. That&#39;s the whole performance story: on this table <code>SYSTEM</code> answers in 1.2 ms and <code>BERNOULLI</code> in 10.4 ms.</p>\n<h2 id=\"pages-come-as-bundles\">Pages come as bundles</h2>\n<p>A page here holds about 107 rows, and since the rows went in by time, one page is 107 consecutive seconds of orders. Group a <code>SYSTEM</code> sample by the page half of <code>ctid</code> and the bundles are right there:</p>\n<pre><code>SELECT (ctid::text::point)[0]::int AS page, count(*) AS rows,\n       min(created_at)::time AS first, max(created_at)::time AS last\nFROM orders TABLESAMPLE SYSTEM (1) REPEATABLE (7)\nGROUP BY 1 ORDER BY 1 LIMIT 4;</code></pre>\n<pre><code> page | rows |  first   |   last\n------+------+----------+----------\n    4 |  107 | 00:07:09 | 00:08:55\n   38 |  107 | 01:07:47 | 01:09:33\n   57 |  107 | 01:41:40 | 01:43:26\n  265 |  107 | 07:52:36 | 07:54:22</code></pre>\n<p>The sample isn&#39;t 20,000 scattered seconds. It&#39;s about 200 short strips of the timeline, and <code>min()</code> returns the start of whichever strip the hash kept first. Runs one and five above kept the same first page.</p>\n<p>Shuffle the table and the problem disappears. <code>orders_shuffled</code> holds the same rows written in <code>ORDER BY random()</code> order, so every page is a handful of seconds from all over the three weeks, and the same query lands within a few minutes of the truth every time:</p>\n<pre><code> 2024-01-01 00:00:06+00\n 2024-01-01 00:01:20+00\n 2024-01-01 00:01:43+00\n 2024-01-01 00:01:24+00\n 2024-01-01 00:02:50+00\n 2024-01-01 00:05:07+00</code></pre>\n<p><code>BERNOULLI</code> on the original, time-ordered table does just as well, because a coin per line pointer never bundles anything.</p>\n<p>Nobody samples for a minimum, but the bundles hurt averages the same way. Across 200 seeds, <code>avg(created_at)</code> from a <code>SYSTEM</code> sample wanders <strong>ten times</strong> as far as the same average from <code>BERNOULLI</code>. The reason is plain once you think in pages: 107 rows from one page tell you about as much about the timeline as one row from that page would, so a <code>SYSTEM</code> sample of 20,000 rows has roughly the precision of a 200-row one. A column that has nothing to do with where rows sit, like a random <code>amount</code>, averages identically under both methods. Time columns, ever-increasing ids, and anything loaded in batches are the columns where it matters.</p>\n<h2 id=\"repeatable-remembers-addresses\">REPEATABLE remembers addresses</h2>\n<p><code>REPEATABLE (42)</code> fixes the seed. Same seed, same hash, same addresses. Whether those addresses still hold the same rows depends on what happened to the table in between, and the address model predicts every line of this table. Each run captures a sample with seed 42, changes the table, captures again, and counts how many rows from the first sample are still in it:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>after</th><th>SYSTEM kept</th><th>BERNOULLI kept</th></tr></thead><tbody><tr><td>append 200k rows</td><td>17,595 of 17,595</td><td>19,800 of 19,800</td></tr><tr><td>delete half the rows</td><td>8,795 of 8,795</td><td>10,003 of 10,003</td></tr><tr><td><code>VACUUM</code></td><td>8,795 of 8,795</td><td>10,003 of 10,003</td></tr><tr><td><code>UPDATE</code> every row</td><td>8,740 of 8,795</td><td><strong>109 of 10,003</strong></td></tr><tr><td><code>VACUUM FULL</code></td><td>113 of 8,795</td><td>113 of 10,003</td></tr><tr><td><code>CLUSTER</code> on a reversed index</td><td>0 of 8,795</td><td>95 of 10,003</td></tr></tbody></table></div>\n<p>Appends create new addresses and deletes empty old ones; no surviving row moves, so nothing changes. <code>VACUUM</code> marks dead line pointers unused and compacts the tuple data inside each page, but it never renumbers a line pointer that still points at a live row (<a href=\"https://boringsql.com/posts/vacuum-at-the-page-level/\" rel=\"nofollow ugc noopener\">VACUUM at the Page Level</a> walks through each phase). <code>VACUUM FULL</code> and <code>CLUSTER</code> build a fresh copy of the table, every row gets a new address, and both samples are effectively redrawn.</p>\n<p><code>UPDATE</code> is the interesting line. Postgres never overwrites a row in place: an update writes a new version, and when the page has room and no indexed column changed, it puts that version on the same page without touching any index. That&#39;s a HOT update (<a href=\"https://boringsql.com/posts/hot-updates/\" rel=\"nofollow ugc noopener\">HOT Updates in Postgres</a>), and here 99% of them were. Same page means <code>SYSTEM</code> doesn&#39;t notice. But the new version gets a new line pointer, and <code>pageinspect</code> shows it for one row of <code>h</code>:</p>\n<pre><code>SELECT ctid FROM h WHERE id = 3;   -- (0,3)\nUPDATE h SET amount = amount + 1 WHERE id = 3;\nSELECT ctid FROM h WHERE id = 3;   -- (0,76)\n\nSELECT lp, lp_flags, lp_off, t_ctid\nFROM heap_page_items(get_raw_page(&#39;h&#39;, 0)) WHERE lp IN (3, 76);</code></pre>\n<pre><code> lp | lp_flags | lp_off | t_ctid\n----+----------+--------+--------\n  3 |        2 |     76 |\n 76 |        1 |   2792 | (0,76)</code></pre>\n<p>Line pointer 3 is now <code>LP_REDIRECT</code> (<code>lp_flags = 2</code>), a signpost that says &quot;moved to 76&quot; so index entries pointing at <code>(0,3)</code> still find the row. <code>BERNOULLI</code> doesn&#39;t read signposts. It hashes line pointer 76, which is a fresh 1% coin flip, so almost every updated row drops out of the sample and a different 1% drops in.</p>\n<h2 id=\"hashing-the-key-instead\">Hashing the key instead</h2>\n<p>When people pin a seed, they usually want a development or test subset that stays the same from one day to the next. An address can&#39;t promise that, because updates and rewrites move rows. A primary key doesn&#39;t move, so hash the key:</p>\n<pre><code>SELECT * FROM orders WHERE (hashint8extended(id, 42) &amp; 1023) &lt; 10;</code></pre>\n<p><code>hashint8extended</code> hashes a bigint with a seed; keeping 10 of 1,024 buckets returns 0.98% of the rows. Through the same changes it kept every surviving row at every step, <strong>9,916 of 9,916</strong> after the delete, <code>UPDATE</code>, <code>VACUUM FULL</code> and <code>CLUSTER</code> included. It has no bundles either: its 200-seed spread on <code>avg(created_at)</code> matches <code>BERNOULLI</code>.</p>\n<p>A key can also be a foreign key. Sample <code>customers</code> on <code>hashint8extended(id, 42)</code> and <code>orders</code> on <code>hashint8extended(customer_id, 42)</code>, and you get 484 customers with all 19,432 of their orders. The same two tables sampled with <code>SYSTEM (1) REPEATABLE (42)</code> leave 17,517 of the 17,595 sampled orders pointing at a customer who isn&#39;t in the sample.</p>\n<p>The price is a full scan, since the hash has to be computed for every row: 44.4 ms serially, 21.5 ms with two parallel workers. For a subset you pull often, an expression index on <code>(hashint8extended(id, 42) &amp; 1023)</code> (14 MB here) turns it into a bitmap scan at 8.5 ms, with the seed fixed in the index definition.</p>\n<h2 id=\"the-join-doesn-t-shrink\">The join doesn&#39;t shrink</h2>\n<p>Everything so far read one table. Join the orders to their customers and count:</p>\n<pre><code>EXPLAIN (ANALYZE, BUFFERS)\nSELECT count(*) FROM orders o JOIN customers c ON c.id = o.customer_id;</code></pre>\n<p>The full join answers in 84.4 ms from a parallel hash join with two workers: 18,751 buffer hits, all but 666 of them on <code>orders</code>. Sample the orders side:</p>\n<pre><code>EXPLAIN (ANALYZE, BUFFERS)\nSELECT count(*) * 100\nFROM orders o TABLESAMPLE SYSTEM (1) REPEATABLE (7)\nJOIN customers c ON c.id = o.customer_id;</code></pre>\n<pre><code> Aggregate\n   Buffers: shared hit=424\n   -&gt;  Hash Join (actual rows=22316.00 loops=1)\n         Hash Cond: (o.customer_id = c.id)\n         -&gt;  Sample Scan on orders o (actual rows=22316.00 loops=1)\n               Sampling: system (&#39;1&#39;::real)\n               Buffers: shared hit=202\n         -&gt;  Hash\n               Buckets: 65536  Batches: 1  Memory Usage: 2270kB\n               Buffers: shared hit=222\n               -&gt;  Seq Scan on customers c (actual rows=50001.00 loops=1)\n                     Buffers: shared hit=222\n Execution Time: 25.590 ms</code></pre>\n<p>The sample scan did its part, 202 pages against the full 18,085. The hash over <code>customers</code> still reads all 222 of its pages, because <code>TABLESAMPLE</code> only shrinks the table it sits on, and building the hash is 11.5 ms of the 25.6. The query gets 3.3× faster, not 90×. Most of what remains is the table the sample never touched. The workers are gone too: a sample scan runs in the leader process, so the plan falls back to serial however big the table is.</p>\n<p>Forcing the other join shape moves the cost rather than removing it:</p>\n<pre><code>SET enable_hashjoin = off;\nSET enable_mergejoin = off;</code></pre>\n<p>The same query then runs 22,316 index-only probes against <code>customers_pkey</code>, 44,636 buffer hits against the sample&#39;s own 202, and finishes in 21.6 ms. Every sampled row pays full price for its customer lookup.</p>\n<p>A predicate on the far side works normally: adding <code>WHERE c.id &lt;= 500</code> builds the hash over 500 rows and answers in 1.5 ms. Some questions break outright, though. Every one of the 50,001 customers has orders, but the 1% draw sees:</p>\n<pre><code>SELECT count(DISTINCT customer_id) FROM orders TABLESAMPLE SYSTEM (1) REPEATABLE (7);</code></pre>\n<pre><code> count\n-------\n 18034</code></pre>\n<p>Multiplying by 100 can&#39;t bring back a customer whose orders all landed on losing pages. A sample scales sums and counts of what it kept; anything you have to see to count comes out low.</p>\n<p>Sampling the join output directly isn&#39;t an option: <code>TABLESAMPLE</code> attaches to one table in the <code>FROM</code> clause, and putting it on a join or a subquery is a syntax error. Sampling both sides is as close as you can get, and it&#39;s close to useless:</p>\n<pre><code>SELECT count(*)\nFROM orders o TABLESAMPLE SYSTEM (1) REPEATABLE (7)\nJOIN customers c TABLESAMPLE SYSTEM (1) REPEATABLE (7) ON c.id = o.customer_id;</code></pre>\n<pre><code> count\n-------\n   288</code></pre>\n<p>The real join has two million rows. Two independent address draws keep 288 of them, because a sampled order finds its customer only when both page hashes kept the matching pair. When a sampled pair of tables has to stay joinable, hashing the foreign key on both sides is the way to do it.</p>\n<h2 id=\"which-to-reach-for\">Which to reach for</h2>\n<p><code>SYSTEM</code> for a quick number over a column that has nothing to do with insert order. <code>BERNOULLI</code> when it might, and reading the whole table once is affordable. A hashed key when the subset has to be the same rows tomorrow, or has to keep its foreign keys intact. None of them when you need a filtered subset: the sample is chosen before your <code>WHERE</code> runs, so the filter only trims a draw that was already made.</p>\n<p><code>ANALYZE</code> builds <code>pg_stats</code> from a sample too, with its own block sampler and a seed you don&#39;t get to set, which is why the numbers move between runs even when the data hasn&#39;t.</p>","headings":[{"level":2,"text":"Setup","id":"setup"},{"level":2,"text":"Every row has an address","id":"every-row-has-an-address"},{"level":2,"text":"Pages come as bundles","id":"pages-come-as-bundles"},{"level":2,"text":"REPEATABLE remembers addresses","id":"repeatable-remembers-addresses"},{"level":2,"text":"Hashing the key instead","id":"hashing-the-key-instead"},{"level":2,"text":"The join doesn't shrink","id":"the-join-doesn-t-shrink"},{"level":2,"text":"Which to reach for","id":"which-to-reach-for"}]}}