[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-it-postgres-partial-index-explain-proof":3},{"id":4,"title":5,"author":6,"body":7,"cover":763,"description":764,"draft":765,"extension":766,"locale":767,"meta":768,"navigation":131,"path":769,"publishedAt":770,"seo":771,"seoTitle":772,"slug":773,"stem":774,"tags":775,"__hash__":779},"blog\u002Fit\u002Fblog\u002Fpostgres-partial-index-explain-proof.md","Indici parziali in PostgreSQL: verifica con EXPLAIN ANALYZE e controllo negativo","Manuel Tardivo",{"type":8,"value":9,"toc":752},"minimark",[10,15,41,64,68,87,91,109,251,254,269,304,314,318,362,365,400,405,413,416,452,473,477,505,514,542,559,565,567,593,600,604,632,649,654,660,666,670,683,687,719,723,748],[11,12,14],"h2",{"id":13},"obiettivo","Obiettivo",[16,17,18,19,23,24,27,28,31,32,36,37,40],"p",{},"Misurare, su un carico ",[20,21,22],"strong",{},"sintetico ma riproducibile",", se un indice parziale allineato al predicato sposta il piano da ",[20,25,26],{},"sequential scan sulla heap"," a ",[20,29,30],{},"accesso per indice"," e se, leggendo ",[33,34,35],"code",{},"BUFFERS",", le letture si concentrano sull’indice invece che sull’intera tabella. Il test include anche una query il cui predicato ",[20,38,39],{},"non"," entra nell’indice parziale, per vedere che il planner non lo “presta” per errore.",[16,42,43,44,47,48,51,52,59,60,63],{},"Riferimento di versione: comportamento descritto allineato a ",[20,45,46],{},"PostgreSQL 16"," (nomi dei nodi e opzioni ",[33,49,50],{},"EXPLAIN"," come in ",[53,54,58],"a",{"href":55,"rel":56},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-explain.html",[57],"nofollow","documentazione ufficiale","). Su altre major i costi e i tempi cambiano; la ",[20,61,62],{},"forma"," del confronto resta valida.",[11,65,67],{"id":66},"ipotesi","Ipotesi",[16,69,70,71,74,75,78,79,82,83,86],{},"Con ~500k righe e una quota piccola di righe ",[33,72,73],{},"open",", una query del tipo ",[33,76,77],{},"WHERE tenant_id = costante AND status = 'open'"," dovrebbe, ",[20,80,81],{},"dopo"," ",[33,84,85],{},"CREATE INDEX … WHERE (status = 'open')",", favorire un piano che attinge al sottoinsieme indicizzato invece di leggere quasi tutta la heap.",[11,88,90],{"id":89},"dataset-deterministico","Dataset (deterministico)",[16,92,93,96,97,100,101,104,105,108],{},[33,94,95],{},"random()"," rende il file diverso a ogni run. Qui uso ",[33,98,99],{},"hashtextextended"," su stringhe derivate da ",[33,102,103],{},"n"," così, a parità di major Postgres, ",[20,106,107],{},"stesse righe"," a ogni ricreazione della tabella.",[110,111,116],"pre",{"className":112,"code":113,"language":114,"meta":115,"style":115},"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,117,118,126,133,139,145,151,157,163,169,175,180,186,192,198,204,210,216,222,228,234,240,245],{"__ignoreMap":115},[119,120,123],"span",{"class":121,"line":122},"line",1,[119,124,125],{},"DROP TABLE IF EXISTS orders;\n",[119,127,129],{"class":121,"line":128},2,[119,130,132],{"emptyLinePlaceholder":131},true,"\n",[119,134,136],{"class":121,"line":135},3,[119,137,138],{},"CREATE TABLE orders (\n",[119,140,142],{"class":121,"line":141},4,[119,143,144],{},"  id           bigserial PRIMARY KEY,\n",[119,146,148],{"class":121,"line":147},5,[119,149,150],{},"  tenant_id    int NOT NULL,\n",[119,152,154],{"class":121,"line":153},6,[119,155,156],{},"  status       text NOT NULL CHECK (status IN ('open', 'closed', 'archived')),\n",[119,158,160],{"class":121,"line":159},7,[119,161,162],{},"  amount_cents int NOT NULL,\n",[119,164,166],{"class":121,"line":165},8,[119,167,168],{},"  created_at   timestamptz NOT NULL DEFAULT now()\n",[119,170,172],{"class":121,"line":171},9,[119,173,174],{},");\n",[119,176,178],{"class":121,"line":177},10,[119,179,132],{"emptyLinePlaceholder":131},[119,181,183],{"class":121,"line":182},11,[119,184,185],{},"INSERT INTO orders (tenant_id, status, amount_cents)\n",[119,187,189],{"class":121,"line":188},12,[119,190,191],{},"SELECT\n",[119,193,195],{"class":121,"line":194},13,[119,196,197],{},"  (abs(hashtextextended('t' || n::text, 0)) % 5) + 1 AS tenant_id,\n",[119,199,201],{"class":121,"line":200},14,[119,202,203],{},"  CASE\n",[119,205,207],{"class":121,"line":206},15,[119,208,209],{},"    WHEN (abs(hashtextextended('o' || n::text, 0)) % 1000) \u003C 12 THEN 'open'\n",[119,211,213],{"class":121,"line":212},16,[119,214,215],{},"    WHEN (abs(hashtextextended('c' || n::text, 0)) % 1000) \u003C 12 THEN 'closed'\n",[119,217,219],{"class":121,"line":218},17,[119,220,221],{},"    ELSE 'archived'\n",[119,223,225],{"class":121,"line":224},18,[119,226,227],{},"  END AS status,\n",[119,229,231],{"class":121,"line":230},19,[119,232,233],{},"  abs(hashtextextended('a' || n::text, 0)) % 100000 AS amount_cents\n",[119,235,237],{"class":121,"line":236},20,[119,238,239],{},"FROM generate_series(1, 500000) AS s(n);\n",[119,241,243],{"class":121,"line":242},21,[119,244,132],{"emptyLinePlaceholder":131},[119,246,248],{"class":121,"line":247},22,[119,249,250],{},"ANALYZE orders;\n",[16,252,253],{},"Verifiche consigliate subito dopo l’insert (output numerico dipende solo da questa formula, non da “un feeling”):",[110,255,257],{"className":112,"code":256,"language":114,"meta":115,"style":115},"SELECT status, count(*) FROM orders GROUP BY 1 ORDER BY 1;\nSELECT count(*) FROM orders WHERE tenant_id = 3 AND status = 'open';\n",[33,258,259,264],{"__ignoreMap":115},[119,260,261],{"class":121,"line":122},[119,262,263],{},"SELECT status, count(*) FROM orders GROUP BY 1 ORDER BY 1;\n",[119,265,266],{"class":121,"line":128},[119,267,268],{},"SELECT count(*) FROM orders WHERE tenant_id = 3 AND status = 'open';\n",[16,270,271,272,82,275,277,278,281,282,285,286,288,289,292,293,296,297,300,301,303],{},"Ci si aspetta ",[20,273,274],{},"pochi",[33,276,73],{}," rispetto ad ",[33,279,280],{},"archived",", e un conteggio ",[20,283,284],{},"> 0"," di ",[33,287,73],{}," per ",[33,290,291],{},"tenant_id = 3"," (il mix tenant\u002Fstatus è decorrelato rispetto al vecchio schema ",[33,294,295],{},"n % 100"," \u002F ",[33,298,299],{},"n % 5",", che incollava ",[33,302,73],{}," su un solo tenant).",[16,305,306,307,310,311,313],{},"La ",[20,308,309],{},"stessa"," istruzione ",[33,312,50],{}," sotto va eseguita in fase A (solo PK) e di nuovo in fase B (dopo l’indice parziale).",[11,315,317],{"id":316},"fase-a-solo-chiave-primaria","Fase A — solo chiave primaria",[16,319,320,321,324,325,328,329,331,332,335,336,345,346,349,350,353,354,357,358,361],{},"Con la tabella così popolata e ",[20,322,323],{},"senza"," indice secondario su ",[33,326,327],{},"(tenant_id, …)"," filtrato per ",[33,330,73],{},", il piano ",[20,333,334],{},"atteso"," è un ",[20,337,338,341,342],{},[33,339,340],{},"Seq Scan"," su ",[33,343,344],{},"orders",": attraversamento della heap (o gran parte), filtro su ",[33,347,348],{},"tenant_id"," e ",[33,351,352],{},"status",", soddisfacimento di ",[33,355,356],{},"ORDER BY id",", poi ",[33,359,360],{},"LIMIT",".",[16,363,364],{},"Esegui:",[110,366,368],{"className":112,"code":367,"language":114,"meta":115,"style":115},"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,369,370,375,380,385,390,395],{"__ignoreMap":115},[119,371,372],{"class":121,"line":122},[119,373,374],{},"EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)\n",[119,376,377],{"class":121,"line":128},[119,378,379],{},"SELECT id, amount_cents\n",[119,381,382],{"class":121,"line":135},[119,383,384],{},"FROM orders\n",[119,386,387],{"class":121,"line":141},[119,388,389],{},"WHERE tenant_id = 3 AND status = 'open'\n",[119,391,392],{"class":121,"line":147},[119,393,394],{},"ORDER BY id\n",[119,396,397],{"class":121,"line":153},[119,398,399],{},"LIMIT 50;\n",[16,401,402],{},[20,403,404],{},"Esempio di piano (sintetico, PG 16 — costi\u002Ftempi\u002Fbuffer sono indicativi; sul tuo host saranno diversi, la gerarchia dei nodi è ciò che conta):",[110,406,411],{"className":407,"code":409,"language":410,"meta":115},[408],"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   -- quasi tutta la tabella scartata qui\n              Buffers: shared hit=... read=...  -- molta heap, poco risultato\n","text",[33,412,409],{"__ignoreMap":115},[16,414,415],{},"Cosa verificare a occhio:",[417,418,419,432,444],"ul",{},[420,421,422,423,428,429,431],"li",{},"compare ",[20,424,425],{},[33,426,427],{},"Seq Scan on orders"," (non un indice sul predicato ",[33,430,73],{},");",[420,433,434,439,440,443],{},[20,435,436],{},[33,437,438],{},"Rows Removed by Filter"," molto alto rispetto alle ",[20,441,442],{},"50"," righe finali;",[420,445,446,451],{},[20,447,448],{},[33,449,450],{},"Buffers"," sulla relazione heap ampia rispetto al risultato.",[453,454,455],"blockquote",{},[16,456,457,460,461,464,465,468,469,472],{},[20,458,459],{},"Nota:"," non ricreare la tabella tra fase A e fase B. L’indice si aggiunge sulla ",[20,462,463],{},"stessa heap"," già popolata; se rifai ",[33,466,467],{},"DROP","\u002F",[33,470,471],{},"INSERT"," nel mezzo, i piani non sono più confrontabili con la baseline appena misurata.",[11,474,476],{"id":475},"fase-b-indice-parziale-allineato-al-predicato","Fase B — indice parziale allineato al predicato",[110,478,480],{"className":112,"code":479,"language":114,"meta":115,"style":115},"CREATE INDEX orders_open_by_tenant_partial\n  ON orders (tenant_id, id)\n  WHERE status = 'open';\n\nANALYZE orders;\n",[33,481,482,487,492,497,501],{"__ignoreMap":115},[119,483,484],{"class":121,"line":122},[119,485,486],{},"CREATE INDEX orders_open_by_tenant_partial\n",[119,488,489],{"class":121,"line":128},[119,490,491],{},"  ON orders (tenant_id, id)\n",[119,493,494],{"class":121,"line":135},[119,495,496],{},"  WHERE status = 'open';\n",[119,498,499],{"class":121,"line":141},[119,500,132],{"emptyLinePlaceholder":131},[119,502,503],{"class":121,"line":147},[119,504,250],{},[16,506,507,508,82,511,513],{},"Ripeti ",[20,509,510],{},"lo stesso identico",[33,512,50],{}," della fase A:",[110,515,516],{"className":112,"code":367,"language":114,"meta":115,"style":115},[33,517,518,522,526,530,534,538],{"__ignoreMap":115},[119,519,520],{"class":121,"line":122},[119,521,374],{},[119,523,524],{"class":121,"line":128},[119,525,379],{},[119,527,528],{"class":121,"line":135},[119,529,384],{},[119,531,532],{"class":121,"line":141},[119,533,389],{},[119,535,536],{"class":121,"line":147},[119,537,394],{},[119,539,540],{"class":121,"line":153},[119,541,399],{},[16,543,544],{},[20,545,546,547,550,551,554,555,558],{},"Esempio di piano dopo l’indice (sintetico — Postgres può scegliere ",[33,548,549],{},"Index Scan"," o catena ",[33,552,553],{},"Bitmap Index Scan"," → ",[33,556,557],{},"Bitmap Heap Scan","; l’obiettivo è vedere il nome dell’indice parziale nel piano):",[110,560,563],{"className":561,"code":562,"language":410,"meta":115},[408],"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=...   -- compaiono letture sull’INDICE + heap ridotta\n",[33,564,562],{"__ignoreMap":115},[16,566,415],{},[417,568,569,578],{},[420,570,571,572,577],{},"compare il nome ",[20,573,574],{},[33,575,576],{},"orders_open_by_tenant_partial"," (o accesso equivalente che lo cita);",[420,579,580,581,585,586,589,590,361],{},"in ",[20,582,583],{},[33,584,450],{},", comparsa di blocchi letti\u002Fhit sull’",[20,587,588],{},"indice"," e heap molto più leggera rispetto alla fase A per la stessa ",[33,591,592],{},"SELECT",[16,594,595,596,599],{},"Il confronto A vs B non è teorico: è ",[20,597,598],{},"la stessa query",", due volte, con solo l’DDL dell’indice in mezzo.",[11,601,603],{"id":602},"controllo-negativo","Controllo negativo",[110,605,607],{"className":112,"code":606,"language":114,"meta":115,"style":115},"EXPLAIN (ANALYZE, BUFFERS)\nSELECT id\nFROM orders\nWHERE tenant_id = 3 AND status = 'closed'\nLIMIT 50;\n",[33,608,609,614,619,623,628],{"__ignoreMap":115},[119,610,611],{"class":121,"line":122},[119,612,613],{},"EXPLAIN (ANALYZE, BUFFERS)\n",[119,615,616],{"class":121,"line":128},[119,617,618],{},"SELECT id\n",[119,620,621],{"class":121,"line":135},[119,622,384],{},[119,624,625],{"class":121,"line":141},[119,626,627],{},"WHERE tenant_id = 3 AND status = 'closed'\n",[119,629,630],{"class":121,"line":147},[119,631,399],{},[16,633,634,635,638,639,642,643,645,646,361],{},"Il predicato non soddisfa ",[33,636,637],{},"WHERE status = 'open'"," dell’indice parziale. ",[20,640,641],{},"Non"," deve comparire ",[33,644,576],{}," come pezzo del piano per filtrare ",[33,647,648],{},"closed",[16,650,651],{},[20,652,653],{},"Esempio (sintetico) — atteso: seq scan o altro indice, ma non il parziale “open”:",[110,655,658],{"className":656,"code":657,"language":410,"meta":115},[408],"Limit  (...)\n  ->  Seq Scan on orders  (...)\n        Filter: ((tenant_id = 3) AND (status = 'closed'::text))\n        Rows Removed by Filter: ...\n        Buffers: ...\n-- nessuna riga \"Bitmap Index Scan on orders_open_by_tenant_partial\"\n",[33,659,657],{"__ignoreMap":115},[16,661,662,663,665],{},"Se vedessi quel nome indicizzato per risolvere ",[33,664,648],{},", è un segnale da investigare (statistiche, predicato, ipotesi sbagliata), non un’ottimizzazione silenziosa.",[11,667,669],{"id":668},"cosa-resta-fuori","Cosa resta fuori",[16,671,672,673,676,677,679,680,682],{},"Niente parallel workers, niente ",[33,674,675],{},"INCLUDE",", niente partizionamento: solo heap + un indice parziale e due famiglie di query (",[33,678,73],{}," vs ",[33,681,648],{},") per isolare l’effetto.",[11,684,686],{"id":685},"chiusura","Chiusura",[16,688,689,690,693,694,698,699,701,702,704,705,707,708,711,712,704,715,718],{},"Gli output in riquadro sono ",[20,691,692],{},"ancore visive",": mostrano ",[695,696,697],"em",{},"dove"," guardare nel testo del piano (",[33,700,340],{}," vs nome indice, ",[33,703,438],{},", ",[33,706,450],{},"). I numeri precisi (tempi, conteggi buffer) li conferma ",[20,709,710],{},"solo"," la tua istanza con gli script sopra. Se dopo l’indice Postgres resta sul seq scan, il materiale operativo è lo stesso che in produzione: allineamento predicato\u002Findice, cast impliciti, freschezza di ",[33,713,714],{},"ANALYZE",[33,716,717],{},"work_mem",", cardinalità stimate vs reali.",[11,720,722],{"id":721},"riferimenti","Riferimenti",[417,724,725,736],{},[420,726,727,732,733],{},[53,728,731],{"href":729,"rel":730},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-createindex.html",[57],"CREATE INDEX"," — clausola ",[33,734,735],{},"WHERE",[420,737,738,743,744,704,746],{},[53,739,741],{"href":55,"rel":740},[57],[33,742,50],{}," — ",[33,745,714],{},[33,747,35],{},[749,750,751],"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":115,"searchDepth":128,"depth":128,"links":753},[754,755,756,757,758,759,760,761,762],{"id":13,"depth":128,"text":14},{"id":66,"depth":128,"text":67},{"id":89,"depth":128,"text":90},{"id":316,"depth":128,"text":317},{"id":475,"depth":128,"text":476},{"id":602,"depth":128,"text":603},{"id":668,"depth":128,"text":669},{"id":685,"depth":128,"text":686},{"id":721,"depth":128,"text":722},"\u002Fblog\u002F32_postgres_partial_index_explain_proof_1x.webp","Dataset deterministico e stessa query prima e dopo un indice parziale: cosa leggere in EXPLAIN ANALYZE, più un controllo negativo sullo stato closed.",false,"md","it",{},"\u002Fit\u002Fblog\u002Fpostgres-partial-index-explain-proof","2026-06-10",{"title":5,"description":764},"Indici parziali Postgres: EXPLAIN ANALYZE","postgres-partial-index-explain-proof","it\u002Fblog\u002Fpostgres-partial-index-explain-proof",[776,777,778],"postgres","performance","mvp","BZI6SKBsuHkfE5CJP6Yfw9fFKi70shR3ZcDKDM4yEtw"]