[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-en-postgres-partial-index-explain-proof":3},{"id":4,"title":5,"author":6,"body":7,"cover":751,"description":752,"draft":753,"extension":754,"locale":755,"meta":756,"navigation":127,"path":757,"publishedAt":758,"seo":759,"seoTitle":760,"slug":761,"stem":762,"tags":763,"__hash__":767},"blog\u002Fen\u002Fblog\u002Fpostgres-partial-index-explain-proof.md","Partial indexes in PostgreSQL: EXPLAIN ANALYZE proof and a negative control","Manuel Tardivo",{"type":8,"value":9,"toc":740},"minimark",[10,15,41,64,68,87,91,105,247,250,265,299,308,312,356,359,394,399,407,410,445,466,470,498,507,535,552,558,560,582,589,593,621,636,641,647,653,657,670,674,707,711,736],[11,12,14],"h2",{"id":13},"goal","Goal",[16,17,18,19,23,24,27,28,31,32,36,37,40],"p",{},"On a ",[20,21,22],"strong",{},"synthetic but reproducible"," workload, check whether a partial index aligned with the predicate moves the plan from a ",[20,25,26],{},"heap sequential scan"," to an ",[20,29,30],{},"index-backed access path",", and whether ",[33,34,35],"code",{},"BUFFERS"," shifts reads from the whole table toward the index (plus a small negative test: a query whose predicate is ",[20,38,39],{},"not"," covered by the partial index should not use that index as the main filter path).",[16,42,43,44,47,48,51,52,59,60,63],{},"Version anchor: node names and ",[33,45,46],{},"EXPLAIN"," options follow ",[20,49,50],{},"PostgreSQL 16"," as in the ",[53,54,58],"a",{"href":55,"rel":56},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-explain.html",[57],"nofollow","official docs",". Timings and buffer counts vary by hardware and cache; the ",[20,61,62],{},"shape"," of the comparison is what this note keeps.",[11,65,67],{"id":66},"hypothesis","Hypothesis",[16,69,70,71,74,75,78,79,82,83,86],{},"With ~500k rows and a small ",[33,72,73],{},"open"," fraction, a query like ",[33,76,77],{},"WHERE tenant_id = constant AND status = 'open'"," should, ",[20,80,81],{},"after"," ",[33,84,85],{},"CREATE INDEX … WHERE (status = 'open')",", tend toward a plan that hits the indexed subset instead of scanning almost the entire heap.",[11,88,90],{"id":89},"dataset-deterministic","Dataset (deterministic)",[16,92,93,96,97,100,101,104],{},[33,94,95],{},"random()"," makes every reload differ. Here ",[33,98,99],{},"hashtextextended"," on strings built from ",[33,102,103],{},"n"," fixes the row mix for a given Postgres major.",[106,107,112],"pre",{"className":108,"code":109,"language":110,"meta":111,"style":111},"language-sql shiki shiki-themes github-light github-dark","DROP TABLE IF EXISTS orders;\n\nCREATE TABLE orders (\n  id           bigserial PRIMARY KEY,\n  tenant_id    int NOT NULL,\n  status       text NOT NULL CHECK (status IN ('open', 'closed', 'archived')),\n  amount_cents int NOT NULL,\n  created_at   timestamptz NOT NULL DEFAULT now()\n);\n\nINSERT INTO orders (tenant_id, status, amount_cents)\nSELECT\n  (abs(hashtextextended('t' || n::text, 0)) % 5) + 1 AS tenant_id,\n  CASE\n    WHEN (abs(hashtextextended('o' || n::text, 0)) % 1000) \u003C 12 THEN 'open'\n    WHEN (abs(hashtextextended('c' || n::text, 0)) % 1000) \u003C 12 THEN 'closed'\n    ELSE 'archived'\n  END AS status,\n  abs(hashtextextended('a' || n::text, 0)) % 100000 AS amount_cents\nFROM generate_series(1, 500000) AS s(n);\n\nANALYZE orders;\n","sql","",[33,113,114,122,129,135,141,147,153,159,165,171,176,182,188,194,200,206,212,218,224,230,236,241],{"__ignoreMap":111},[115,116,119],"span",{"class":117,"line":118},"line",1,[115,120,121],{},"DROP TABLE IF EXISTS orders;\n",[115,123,125],{"class":117,"line":124},2,[115,126,128],{"emptyLinePlaceholder":127},true,"\n",[115,130,132],{"class":117,"line":131},3,[115,133,134],{},"CREATE TABLE orders (\n",[115,136,138],{"class":117,"line":137},4,[115,139,140],{},"  id           bigserial PRIMARY KEY,\n",[115,142,144],{"class":117,"line":143},5,[115,145,146],{},"  tenant_id    int NOT NULL,\n",[115,148,150],{"class":117,"line":149},6,[115,151,152],{},"  status       text NOT NULL CHECK (status IN ('open', 'closed', 'archived')),\n",[115,154,156],{"class":117,"line":155},7,[115,157,158],{},"  amount_cents int NOT NULL,\n",[115,160,162],{"class":117,"line":161},8,[115,163,164],{},"  created_at   timestamptz NOT NULL DEFAULT now()\n",[115,166,168],{"class":117,"line":167},9,[115,169,170],{},");\n",[115,172,174],{"class":117,"line":173},10,[115,175,128],{"emptyLinePlaceholder":127},[115,177,179],{"class":117,"line":178},11,[115,180,181],{},"INSERT INTO orders (tenant_id, status, amount_cents)\n",[115,183,185],{"class":117,"line":184},12,[115,186,187],{},"SELECT\n",[115,189,191],{"class":117,"line":190},13,[115,192,193],{},"  (abs(hashtextextended('t' || n::text, 0)) % 5) + 1 AS tenant_id,\n",[115,195,197],{"class":117,"line":196},14,[115,198,199],{},"  CASE\n",[115,201,203],{"class":117,"line":202},15,[115,204,205],{},"    WHEN (abs(hashtextextended('o' || n::text, 0)) % 1000) \u003C 12 THEN 'open'\n",[115,207,209],{"class":117,"line":208},16,[115,210,211],{},"    WHEN (abs(hashtextextended('c' || n::text, 0)) % 1000) \u003C 12 THEN 'closed'\n",[115,213,215],{"class":117,"line":214},17,[115,216,217],{},"    ELSE 'archived'\n",[115,219,221],{"class":117,"line":220},18,[115,222,223],{},"  END AS status,\n",[115,225,227],{"class":117,"line":226},19,[115,228,229],{},"  abs(hashtextextended('a' || n::text, 0)) % 100000 AS amount_cents\n",[115,231,233],{"class":117,"line":232},20,[115,234,235],{},"FROM generate_series(1, 500000) AS s(n);\n",[115,237,239],{"class":117,"line":238},21,[115,240,128],{"emptyLinePlaceholder":127},[115,242,244],{"class":117,"line":243},22,[115,245,246],{},"ANALYZE orders;\n",[16,248,249],{},"Sanity checks right after load:",[106,251,253],{"className":108,"code":252,"language":110,"meta":111,"style":111},"SELECT status, count(*) FROM orders GROUP BY 1 ORDER BY 1;\nSELECT count(*) FROM orders WHERE tenant_id = 3 AND status = 'open';\n",[33,254,255,260],{"__ignoreMap":111},[115,256,257],{"class":117,"line":118},[115,258,259],{},"SELECT status, count(*) FROM orders GROUP BY 1 ORDER BY 1;\n",[115,261,262],{"class":117,"line":124},[115,263,264],{},"SELECT count(*) FROM orders WHERE tenant_id = 3 AND status = 'open';\n",[16,266,267,268,82,271,273,274,277,278,82,281,283,284,287,288,291,292,295,296,298],{},"You should see ",[20,269,270],{},"few",[33,272,73],{}," rows vs ",[33,275,276],{},"archived",", and ",[20,279,280],{},"> 0",[33,282,73],{}," rows for ",[33,285,286],{},"tenant_id = 3"," (tenant and status are decoupled unlike a naive ",[33,289,290],{},"n % 100"," \u002F ",[33,293,294],{},"n % 5"," pattern that can park all ",[33,297,73],{}," rows on one tenant).",[16,300,301,302,82,305,307],{},"Run the ",[20,303,304],{},"same",[33,306,46],{}," below in phase A (primary key only) and again in phase B (after the partial index).",[11,309,311],{"id":310},"phase-a-primary-key-only","Phase A — primary key only",[16,313,314,315,318,319,322,323,325,326,329,330,339,340,343,344,347,348,351,352,355],{},"With the table filled and ",[20,316,317],{},"no"," secondary index on ",[33,320,321],{},"(tenant_id, …)"," restricted to ",[33,324,73],{},", the ",[20,327,328],{},"expected"," plan is a ",[20,331,332,335,336],{},[33,333,334],{},"Seq Scan"," on ",[33,337,338],{},"orders",": scan the heap (or most of it), filter on ",[33,341,342],{},"tenant_id"," and ",[33,345,346],{},"status",", satisfy ",[33,349,350],{},"ORDER BY id",", then ",[33,353,354],{},"LIMIT",".",[16,357,358],{},"Run:",[106,360,362],{"className":108,"code":361,"language":110,"meta":111,"style":111},"EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)\nSELECT id, amount_cents\nFROM orders\nWHERE tenant_id = 3 AND status = 'open'\nORDER BY id\nLIMIT 50;\n",[33,363,364,369,374,379,384,389],{"__ignoreMap":111},[115,365,366],{"class":117,"line":118},[115,367,368],{},"EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)\n",[115,370,371],{"class":117,"line":124},[115,372,373],{},"SELECT id, amount_cents\n",[115,375,376],{"class":117,"line":131},[115,377,378],{},"FROM orders\n",[115,380,381],{"class":117,"line":137},[115,382,383],{},"WHERE tenant_id = 3 AND status = 'open'\n",[115,385,386],{"class":117,"line":143},[115,387,388],{},"ORDER BY id\n",[115,390,391],{"class":117,"line":149},[115,392,393],{},"LIMIT 50;\n",[16,395,396],{},[20,397,398],{},"Sample plan (abbreviated, PG 16 — costs\u002Ftimes\u002Fbuffers are illustrative; on your host they differ; node hierarchy is what matters):",[106,400,405],{"className":401,"code":403,"language":404,"meta":111},[402],"language-text","Limit  (cost=... rows=50 width=...) (actual time=... rows=50 loops=1)\n  ->  Sort  (...)\n        Sort Key: id\n        Sort Method: top-N heapsort\n        ->  Seq Scan on orders  (...)\n              Filter: ((tenant_id = 3) AND (status = 'open'::text))\n              Rows Removed by Filter: 499xxx   -- almost the whole table dropped here\n              Buffers: shared hit=... read=...  -- lots of heap, tiny result\n","text",[33,406,403],{"__ignoreMap":111},[16,408,409],{},"What to scan for:",[411,412,413,425,437],"ul",{},[414,415,416,421,422,424],"li",{},[20,417,418],{},[33,419,420],{},"Seq Scan on orders"," (no index satisfying the ",[33,423,73],{}," predicate);",[414,426,427,432,433,436],{},[20,428,429],{},[33,430,431],{},"Rows Removed by Filter"," huge vs the final ",[20,434,435],{},"50"," rows;",[414,438,439,444],{},[20,440,441],{},[33,442,443],{},"Buffers"," on the heap relation large relative to the result.",[446,447,448],"blockquote",{},[16,449,450,453,454,457,458,461,462,465],{},[20,451,452],{},"Note:"," do not recreate the table between phase A and phase B. The index is built on the ",[20,455,456],{},"same populated heap","; if you run ",[33,459,460],{},"DROP","\u002F",[33,463,464],{},"INSERT"," in between, the plans are no longer comparable to the baseline you just captured.",[11,467,469],{"id":468},"phase-b-partial-index-aligned-with-the-predicate","Phase B — partial index aligned with the predicate",[106,471,473],{"className":108,"code":472,"language":110,"meta":111,"style":111},"CREATE INDEX orders_open_by_tenant_partial\n  ON orders (tenant_id, id)\n  WHERE status = 'open';\n\nANALYZE orders;\n",[33,474,475,480,485,490,494],{"__ignoreMap":111},[115,476,477],{"class":117,"line":118},[115,478,479],{},"CREATE INDEX orders_open_by_tenant_partial\n",[115,481,482],{"class":117,"line":124},[115,483,484],{},"  ON orders (tenant_id, id)\n",[115,486,487],{"class":117,"line":131},[115,488,489],{},"  WHERE status = 'open';\n",[115,491,492],{"class":117,"line":137},[115,493,128],{"emptyLinePlaceholder":127},[115,495,496],{"class":117,"line":143},[115,497,246],{},[16,499,500,501,82,504,506],{},"Re-run the ",[20,502,503],{},"identical",[33,505,46],{}," from phase A:",[106,508,509],{"className":108,"code":361,"language":110,"meta":111,"style":111},[33,510,511,515,519,523,527,531],{"__ignoreMap":111},[115,512,513],{"class":117,"line":118},[115,514,368],{},[115,516,517],{"class":117,"line":124},[115,518,373],{},[115,520,521],{"class":117,"line":131},[115,522,378],{},[115,524,525],{"class":117,"line":137},[115,526,383],{},[115,528,529],{"class":117,"line":143},[115,530,388],{},[115,532,533],{"class":117,"line":149},[115,534,393],{},[16,536,537],{},[20,538,539,540,543,544,547,548,551],{},"Sample plan after the index (abbreviated — Postgres may pick ",[33,541,542],{},"Index Scan"," or a ",[33,545,546],{},"Bitmap Index Scan"," → ",[33,549,550],{},"Bitmap Heap Scan"," chain; the goal is to see the partial index name in the plan):",[106,553,556],{"className":554,"code":555,"language":404,"meta":111},[402],"Limit  (...)\n  ->  Sort  (...)\n        ->  Bitmap Heap Scan on orders  (...)\n              Recheck Cond: ((tenant_id = 3) AND (status = 'open'::text))\n              ->  Bitmap Index Scan on orders_open_by_tenant_partial  (...)\n                    Index Cond: (tenant_id = 3)\n              Buffers: shared hit=... read=...   -- index reads show up; heap much lighter\n",[33,557,555],{"__ignoreMap":111},[16,559,409],{},[411,561,562,571],{},[414,563,564,565,570],{},"the name ",[20,566,567],{},[33,568,569],{},"orders_open_by_tenant_partial"," (or an equivalent access path that names it);",[414,572,573,574,578,579,355],{},"in ",[20,575,576],{},[33,577,443],{},", index blocks plus a much lighter heap footprint than phase A for the same ",[33,580,581],{},"SELECT",[16,583,584,585,588],{},"The A vs B comparison is not theoretical: it is the ",[20,586,587],{},"same query"," twice, with only the index DDL in between.",[11,590,592],{"id":591},"negative-control","Negative control",[106,594,596],{"className":108,"code":595,"language":110,"meta":111,"style":111},"EXPLAIN (ANALYZE, BUFFERS)\nSELECT id\nFROM orders\nWHERE tenant_id = 3 AND status = 'closed'\nLIMIT 50;\n",[33,597,598,603,608,612,617],{"__ignoreMap":111},[115,599,600],{"class":117,"line":118},[115,601,602],{},"EXPLAIN (ANALYZE, BUFFERS)\n",[115,604,605],{"class":117,"line":124},[115,606,607],{},"SELECT id\n",[115,609,610],{"class":117,"line":131},[115,611,378],{},[115,613,614],{"class":117,"line":137},[115,615,616],{},"WHERE tenant_id = 3 AND status = 'closed'\n",[115,618,619],{"class":117,"line":143},[115,620,393],{},[16,622,623,624,627,628,632,633,355],{},"The predicate does not match ",[33,625,626],{},"WHERE status = 'open'"," on the partial index. ",[20,629,630],{},[33,631,569],{}," must not appear as the access path that filters ",[33,634,635],{},"closed",[16,637,638],{},[20,639,640],{},"Sample (abbreviated) — expect seq scan or another path, but not the partial “open” index:",[106,642,645],{"className":643,"code":644,"language":404,"meta":111},[402],"Limit  (...)\n  ->  Seq Scan on orders  (...)\n        Filter: ((tenant_id = 3) AND (status = 'closed'::text))\n        Rows Removed by Filter: ...\n        Buffers: ...\n-- no line \"Bitmap Index Scan on orders_open_by_tenant_partial\"\n",[33,646,644],{"__ignoreMap":111},[16,648,649,650,652],{},"If that index name showed up to answer ",[33,651,635],{},", that would be a signal to dig (stats, predicate shape, wrong assumption), not a silent win.",[11,654,656],{"id":655},"out-of-scope","Out of scope",[16,658,659,660,663,664,666,667,669],{},"No parallel workers, no ",[33,661,662],{},"INCLUDE",", no partitioning—only heap, one partial index, and two query families (",[33,665,73],{}," vs ",[33,668,635],{},") to isolate the effect.",[11,671,673],{"id":672},"closing","Closing",[16,675,676,677,680,681,685,686,688,689,691,692,694,695,698,699,702,703,706],{},"The boxed plans are ",[20,678,679],{},"visual anchors",": they show ",[682,683,684],"em",{},"where"," to look in the plan text (",[33,687,334],{}," vs index name, ",[33,690,431],{},", ",[33,693,443],{},"). Exact numbers (timings, buffer hits) come ",[20,696,697],{},"only"," from your run of the scripts above. If Postgres still prefers a seq scan after the index, the same production checklist applies: predicate\u002Findex alignment, implicit casts, ",[33,700,701],{},"ANALYZE"," freshness, ",[33,704,705],{},"work_mem",", estimated vs actual cardinalities.",[11,708,710],{"id":709},"references","References",[411,712,713,725],{},[414,714,715,720,721,724],{},[53,716,719],{"href":717,"rel":718},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-createindex.html",[57],"CREATE INDEX"," — ",[33,722,723],{},"WHERE"," clause",[414,726,727,720,732,691,734],{},[53,728,730],{"href":55,"rel":729},[57],[33,731,46],{},[33,733,701],{},[33,735,35],{},[737,738,739],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":111,"searchDepth":124,"depth":124,"links":741},[742,743,744,745,746,747,748,749,750],{"id":13,"depth":124,"text":14},{"id":66,"depth":124,"text":67},{"id":89,"depth":124,"text":90},{"id":310,"depth":124,"text":311},{"id":468,"depth":124,"text":469},{"id":591,"depth":124,"text":592},{"id":655,"depth":124,"text":656},{"id":672,"depth":124,"text":673},{"id":709,"depth":124,"text":710},"\u002Fblog\u002F32_postgres_partial_index_explain_proof_1x.webp","A deterministic dataset and the same query before and after a partial index: what to read in EXPLAIN ANALYZE, plus a negative control on closed rows.",false,"md","en",{},"\u002Fen\u002Fblog\u002Fpostgres-partial-index-explain-proof","2026-06-10",{"title":5,"description":752},"Postgres partial indexes: EXPLAIN proof","postgres-partial-index-explain-proof","en\u002Fblog\u002Fpostgres-partial-index-explain-proof",[764,765,766],"postgres","performance","mvp","sjoDcecY80g0sYgUY6t4QdqTT01rSzlQjHYbcFRrOW8"]