[{"data":1,"prerenderedAt":3095},["ShallowReactive",2],{"page-\u002Fsql\u002F26-query-optimization":3},{"id":4,"title":5,"body":6,"description":27,"extension":3089,"meta":3090,"navigation":93,"path":3091,"seo":3092,"stem":3093,"__hash__":3094},"content\u002Fsql\u002F26-query-optimization.md","26 — Query Optimization & EXPLAIN",{"type":7,"value":8,"toc":3059},"minimark",[9,13,18,288,315,381,385,392,402,416,421,563,585,589,767,771,779,783,864,872,876,1032,1036,1040,1231,1235,1243,1247,1281,1285,1397,1401,1409,1415,1419,1550,1554,1558,1806,1810,1877,1881,2120,2124,2228,2232,2361,2365,2477,2573,2577,2687,2691,2866,2870,2873,2881,2888,3055],[10,11,5],"h1",{"id":12},"_26-query-optimization-explain",[14,15,17],"h2",{"id":16},"explain-variants-what-each-shows","EXPLAIN Variants — What Each Shows",[19,20,22],"code-wrapper",{"language":21},"sql",[23,24,28],"pre",{"className":25,"code":26,"language":21,"meta":27,"style":27},"language-sql shiki shiki-themes github-light github-dark","-- PLANNER ESTIMATES ONLY — does NOT execute the query\n-- cost = startup..total (arbitrary units, NOT milliseconds)\n-- rows = estimated row count, width = avg bytes per row\nEXPLAIN SELECT * FROM orders WHERE customer_id = 42;\n\n-- EXECUTES the query — shows actual time (ms), actual rows, loops\n-- The gap between estimated rows and actual rows reveals stale statistics\nEXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;\n\n-- ADDS I\u002FO breakdown: shared hit (cached) vs read (disk) vs dirtied vs written\n-- Tells you if the query is I\u002FO-bound (many reads) or CPU-bound (many hits, high time)\nEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;\n\n-- ADDS per-step timing — essential for isolating which node dominates\nEXPLAIN (ANALYZE, BUFFERS, TIMING) SELECT * FROM orders WHERE customer_id = 42;\n\n-- JSON output for programmatic parsing \u002F diffing plans in CI\nEXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM orders WHERE customer_id = 42;\n\n-- VERBOSE adds schema-qualified names and full expression output\nEXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT * FROM orders WHERE customer_id = 42;\n","",[29,30,31,40,46,52,88,95,101,107,131,136,142,148,172,177,183,207,212,218,248,253,259],"code",{"__ignoreMap":27},[32,33,36],"span",{"class":34,"line":35},"line",1,[32,37,39],{"class":38},"sdCPZ","-- PLANNER ESTIMATES ONLY — does NOT execute the query\n",[32,41,43],{"class":34,"line":42},2,[32,44,45],{"class":38},"-- cost = startup..total (arbitrary units, NOT milliseconds)\n",[32,47,49],{"class":34,"line":48},3,[32,50,51],{"class":38},"-- rows = estimated row count, width = avg bytes per row\n",[32,53,55,59,63,66,69,72,75,78,81,85],{"class":34,"line":54},4,[32,56,58],{"class":57},"ssxIu","EXPLAIN ",[32,60,62],{"class":61},"svdQ7","SELECT",[32,64,65],{"class":61}," *",[32,67,68],{"class":61}," FROM",[32,70,71],{"class":57}," orders ",[32,73,74],{"class":61},"WHERE",[32,76,77],{"class":57}," customer_id ",[32,79,80],{"class":61},"=",[32,82,84],{"class":83},"snvgF"," 42",[32,86,87],{"class":57},";\n",[32,89,91],{"class":34,"line":90},5,[32,92,94],{"emptyLinePlaceholder":93},true,"\n",[32,96,98],{"class":34,"line":97},6,[32,99,100],{"class":38},"-- EXECUTES the query — shows actual time (ms), actual rows, loops\n",[32,102,104],{"class":34,"line":103},7,[32,105,106],{"class":38},"-- The gap between estimated rows and actual rows reveals stale statistics\n",[32,108,110,113,115,117,119,121,123,125,127,129],{"class":34,"line":109},8,[32,111,112],{"class":57},"EXPLAIN ANALYZE ",[32,114,62],{"class":61},[32,116,65],{"class":61},[32,118,68],{"class":61},[32,120,71],{"class":57},[32,122,74],{"class":61},[32,124,77],{"class":57},[32,126,80],{"class":61},[32,128,84],{"class":83},[32,130,87],{"class":57},[32,132,134],{"class":34,"line":133},9,[32,135,94],{"emptyLinePlaceholder":93},[32,137,139],{"class":34,"line":138},10,[32,140,141],{"class":38},"-- ADDS I\u002FO breakdown: shared hit (cached) vs read (disk) vs dirtied vs written\n",[32,143,145],{"class":34,"line":144},11,[32,146,147],{"class":38},"-- Tells you if the query is I\u002FO-bound (many reads) or CPU-bound (many hits, high time)\n",[32,149,151,154,156,158,160,162,164,166,168,170],{"class":34,"line":150},12,[32,152,153],{"class":57},"EXPLAIN (ANALYZE, BUFFERS) ",[32,155,62],{"class":61},[32,157,65],{"class":61},[32,159,68],{"class":61},[32,161,71],{"class":57},[32,163,74],{"class":61},[32,165,77],{"class":57},[32,167,80],{"class":61},[32,169,84],{"class":83},[32,171,87],{"class":57},[32,173,175],{"class":34,"line":174},13,[32,176,94],{"emptyLinePlaceholder":93},[32,178,180],{"class":34,"line":179},14,[32,181,182],{"class":38},"-- ADDS per-step timing — essential for isolating which node dominates\n",[32,184,186,189,191,193,195,197,199,201,203,205],{"class":34,"line":185},15,[32,187,188],{"class":57},"EXPLAIN (ANALYZE, BUFFERS, TIMING) ",[32,190,62],{"class":61},[32,192,65],{"class":61},[32,194,68],{"class":61},[32,196,71],{"class":57},[32,198,74],{"class":61},[32,200,77],{"class":57},[32,202,80],{"class":61},[32,204,84],{"class":83},[32,206,87],{"class":57},[32,208,210],{"class":34,"line":209},16,[32,211,94],{"emptyLinePlaceholder":93},[32,213,215],{"class":34,"line":214},17,[32,216,217],{"class":38},"-- JSON output for programmatic parsing \u002F diffing plans in CI\n",[32,219,221,224,227,230,232,234,236,238,240,242,244,246],{"class":34,"line":220},18,[32,222,223],{"class":57},"EXPLAIN (ANALYZE, BUFFERS, FORMAT ",[32,225,226],{"class":61},"JSON",[32,228,229],{"class":57},") ",[32,231,62],{"class":61},[32,233,65],{"class":61},[32,235,68],{"class":61},[32,237,71],{"class":57},[32,239,74],{"class":61},[32,241,77],{"class":57},[32,243,80],{"class":61},[32,245,84],{"class":83},[32,247,87],{"class":57},[32,249,251],{"class":34,"line":250},19,[32,252,94],{"emptyLinePlaceholder":93},[32,254,256],{"class":34,"line":255},20,[32,257,258],{"class":38},"-- VERBOSE adds schema-qualified names and full expression output\n",[32,260,262,265,268,270,272,274,276,278,280,282,284,286],{"class":34,"line":261},21,[32,263,264],{"class":57},"EXPLAIN (ANALYZE, BUFFERS, ",[32,266,267],{"class":61},"VERBOSE",[32,269,229],{"class":57},[32,271,62],{"class":61},[32,273,65],{"class":61},[32,275,68],{"class":61},[32,277,71],{"class":57},[32,279,74],{"class":61},[32,281,77],{"class":57},[32,283,80],{"class":61},[32,285,84],{"class":83},[32,287,87],{"class":57},[289,290,291,292,295,296,300,301,304,305,304,308,304,311,314],"p",{},"⚠️ ",[29,293,294],{},"EXPLAIN ANALYZE"," ",[297,298,299],"strong",{},"executes"," the query. For ",[29,302,303],{},"INSERT","\u002F",[29,306,307],{},"UPDATE",[29,309,310],{},"DELETE",[29,312,313],{},"TRUNCATE",", this modifies data. Always wrap DML in a transaction and roll back:",[19,316,317],{"language":21},[23,318,320],{"className":25,"code":319,"language":21,"meta":27,"style":27},"BEGIN;\nEXPLAIN ANALYZE UPDATE orders SET amount = amount * 1.1 WHERE customer_id = 42;\nROLLBACK;  -- revert the update; the plan + timing are still displayed\n",[29,321,322,329,370],{"__ignoreMap":27},[32,323,324,327],{"class":34,"line":35},[32,325,326],{"class":61},"BEGIN",[32,328,87],{"class":57},[32,330,331,333,335,337,340,343,345,347,350,353,356,359,362,364,366,368],{"class":34,"line":42},[32,332,112],{"class":57},[32,334,307],{"class":61},[32,336,71],{"class":57},[32,338,339],{"class":61},"SET",[32,341,342],{"class":57}," amount ",[32,344,80],{"class":61},[32,346,342],{"class":57},[32,348,349],{"class":61},"*",[32,351,352],{"class":83}," 1",[32,354,355],{"class":57},".",[32,357,358],{"class":83},"1",[32,360,361],{"class":61}," WHERE",[32,363,77],{"class":57},[32,365,80],{"class":61},[32,367,84],{"class":83},[32,369,87],{"class":57},[32,371,372,375,378],{"class":34,"line":48},[32,373,374],{"class":61},"ROLLBACK",[32,376,377],{"class":57},";  ",[32,379,380],{"class":38},"-- revert the update; the plan + timing are still displayed\n",[14,382,384],{"id":383},"reading-a-plan-tree-inside-out","Reading a Plan Tree — Inside-Out",[289,386,387,388,391],{},"A plan is a tree of nodes. Indentation = parent-child. The innermost (most-indented) nodes execute first; their output feeds the parent. Read ",[297,389,390],{},"inside-out, bottom-up",":",[19,393,395],{"language":394},"text",[23,396,400],{"className":397,"code":399,"language":394,"meta":27},[398],"language-text","EXPLAIN SELECT c.name, o.amount\nFROM customers c\nJOIN orders o ON c.id = o.customer_id\nWHERE c.city = 'NYC';\n\n                                 QUERY PLAN\n──────────────────────────────────────────────────────────────────\n Hash Join                           -- ④ final join: probe orders with customer hash\n   Hash Cond: (o.customer_id = c.id)\n   ->  Seq Scan on orders o          -- ③ full scan of orders (no filter — all rows)\n   ->  Hash                          -- ② build hash table from filtered customers\n         ->  Seq Scan on customers c  -- ① scan customers, filter city = 'NYC'\n               Filter: (city = 'NYC')\n",[29,401,399],{"__ignoreMap":27},[289,403,404,405,408,409,412,413,355],{},"Execution order: ① → ② → ③ → ④. Each node reports ",[29,406,407],{},"(cost=startup..total rows=N width=W)",". With ",[29,410,411],{},"ANALYZE",", it also reports ",[29,414,415],{},"(actual time=T1..T2 rows=R loops=L)",[417,418,420],"h3",{"id":419},"cost-model-decoded","Cost Model Decoded",[422,423,424,440],"table",{},[425,426,427],"thead",{},[428,429,430,434,437],"tr",{},[431,432,433],"th",{},"Field",[431,435,436],{},"Meaning",[431,438,439],{},"Source",[441,442,443,461,478,498,514,529,546],"tbody",{},[428,444,445,451,458],{},[446,447,448],"td",{},[29,449,450],{},"startup",[446,452,453,454,457],{},"Cost to produce the ",[297,455,456],{},"first"," row",[446,459,460],{},"Index scans: low. Sorts: high (must sort all before first row).",[428,462,463,468,475],{},[446,464,465],{},[29,466,467],{},"total",[446,469,470,471,474],{},"Cost to produce ",[297,472,473],{},"all"," rows",[446,476,477],{},"Includes startup + per-row costs.",[428,479,480,485,491],{},[446,481,482],{},[29,483,484],{},"rows",[446,486,487,490],{},[297,488,489],{},"Estimated"," output row count",[446,492,493,494,497],{},"From ",[29,495,496],{},"pg_class.reltuples"," × selectivity estimate.",[428,499,500,505,508],{},[446,501,502],{},[29,503,504],{},"width",[446,506,507],{},"Avg row width in bytes",[446,509,493,510,513],{},[29,511,512],{},"pg_stats"," histogram bounds.",[428,515,516,521,524],{},[446,517,518],{},[29,519,520],{},"actual time",[446,522,523],{},"Wall-clock ms to first \u002F all rows",[446,525,526,527,355],{},"Only with ",[29,528,411],{},[428,530,531,536,539],{},[446,532,533],{},[29,534,535],{},"actual rows",[446,537,538],{},"Real output row count",[446,540,526,541,543,544,355],{},[29,542,411],{},". Compare to estimated ",[29,545,484],{},[428,547,548,553,556],{},[446,549,550],{},[29,551,552],{},"loops",[446,554,555],{},"How many times this node was invoked",[446,557,558,559,562],{},"Nested loop inner: loops = outer row count. Multiply ",[29,560,561],{},"time × loops"," for total.",[289,564,565,566,569,570,573,574,573,577,580,581,584],{},"Cost units are ",[297,567,568],{},"arbitrary"," (not ms) — derived from ",[29,571,572],{},"seq_page_cost",", ",[29,575,576],{},"random_page_cost",[29,578,579],{},"cpu_tuple_cost",". They're only meaningful for ",[297,582,583],{},"relative"," comparison between plan alternatives, never as an absolute time estimate.",[14,586,588],{"id":587},"node-types-scans","Node Types — Scans",[422,590,591,607],{},[425,592,593],{},[428,594,595,598,601,604],{},[431,596,597],{},"Node",[431,599,600],{},"When Chosen",[431,602,603],{},"I\u002FO Pattern",[431,605,606],{},"Red Flags",[441,608,609,625,645,667,690,709,733,749],{},[428,610,611,616,619,622],{},[446,612,613],{},[297,614,615],{},"Seq Scan",[446,617,618],{},"Small table, or most rows match, or no index",[446,620,621],{},"Sequential (cheap per page)",[446,623,624],{},"On a large table with a selective predicate = missing index",[428,626,627,632,635,638],{},[446,628,629],{},[297,630,631],{},"Index Scan",[446,633,634],{},"Selective predicate on an indexed column",[446,636,637],{},"Random I\u002FO (index + heap fetch)",[446,639,640,641,644],{},"High ",[29,642,643],{},"Heap Fetches"," count = consider covering index",[428,646,647,652,655,658],{},[446,648,649],{},[297,650,651],{},"Index Only Scan",[446,653,654],{},"All needed columns are in the index + visibility map is fresh",[446,656,657],{},"Index-only (no heap access)",[446,659,660,663,664],{},[29,661,662],{},"Heap Fetches: N"," > 0 = visibility map stale, needs ",[29,665,666],{},"VACUUM",[428,668,669,674,677,680],{},[446,670,671],{},[297,672,673],{},"Bitmap Index Scan → Bitmap Heap Scan",[446,675,676],{},"Medium selectivity (many matching rows but not all)",[446,678,679],{},"Batched random I\u002FO",[446,681,682,685,686,689],{},[29,683,684],{},"Exact"," vs ",[29,687,688],{},"Lossy"," bitmap — lossy means recheck per heap row",[428,691,692,697,703,706],{},[446,693,694],{},[297,695,696],{},"Tid Scan",[446,698,699,702],{},[29,700,701],{},"WHERE ctid = '(0,1)'"," — physical row location",[446,704,705],{},"Direct page access",[446,707,708],{},"Rare; only for CTID-based access",[428,710,711,716,723,726],{},[446,712,713],{},[297,714,715],{},"Function Scan",[446,717,718,719,722],{},"Set-returning function (",[29,720,721],{},"generate_series",", etc.)",[446,724,725],{},"N\u002FA",[446,727,728,729,732],{},"Always estimate 1000 rows unless ",[29,730,731],{},"ROWS"," specified",[428,734,735,740,743,746],{},[446,736,737],{},[297,738,739],{},"Subquery Scan",[446,741,742],{},"Wraps a subquery \u002F CTE result",[446,744,745],{},"Depends on child",[446,747,748],{},"Often a no-op wrapper; check the child node",[428,750,751,756,761,764],{},[446,752,753],{},[297,754,755],{},"Sample Scan",[446,757,758],{},[29,759,760],{},"TABLESAMPLE",[446,762,763],{},"Random subset",[446,765,766],{},"Used for approximations",[417,768,770],{"id":769},"bitmap-scan-vs-index-scan-the-planners-choice","Bitmap Scan vs Index Scan — The Planner's Choice",[19,772,773],{"language":394},[23,774,777],{"className":775,"code":776,"language":394,"meta":27},[398],"-- Index Scan: for each matching tuple, fetch the heap page directly\n--   O(matching_rows × random_page_cost)\n--   Best when matching_rows is small (high selectivity)\n\n-- Bitmap Index Scan: build a bitmap of matching tuple locations, then\n--   batch-fetch heap pages in physical order (sorted by page number)\n--   O(matching_pages × seq_page_cost) — amortizes random I\u002FO\n--   Best when matching_rows is moderate (many per page)\n\n-- If the bitmap exceeds work_mem × 8 (in pages), it becomes LOSSY:\n--   only page-level granularity is kept → must recheck the condition\n--   per heap row. Raise work_mem to avoid this.\n",[29,778,776],{"__ignoreMap":27},[14,780,782],{"id":781},"node-types-joins","Node Types — Joins",[422,784,785,803],{},[425,786,787],{},[428,788,789,791,794,797,800],{},[431,790,597],{},[431,792,793],{},"Algorithm",[431,795,796],{},"Best When",[431,798,799],{},"Cost",[431,801,802],{},"Memory",[441,804,805,824,846],{},[428,806,807,812,815,818,821],{},[446,808,809],{},[297,810,811],{},"Nested Loop",[446,813,814],{},"For each outer row, scan\u002Flookup inner",[446,816,817],{},"One side is small (\u003C ~100 rows) or inner has an index",[446,819,820],{},"O(N × M) or O(N × log M) with index",[446,822,823],{},"None",[428,825,826,831,834,837,840],{},[446,827,828],{},[297,829,830],{},"Hash Join",[446,832,833],{},"Build hash on smaller side, probe with larger",[446,835,836],{},"Both sides large, equality join, no index",[446,838,839],{},"O(N + M)",[446,841,842,845],{},[29,843,844],{},"work_mem"," (spills on overflow)",[428,847,848,853,856,859,861],{},[446,849,850],{},[297,851,852],{},"Merge Join",[446,854,855],{},"Both inputs pre-sorted on join key, merge",[446,857,858],{},"Both sorted (via index or explicit sort)",[446,860,839],{},[446,862,863],{},"None (but may need Sort inputs)",[19,865,866],{"language":394},[23,867,870],{"className":868,"code":869,"language":394,"meta":27},[398],"-- Nested Loop blowup: if the planner estimates 1 outer row but\n-- actual is 10000, the inner is executed 10000 times. If the\n-- inner is a Seq Scan, that's 10000 full table scans:\n\nNested Loop  (rows=1) (actual time=0.1..98000 rows=10000 loops=1)\n  -> Index Scan on users  (rows=1) (actual rows=10000)   -- estimate: 1, actual: 10000\n  -> Seq Scan on orders   (rows=1) (actual rows=500 loops=10000)  -- 10000 × full scan\n         Filter: (customer_id = users.id)\n         Rows Removed by Filter: 999995   -- discards nearly the entire table each loop\n\n-- Total inner work: 10000 × 1000000 = 10 billion row examinations\n-- Fix: CREATE INDEX ON orders(customer_id) → inner becomes Index Scan, O(log N + matches)\n-- AND: ANALYZE users → fix the estimate so the planner picks Hash Join instead\n",[29,871,869],{"__ignoreMap":27},[14,873,875],{"id":874},"node-types-other","Node Types — Other",[422,877,878,886],{},[425,879,880],{},[428,881,882,884],{},[431,883,597],{},[431,885,436],{},[441,887,888,908,921,931,941,959,973,983,997,1014],{},[428,889,890,895],{},[446,891,892],{},[297,893,894],{},"Sort",[446,896,897,898,685,901,904,905,907],{},"Explicit in-memory or external-merge sort. ",[29,899,900],{},"Sort Method: quicksort Memory: 25kB",[29,902,903],{},"external merge Disk: 50000kB"," (spilled — raise ",[29,906,844],{},").",[428,909,910,915],{},[446,911,912],{},[297,913,914],{},"Hash Aggregate",[446,916,917,918,920],{},"Group\u002Faggregate via hash table. Spills to disk if exceeds ",[29,919,844],{},". PostgreSQL 13+ has disk-based hash aggregation.",[428,922,923,928],{},[446,924,925],{},[297,926,927],{},"Group Aggregate",[446,929,930],{},"Group\u002Faggregate after a sort. Used when input is already sorted or hash would spill.",[428,932,933,938],{},[446,934,935],{},[297,936,937],{},"Limit",[446,939,940],{},"Stops after N rows. With an index on the sort key, enables fast top-N without sorting.",[428,942,943,948],{},[446,944,945],{},[297,946,947],{},"Gather \u002F Gather Merge",[446,949,950,951,954,955,958],{},"Parallel query — workers scan partitions. ",[29,952,953],{},"Gather"," merges unordered, ",[29,956,957],{},"Gather Merge"," merges pre-sorted.",[428,960,961,966],{},[446,962,963],{},[297,964,965],{},"Append",[446,967,968,969,972],{},"Combines child results (",[29,970,971],{},"UNION ALL",", partition scanning).",[428,974,975,980],{},[446,976,977],{},[297,978,979],{},"Materialize",[446,981,982],{},"Caches a subquery's output for repeated reads by a Nested Loop.",[428,984,985,990],{},[446,986,987],{},[297,988,989],{},"CTE Scan",[446,991,992,993,996],{},"Reads a CTE. If ",[29,994,995],{},"NOT MATERIALIZED"," (default in PG12+ for non-recursive CTEs), the planner may inline it.",[428,998,999,1004],{},[446,1000,1001],{},[297,1002,1003],{},"Unique",[446,1005,1006,1007,573,1010,1013],{},"Removes duplicates (from ",[29,1008,1009],{},"DISTINCT",[29,1011,1012],{},"EXCEPT","). Usually via sort + dedup.",[428,1015,1016,1021],{},[446,1017,1018],{},[297,1019,1020],{},"WindowAgg",[446,1022,1023,1024,1027,1028,1031],{},"Computes window functions. Often requires a Sort on the ",[29,1025,1026],{},"PARTITION BY"," + ",[29,1029,1030],{},"ORDER BY"," columns first.",[14,1033,1035],{"id":1034},"complex-example-slow-query-diagnosis","Complex Example — Slow Query Diagnosis",[417,1037,1039],{"id":1038},"the-problem-query","The Problem Query",[19,1041,1042],{"language":21},[23,1043,1045],{"className":25,"code":1044,"language":21,"meta":27,"style":27},"-- \"Find the top 10 customers by total spend in Q1 2024, with their email\"\nSELECT c.id, c.email, c.name, SUM(o.amount) AS total_spend\nFROM customers c\nJOIN orders o ON c.id = o.customer_id\nWHERE o.ordered_on >= '2024-01-01'\n  AND o.ordered_on \u003C  '2024-04-01'\nGROUP BY c.id, c.email, c.name\nORDER BY total_spend DESC\nLIMIT 10;\n-- Runtime: 45 seconds on 50M orders\n",[29,1046,1047,1052,1107,1115,1143,1161,1178,1206,1216,1226],{"__ignoreMap":27},[32,1048,1049],{"class":34,"line":35},[32,1050,1051],{"class":38},"-- \"Find the top 10 customers by total spend in Q1 2024, with their email\"\n",[32,1053,1054,1056,1059,1061,1064,1066,1069,1071,1074,1076,1078,1080,1083,1085,1088,1091,1094,1096,1099,1101,1104],{"class":34,"line":42},[32,1055,62],{"class":61},[32,1057,1058],{"class":83}," c",[32,1060,355],{"class":57},[32,1062,1063],{"class":83},"id",[32,1065,573],{"class":57},[32,1067,1068],{"class":83},"c",[32,1070,355],{"class":57},[32,1072,1073],{"class":83},"email",[32,1075,573],{"class":57},[32,1077,1068],{"class":83},[32,1079,355],{"class":57},[32,1081,1082],{"class":83},"name",[32,1084,573],{"class":57},[32,1086,1087],{"class":83},"SUM",[32,1089,1090],{"class":57},"(",[32,1092,1093],{"class":83},"o",[32,1095,355],{"class":57},[32,1097,1098],{"class":83},"amount",[32,1100,229],{"class":57},[32,1102,1103],{"class":61},"AS",[32,1105,1106],{"class":57}," total_spend\n",[32,1108,1109,1112],{"class":34,"line":48},[32,1110,1111],{"class":61},"FROM",[32,1113,1114],{"class":57}," customers c\n",[32,1116,1117,1120,1123,1126,1128,1130,1132,1135,1138,1140],{"class":34,"line":54},[32,1118,1119],{"class":61},"JOIN",[32,1121,1122],{"class":57}," orders o ",[32,1124,1125],{"class":61},"ON",[32,1127,1058],{"class":83},[32,1129,355],{"class":57},[32,1131,1063],{"class":83},[32,1133,1134],{"class":61}," =",[32,1136,1137],{"class":83}," o",[32,1139,355],{"class":57},[32,1141,1142],{"class":83},"customer_id\n",[32,1144,1145,1147,1149,1151,1154,1157],{"class":34,"line":90},[32,1146,74],{"class":61},[32,1148,1137],{"class":83},[32,1150,355],{"class":57},[32,1152,1153],{"class":83},"ordered_on",[32,1155,1156],{"class":61}," >=",[32,1158,1160],{"class":1159},"sJ6F3"," '2024-01-01'\n",[32,1162,1163,1166,1168,1170,1172,1175],{"class":34,"line":97},[32,1164,1165],{"class":61},"  AND",[32,1167,1137],{"class":83},[32,1169,355],{"class":57},[32,1171,1153],{"class":83},[32,1173,1174],{"class":61}," \u003C",[32,1176,1177],{"class":1159},"  '2024-04-01'\n",[32,1179,1180,1183,1185,1187,1189,1191,1193,1195,1197,1199,1201,1203],{"class":34,"line":103},[32,1181,1182],{"class":61},"GROUP BY",[32,1184,1058],{"class":83},[32,1186,355],{"class":57},[32,1188,1063],{"class":83},[32,1190,573],{"class":57},[32,1192,1068],{"class":83},[32,1194,355],{"class":57},[32,1196,1073],{"class":83},[32,1198,573],{"class":57},[32,1200,1068],{"class":83},[32,1202,355],{"class":57},[32,1204,1205],{"class":83},"name\n",[32,1207,1208,1210,1213],{"class":34,"line":109},[32,1209,1030],{"class":61},[32,1211,1212],{"class":57}," total_spend ",[32,1214,1215],{"class":61},"DESC\n",[32,1217,1218,1221,1224],{"class":34,"line":133},[32,1219,1220],{"class":61},"LIMIT",[32,1222,1223],{"class":83}," 10",[32,1225,87],{"class":57},[32,1227,1228],{"class":34,"line":138},[32,1229,1230],{"class":38},"-- Runtime: 45 seconds on 50M orders\n",[417,1232,1234],{"id":1233},"step-1-diagnose-with-explain-analyze-buffers","Step 1 — Diagnose with EXPLAIN (ANALYZE, BUFFERS)",[19,1236,1237],{"language":394},[23,1238,1241],{"className":1239,"code":1240,"language":394,"meta":27},[398],"EXPLAIN (ANALYZE, BUFFERS) [query above];\n\n Limit  (cost=3987521.20..3987521.22 rows=10 width=48) (actual time=45210.3..45210.5 rows=10 loops=1)\n   Buffers: shared hit=512 read=984321\n   ->  GroupAggregate  (cost=3987521.20..4012345.67 rows=1000000 width=48) (actual time=45210.3..45210.4 rows=10 loops=1)\n         Group Key: c.id, c.email, c.name\n         Buffers: shared hit=512 read=984321\n         ->  Sort  (cost=3987521.20..3995678.90 rows=3263072 width=48) (actual time=45100.2..45150.8 rows=3263072 loops=1)\n               Sort Key: c.id\n               Sort Method: external merge  Disk: 198456kB     -- ← spilled to disk!\n               Buffers: shared hit=8 read=984321\n               ->  Hash Join  (cost=15432.00..3789001.20 rows=3263072 width=48) (actual time=12.3..44000.5 rows=3263072 loops=1)\n                     Hash Cond: (o.customer_id = c.id)\n                     Buffers: shared hit=4 read=984321\n                     ->  Seq Scan on orders o  (cost=0.00..3765000.00 rows=3263072 width=12) (actual time=0.1..42000.0 rows=3263072 loops=1)\n                           Filter: ((ordered_on >= '2024-01-01') AND (ordered_on \u003C '2024-04-01'))\n                           Rows Removed by Filter: 46736928\n                           Buffers: shared read=984321            -- ← 984321 pages read from disk!\n                     ->  Hash  (cost=12345.00..12345.00 rows=100000 width=36) (actual time=11.0..11.0 rows=100000 loops=1)\n                           Buckets: 131072  Batches: 1  Memory Usage: 8234kB\n                           Buffers: shared hit=4\n                           ->  Seq Scan on customers c  (cost=0.00..12345.00 rows=100000 width=36) (actual time=0.1..10.0 rows=100000 loops=1)\n",[29,1242,1240],{"__ignoreMap":27},[417,1244,1246],{"id":1245},"reading-the-plan","Reading the Plan",[1248,1249,1250,1263,1275],"ol",{},[1251,1252,1253,1256,1257,1260,1261,355],"li",{},[297,1254,1255],{},"Seq Scan on orders"," — full table scan, 50M rows, discarding 46.7M. ",[29,1258,1259],{},"read=984321"," pages = ~7.7 GB read from disk. No index on ",[29,1262,1153],{},[1251,1264,1265,1268,1269,1268,1272,1274],{},[297,1266,1267],{},"Sort spilled to disk"," — ",[29,1270,1271],{},"external merge Disk: 198456kB",[29,1273,844],{}," too low for 3.2M rows.",[1251,1276,1277,1280],{},[297,1278,1279],{},"GroupAggregate after Sort"," — the sort + aggregate dominates the 45s.",[417,1282,1284],{"id":1283},"step-2-the-fixes","Step 2 — The Fixes",[19,1286,1287],{"language":21},[23,1288,1290],{"className":25,"code":1289,"language":21,"meta":27,"style":27},"-- FIX 1: Add a partial index on the date range (only Q1 2024 orders)\n-- This turns the 50M-row seq scan into a targeted index scan\nCREATE INDEX idx_orders_q1_2024 ON orders(customer_id, ordered_on)\n  INCLUDE (amount)\n  WHERE ordered_on >= '2024-01-01' AND ordered_on \u003C '2024-04-01';\n\n-- FIX 2: Raise work_mem for this session to avoid the sort spill\nSET work_mem = '256MB';\n\n-- FIX 3: Rewrite using a covering index approach\n-- The INCLUDE (amount) makes the index a covering index for this query\n-- Now the planner can do an Index Only Scan — no heap fetch needed\n",[29,1291,1292,1297,1302,1320,1328,1355,1359,1364,1378,1382,1387,1392],{"__ignoreMap":27},[32,1293,1294],{"class":34,"line":35},[32,1295,1296],{"class":38},"-- FIX 1: Add a partial index on the date range (only Q1 2024 orders)\n",[32,1298,1299],{"class":34,"line":42},[32,1300,1301],{"class":38},"-- This turns the 50M-row seq scan into a targeted index scan\n",[32,1303,1304,1307,1310,1314,1317],{"class":34,"line":48},[32,1305,1306],{"class":61},"CREATE",[32,1308,1309],{"class":61}," INDEX",[32,1311,1313],{"class":1312},"sIsaT"," idx_orders_q1_2024",[32,1315,1316],{"class":61}," ON",[32,1318,1319],{"class":57}," orders(customer_id, ordered_on)\n",[32,1321,1322,1325],{"class":34,"line":54},[32,1323,1324],{"class":61},"  INCLUDE",[32,1326,1327],{"class":57}," (amount)\n",[32,1329,1330,1333,1336,1339,1342,1345,1347,1350,1353],{"class":34,"line":90},[32,1331,1332],{"class":61},"  WHERE",[32,1334,1335],{"class":57}," ordered_on ",[32,1337,1338],{"class":61},">=",[32,1340,1341],{"class":1159}," '2024-01-01'",[32,1343,1344],{"class":61}," AND",[32,1346,1335],{"class":57},[32,1348,1349],{"class":61},"\u003C",[32,1351,1352],{"class":1159}," '2024-04-01'",[32,1354,87],{"class":57},[32,1356,1357],{"class":34,"line":97},[32,1358,94],{"emptyLinePlaceholder":93},[32,1360,1361],{"class":34,"line":103},[32,1362,1363],{"class":38},"-- FIX 2: Raise work_mem for this session to avoid the sort spill\n",[32,1365,1366,1368,1371,1373,1376],{"class":34,"line":109},[32,1367,339],{"class":61},[32,1369,1370],{"class":57}," work_mem ",[32,1372,80],{"class":61},[32,1374,1375],{"class":1159}," '256MB'",[32,1377,87],{"class":57},[32,1379,1380],{"class":34,"line":133},[32,1381,94],{"emptyLinePlaceholder":93},[32,1383,1384],{"class":34,"line":138},[32,1385,1386],{"class":38},"-- FIX 3: Rewrite using a covering index approach\n",[32,1388,1389],{"class":34,"line":144},[32,1390,1391],{"class":38},"-- The INCLUDE (amount) makes the index a covering index for this query\n",[32,1393,1394],{"class":34,"line":150},[32,1395,1396],{"class":38},"-- Now the planner can do an Index Only Scan — no heap fetch needed\n",[417,1398,1400],{"id":1399},"step-3-after-the-fixed-plan","Step 3 — After: The Fixed Plan",[19,1402,1403],{"language":394},[23,1404,1407],{"className":1405,"code":1406,"language":394,"meta":27},[398],"EXPLAIN (ANALYZE, BUFFERS) [query above with SET work_mem = '256MB'];\n\n Limit  (cost=50234.12..50234.14 rows=10 width=48) (actual time=340.5..340.7 rows=10 loops=1)\n   Buffers: shared hit=15234 read=0       -- ← all cache hits, 0 disk reads!\n   ->  GroupAggregate  (cost=50234.12..50456.78 rows=100000 width=48) (actual time=340.5..340.6 rows=10 loops=1)\n         Group Key: c.id, c.email, c.name\n         Buffers: shared hit=15234\n         ->  Sort  (cost=50234.12..50289.45 rows=22130 width=48) (actual time=320.1..330.2 rows=22130 loops=1)\n               Sort Key: c.id\n               Sort Method: quicksort  Memory: 2845kB    -- ← in-memory now!\n               Buffers: shared hit=15234\n               ->  Hash Join  (cost=15432.00..49001.20 rows=22130 width=48) (actual time=12.3..310.0 rows=22130 loops=1)\n                     Hash Cond: (o.customer_id = c.id)\n                     Buffers: shared hit=15234\n                     ->  Index Only Scan using idx_orders_q1_2024 on orders o  (cost=0.42..32000.00 rows=22130 width=12) (actual time=0.2..280.0 rows=22130 loops=1)\n                           Index Cond: (ordered_on >= '2024-01-01' AND (ordered_on \u003C '2024-04-01')\n                           Heap Fetches: 0                    -- ← covering index, zero heap access\n                           Buffers: shared hit=15230\n                     ->  Hash  (cost=12345.00..12345.00 rows=100000 width=36) (actual time=11.0..11.0 rows=100000 loops=1)\n                           ->  Seq Scan on customers c  (cost=0.00..12345.00 rows=100000 width=36) (actual time=0.1..10.0 rows=100000 loops=1)\n",[29,1408,1406],{"__ignoreMap":27},[289,1410,1411,1414],{},[297,1412,1413],{},"Result: 45s → 0.34s (132× faster)."," Disk reads: 984321 → 0. Sort: external 198MB disk → 2.8MB memory.",[14,1416,1418],{"id":1417},"anti-pattern-optimizing-without-measuring","Anti-Pattern — Optimizing Without Measuring",[19,1420,1421],{"language":21},[23,1422,1424],{"className":25,"code":1423,"language":21,"meta":27,"style":27},"-- ❌ WRONG: Adding indexes blindly \"because it might help\"\nCREATE INDEX idx_orders_amount ON orders(amount);\nCREATE INDEX idx_orders_amount_customer ON orders(amount, customer_id);\nCREATE INDEX idx_orders_amount_date ON orders(amount, ordered_on);\n-- None of these help the actual query pattern. Each one slows down\n-- every INSERT\u002FUPDATE\u002FDELETE on orders and wastes disk + cache.\n-- You've made writes slower for zero read benefit.\n\n-- ✅ RIGHT: Measure first, index the actual bottleneck\nEXPLAIN (ANALYZE, BUFFERS) SELECT ...;  -- see which node dominates\n-- Identify: Seq Scan on a 50M-row table with a selective filter\n-- Then add the ONE index that turns it into an Index Scan\nCREATE INDEX idx_orders_customer_date ON orders(customer_id, ordered_on) INCLUDE (amount);\nEXPLAIN (ANALYZE, BUFFERS) SELECT ...;  -- verify the improvement\n",[29,1425,1426,1431,1445,1459,1473,1478,1483,1488,1492,1497,1509,1514,1519,1539],{"__ignoreMap":27},[32,1427,1428],{"class":34,"line":35},[32,1429,1430],{"class":38},"-- ❌ WRONG: Adding indexes blindly \"because it might help\"\n",[32,1432,1433,1435,1437,1440,1442],{"class":34,"line":42},[32,1434,1306],{"class":61},[32,1436,1309],{"class":61},[32,1438,1439],{"class":1312}," idx_orders_amount",[32,1441,1316],{"class":61},[32,1443,1444],{"class":57}," orders(amount);\n",[32,1446,1447,1449,1451,1454,1456],{"class":34,"line":48},[32,1448,1306],{"class":61},[32,1450,1309],{"class":61},[32,1452,1453],{"class":1312}," idx_orders_amount_customer",[32,1455,1316],{"class":61},[32,1457,1458],{"class":57}," orders(amount, customer_id);\n",[32,1460,1461,1463,1465,1468,1470],{"class":34,"line":54},[32,1462,1306],{"class":61},[32,1464,1309],{"class":61},[32,1466,1467],{"class":1312}," idx_orders_amount_date",[32,1469,1316],{"class":61},[32,1471,1472],{"class":57}," orders(amount, ordered_on);\n",[32,1474,1475],{"class":34,"line":90},[32,1476,1477],{"class":38},"-- None of these help the actual query pattern. Each one slows down\n",[32,1479,1480],{"class":34,"line":97},[32,1481,1482],{"class":38},"-- every INSERT\u002FUPDATE\u002FDELETE on orders and wastes disk + cache.\n",[32,1484,1485],{"class":34,"line":103},[32,1486,1487],{"class":38},"-- You've made writes slower for zero read benefit.\n",[32,1489,1490],{"class":34,"line":109},[32,1491,94],{"emptyLinePlaceholder":93},[32,1493,1494],{"class":34,"line":133},[32,1495,1496],{"class":38},"-- ✅ RIGHT: Measure first, index the actual bottleneck\n",[32,1498,1499,1501,1503,1506],{"class":34,"line":138},[32,1500,153],{"class":57},[32,1502,62],{"class":61},[32,1504,1505],{"class":57}," ...;  ",[32,1507,1508],{"class":38},"-- see which node dominates\n",[32,1510,1511],{"class":34,"line":144},[32,1512,1513],{"class":38},"-- Identify: Seq Scan on a 50M-row table with a selective filter\n",[32,1515,1516],{"class":34,"line":150},[32,1517,1518],{"class":38},"-- Then add the ONE index that turns it into an Index Scan\n",[32,1520,1521,1523,1525,1528,1530,1533,1536],{"class":34,"line":174},[32,1522,1306],{"class":61},[32,1524,1309],{"class":61},[32,1526,1527],{"class":1312}," idx_orders_customer_date",[32,1529,1316],{"class":61},[32,1531,1532],{"class":57}," orders(customer_id, ordered_on) ",[32,1534,1535],{"class":61},"INCLUDE",[32,1537,1538],{"class":57}," (amount);\n",[32,1540,1541,1543,1545,1547],{"class":34,"line":179},[32,1542,153],{"class":57},[32,1544,62],{"class":61},[32,1546,1505],{"class":57},[32,1548,1549],{"class":38},"-- verify the improvement\n",[14,1551,1553],{"id":1552},"common-optimization-patterns","Common Optimization Patterns",[417,1555,1557],{"id":1556},"push-predicates-filter-early","Push Predicates — Filter Early",[19,1559,1560],{"language":21},[23,1561,1563],{"className":25,"code":1562,"language":21,"meta":27,"style":27},"-- ❌ WRONG: join everything, filter at the end\nWITH all_orders AS (\n  SELECT c.id, c.email, o.amount, o.ordered_on\n  FROM customers c JOIN orders o ON c.id = o.customer_id\n)\nSELECT id, email, SUM(amount) AS total\nFROM all_orders\nWHERE ordered_on >= '2024-01-01'  -- applied AFTER the full join\nGROUP BY id, email;\n\n-- ✅ RIGHT: push the predicate into the join — filter orders first\nSELECT c.id, c.email, SUM(o.amount) AS total\nFROM customers c\nJOIN orders o ON c.id = o.customer_id\n  AND o.ordered_on >= '2024-01-01'  -- filter before join\nGROUP BY c.id, c.email;\n",[29,1564,1565,1570,1583,1619,1647,1652,1669,1676,1689,1696,1700,1705,1741,1747,1769,1786],{"__ignoreMap":27},[32,1566,1567],{"class":34,"line":35},[32,1568,1569],{"class":38},"-- ❌ WRONG: join everything, filter at the end\n",[32,1571,1572,1575,1578,1580],{"class":34,"line":42},[32,1573,1574],{"class":61},"WITH",[32,1576,1577],{"class":57}," all_orders ",[32,1579,1103],{"class":61},[32,1581,1582],{"class":57}," (\n",[32,1584,1585,1588,1590,1592,1594,1596,1598,1600,1602,1604,1606,1608,1610,1612,1614,1616],{"class":34,"line":48},[32,1586,1587],{"class":61},"  SELECT",[32,1589,1058],{"class":83},[32,1591,355],{"class":57},[32,1593,1063],{"class":83},[32,1595,573],{"class":57},[32,1597,1068],{"class":83},[32,1599,355],{"class":57},[32,1601,1073],{"class":83},[32,1603,573],{"class":57},[32,1605,1093],{"class":83},[32,1607,355],{"class":57},[32,1609,1098],{"class":83},[32,1611,573],{"class":57},[32,1613,1093],{"class":83},[32,1615,355],{"class":57},[32,1617,1618],{"class":83},"ordered_on\n",[32,1620,1621,1624,1627,1629,1631,1633,1635,1637,1639,1641,1643,1645],{"class":34,"line":54},[32,1622,1623],{"class":61},"  FROM",[32,1625,1626],{"class":57}," customers c ",[32,1628,1119],{"class":61},[32,1630,1122],{"class":57},[32,1632,1125],{"class":61},[32,1634,1058],{"class":83},[32,1636,355],{"class":57},[32,1638,1063],{"class":83},[32,1640,1134],{"class":61},[32,1642,1137],{"class":83},[32,1644,355],{"class":57},[32,1646,1142],{"class":83},[32,1648,1649],{"class":34,"line":90},[32,1650,1651],{"class":57},")\n",[32,1653,1654,1656,1659,1661,1664,1666],{"class":34,"line":97},[32,1655,62],{"class":61},[32,1657,1658],{"class":57}," id, email, ",[32,1660,1087],{"class":83},[32,1662,1663],{"class":57},"(amount) ",[32,1665,1103],{"class":61},[32,1667,1668],{"class":57}," total\n",[32,1670,1671,1673],{"class":34,"line":103},[32,1672,1111],{"class":61},[32,1674,1675],{"class":57}," all_orders\n",[32,1677,1678,1680,1682,1684,1686],{"class":34,"line":109},[32,1679,74],{"class":61},[32,1681,1335],{"class":57},[32,1683,1338],{"class":61},[32,1685,1341],{"class":1159},[32,1687,1688],{"class":38},"  -- applied AFTER the full join\n",[32,1690,1691,1693],{"class":34,"line":133},[32,1692,1182],{"class":61},[32,1694,1695],{"class":57}," id, email;\n",[32,1697,1698],{"class":34,"line":138},[32,1699,94],{"emptyLinePlaceholder":93},[32,1701,1702],{"class":34,"line":144},[32,1703,1704],{"class":38},"-- ✅ RIGHT: push the predicate into the join — filter orders first\n",[32,1706,1707,1709,1711,1713,1715,1717,1719,1721,1723,1725,1727,1729,1731,1733,1735,1737,1739],{"class":34,"line":150},[32,1708,62],{"class":61},[32,1710,1058],{"class":83},[32,1712,355],{"class":57},[32,1714,1063],{"class":83},[32,1716,573],{"class":57},[32,1718,1068],{"class":83},[32,1720,355],{"class":57},[32,1722,1073],{"class":83},[32,1724,573],{"class":57},[32,1726,1087],{"class":83},[32,1728,1090],{"class":57},[32,1730,1093],{"class":83},[32,1732,355],{"class":57},[32,1734,1098],{"class":83},[32,1736,229],{"class":57},[32,1738,1103],{"class":61},[32,1740,1668],{"class":57},[32,1742,1743,1745],{"class":34,"line":174},[32,1744,1111],{"class":61},[32,1746,1114],{"class":57},[32,1748,1749,1751,1753,1755,1757,1759,1761,1763,1765,1767],{"class":34,"line":179},[32,1750,1119],{"class":61},[32,1752,1122],{"class":57},[32,1754,1125],{"class":61},[32,1756,1058],{"class":83},[32,1758,355],{"class":57},[32,1760,1063],{"class":83},[32,1762,1134],{"class":61},[32,1764,1137],{"class":83},[32,1766,355],{"class":57},[32,1768,1142],{"class":83},[32,1770,1771,1773,1775,1777,1779,1781,1783],{"class":34,"line":185},[32,1772,1165],{"class":61},[32,1774,1137],{"class":83},[32,1776,355],{"class":57},[32,1778,1153],{"class":83},[32,1780,1156],{"class":61},[32,1782,1341],{"class":1159},[32,1784,1785],{"class":38},"  -- filter before join\n",[32,1787,1788,1790,1792,1794,1796,1798,1800,1802,1804],{"class":34,"line":209},[32,1789,1182],{"class":61},[32,1791,1058],{"class":83},[32,1793,355],{"class":57},[32,1795,1063],{"class":83},[32,1797,573],{"class":57},[32,1799,1068],{"class":83},[32,1801,355],{"class":57},[32,1803,1073],{"class":83},[32,1805,87],{"class":57},[417,1807,1809],{"id":1808},"covering-index-eliminate-heap-fetches","Covering Index — Eliminate Heap Fetches",[19,1811,1812],{"language":21},[23,1813,1815],{"className":25,"code":1814,"language":21,"meta":27,"style":27},"-- Query: SELECT customer_id, amount FROM orders WHERE customer_id = 42;\n-- With a plain index: Index Scan → 1000 heap fetches to get `amount`\nCREATE INDEX ON orders(customer_id);\n\n-- With a covering index: Index Only Scan → 0 heap fetches\nCREATE INDEX ON orders(customer_id) INCLUDE (amount);\n-- The INCLUDE column is stored in the index but not part of the sort key\n-- It's smaller than a full composite (customer_id, amount) because\n-- amount isn't used for tree navigation, only for covering\n",[29,1816,1817,1822,1827,1838,1842,1847,1862,1867,1872],{"__ignoreMap":27},[32,1818,1819],{"class":34,"line":35},[32,1820,1821],{"class":38},"-- Query: SELECT customer_id, amount FROM orders WHERE customer_id = 42;\n",[32,1823,1824],{"class":34,"line":42},[32,1825,1826],{"class":38},"-- With a plain index: Index Scan → 1000 heap fetches to get `amount`\n",[32,1828,1829,1831,1833,1835],{"class":34,"line":48},[32,1830,1306],{"class":61},[32,1832,1309],{"class":61},[32,1834,1316],{"class":1312},[32,1836,1837],{"class":57}," orders(customer_id);\n",[32,1839,1840],{"class":34,"line":54},[32,1841,94],{"emptyLinePlaceholder":93},[32,1843,1844],{"class":34,"line":90},[32,1845,1846],{"class":38},"-- With a covering index: Index Only Scan → 0 heap fetches\n",[32,1848,1849,1851,1853,1855,1858,1860],{"class":34,"line":97},[32,1850,1306],{"class":61},[32,1852,1309],{"class":61},[32,1854,1316],{"class":1312},[32,1856,1857],{"class":57}," orders(customer_id) ",[32,1859,1535],{"class":61},[32,1861,1538],{"class":57},[32,1863,1864],{"class":34,"line":103},[32,1865,1866],{"class":38},"-- The INCLUDE column is stored in the index but not part of the sort key\n",[32,1868,1869],{"class":34,"line":109},[32,1870,1871],{"class":38},"-- It's smaller than a full composite (customer_id, amount) because\n",[32,1873,1874],{"class":34,"line":133},[32,1875,1876],{"class":38},"-- amount isn't used for tree navigation, only for covering\n",[417,1878,1880],{"id":1879},"partition-pruning-skip-irrelevant-partitions","Partition Pruning — Skip Irrelevant Partitions",[19,1882,1883],{"language":21},[23,1884,1886],{"className":25,"code":1885,"language":21,"meta":27,"style":27},"-- Partition by month, prune automatically\nCREATE TABLE orders (\n  id BIGSERIAL,\n  customer_id BIGINT,\n  amount NUMERIC(10,2),\n  ordered_on TIMESTAMPTZ NOT NULL\n) PARTITION BY RANGE (ordered_on);\n\nCREATE TABLE orders_2024_01 PARTITION OF orders\n  FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');\nCREATE TABLE orders_2024_02 PARTITION OF orders\n  FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');\n-- ... etc\n\n-- The planner prunes partitions that can't match the WHERE clause\nEXPLAIN SELECT * FROM orders WHERE ordered_on >= '2024-01-15' AND ordered_on \u003C '2024-01-20';\n-- → Append\n--     -> Seq Scan on orders_2024_01   -- only Jan is scanned\n-- Feb, Mar, etc. are not even touched\n",[29,1887,1888,1893,1905,1916,1926,1948,1959,1975,1979,1994,2023,2036,2059,2064,2068,2073,2105,2110,2115],{"__ignoreMap":27},[32,1889,1890],{"class":34,"line":35},[32,1891,1892],{"class":38},"-- Partition by month, prune automatically\n",[32,1894,1895,1897,1900,1903],{"class":34,"line":42},[32,1896,1306],{"class":61},[32,1898,1899],{"class":61}," TABLE",[32,1901,1902],{"class":1312}," orders",[32,1904,1582],{"class":57},[32,1906,1907,1910,1913],{"class":34,"line":48},[32,1908,1909],{"class":57},"  id ",[32,1911,1912],{"class":61},"BIGSERIAL",[32,1914,1915],{"class":57},",\n",[32,1917,1918,1921,1924],{"class":34,"line":54},[32,1919,1920],{"class":57},"  customer_id ",[32,1922,1923],{"class":61},"BIGINT",[32,1925,1915],{"class":57},[32,1927,1928,1931,1934,1936,1939,1942,1945],{"class":34,"line":90},[32,1929,1930],{"class":57},"  amount ",[32,1932,1933],{"class":61},"NUMERIC",[32,1935,1090],{"class":57},[32,1937,1938],{"class":83},"10",[32,1940,1941],{"class":57},",",[32,1943,1944],{"class":83},"2",[32,1946,1947],{"class":57},"),\n",[32,1949,1950,1953,1956],{"class":34,"line":97},[32,1951,1952],{"class":57},"  ordered_on ",[32,1954,1955],{"class":61},"TIMESTAMPTZ",[32,1957,1958],{"class":61}," NOT NULL\n",[32,1960,1961,1963,1966,1969,1972],{"class":34,"line":103},[32,1962,229],{"class":57},[32,1964,1965],{"class":61},"PARTITION",[32,1967,1968],{"class":61}," BY",[32,1970,1971],{"class":61}," RANGE",[32,1973,1974],{"class":57}," (ordered_on);\n",[32,1976,1977],{"class":34,"line":109},[32,1978,94],{"emptyLinePlaceholder":93},[32,1980,1981,1983,1985,1988,1991],{"class":34,"line":133},[32,1982,1306],{"class":61},[32,1984,1899],{"class":61},[32,1986,1987],{"class":1312}," orders_2024_01",[32,1989,1990],{"class":61}," PARTITION",[32,1992,1993],{"class":57}," OF orders\n",[32,1995,1996,1999,2002,2004,2007,2010,2012,2015,2017,2020],{"class":34,"line":138},[32,1997,1998],{"class":61},"  FOR",[32,2000,2001],{"class":61}," VALUES",[32,2003,68],{"class":61},[32,2005,2006],{"class":57}," (",[32,2008,2009],{"class":1159},"'2024-01-01'",[32,2011,229],{"class":57},[32,2013,2014],{"class":61},"TO",[32,2016,2006],{"class":57},[32,2018,2019],{"class":1159},"'2024-02-01'",[32,2021,2022],{"class":57},");\n",[32,2024,2025,2027,2029,2032,2034],{"class":34,"line":144},[32,2026,1306],{"class":61},[32,2028,1899],{"class":61},[32,2030,2031],{"class":1312}," orders_2024_02",[32,2033,1990],{"class":61},[32,2035,1993],{"class":57},[32,2037,2038,2040,2042,2044,2046,2048,2050,2052,2054,2057],{"class":34,"line":150},[32,2039,1998],{"class":61},[32,2041,2001],{"class":61},[32,2043,68],{"class":61},[32,2045,2006],{"class":57},[32,2047,2019],{"class":1159},[32,2049,229],{"class":57},[32,2051,2014],{"class":61},[32,2053,2006],{"class":57},[32,2055,2056],{"class":1159},"'2024-03-01'",[32,2058,2022],{"class":57},[32,2060,2061],{"class":34,"line":174},[32,2062,2063],{"class":38},"-- ... etc\n",[32,2065,2066],{"class":34,"line":179},[32,2067,94],{"emptyLinePlaceholder":93},[32,2069,2070],{"class":34,"line":185},[32,2071,2072],{"class":38},"-- The planner prunes partitions that can't match the WHERE clause\n",[32,2074,2075,2077,2079,2081,2083,2085,2087,2089,2091,2094,2096,2098,2100,2103],{"class":34,"line":209},[32,2076,58],{"class":57},[32,2078,62],{"class":61},[32,2080,65],{"class":61},[32,2082,68],{"class":61},[32,2084,71],{"class":57},[32,2086,74],{"class":61},[32,2088,1335],{"class":57},[32,2090,1338],{"class":61},[32,2092,2093],{"class":1159}," '2024-01-15'",[32,2095,1344],{"class":61},[32,2097,1335],{"class":57},[32,2099,1349],{"class":61},[32,2101,2102],{"class":1159}," '2024-01-20'",[32,2104,87],{"class":57},[32,2106,2107],{"class":34,"line":214},[32,2108,2109],{"class":38},"-- → Append\n",[32,2111,2112],{"class":34,"line":220},[32,2113,2114],{"class":38},"--     -> Seq Scan on orders_2024_01   -- only Jan is scanned\n",[32,2116,2117],{"class":34,"line":250},[32,2118,2119],{"class":38},"-- Feb, Mar, etc. are not even touched\n",[417,2121,2123],{"id":2122},"parallel-query-use-multiple-workers","Parallel Query — Use Multiple Workers",[19,2125,2126],{"language":21},[23,2127,2129],{"className":25,"code":2128,"language":21,"meta":27,"style":27},"-- The planner auto-parallelizes large scans\u002Faggregations\n-- Tune the worker count per session:\nSET max_parallel_workers_per_gather = 4;  -- up to 4 workers per query node\nSET min_parallel_table_scan_size = '8MB'; -- minimum table size to parallelize\nSET parallel_setup_cost = 100;            -- lower = more eager to parallelize\n\n-- Plan with parallelism:\n-- Gather  (workers=4)\n--   -> Parallel Seq Scan on orders\n--        Filter: (amount > 100)\n-- Each worker scans a subset of pages; results are merged at the Gather node\n-- Overhead: worker startup + result merging. Not worth it for small tables.\n",[29,2130,2131,2136,2141,2158,2176,2194,2198,2203,2208,2213,2218,2223],{"__ignoreMap":27},[32,2132,2133],{"class":34,"line":35},[32,2134,2135],{"class":38},"-- The planner auto-parallelizes large scans\u002Faggregations\n",[32,2137,2138],{"class":34,"line":42},[32,2139,2140],{"class":38},"-- Tune the worker count per session:\n",[32,2142,2143,2145,2148,2150,2153,2155],{"class":34,"line":48},[32,2144,339],{"class":61},[32,2146,2147],{"class":57}," max_parallel_workers_per_gather ",[32,2149,80],{"class":61},[32,2151,2152],{"class":83}," 4",[32,2154,377],{"class":57},[32,2156,2157],{"class":38},"-- up to 4 workers per query node\n",[32,2159,2160,2162,2165,2167,2170,2173],{"class":34,"line":54},[32,2161,339],{"class":61},[32,2163,2164],{"class":57}," min_parallel_table_scan_size ",[32,2166,80],{"class":61},[32,2168,2169],{"class":1159}," '8MB'",[32,2171,2172],{"class":57},"; ",[32,2174,2175],{"class":38},"-- minimum table size to parallelize\n",[32,2177,2178,2180,2183,2185,2188,2191],{"class":34,"line":90},[32,2179,339],{"class":61},[32,2181,2182],{"class":57}," parallel_setup_cost ",[32,2184,80],{"class":61},[32,2186,2187],{"class":83}," 100",[32,2189,2190],{"class":57},";            ",[32,2192,2193],{"class":38},"-- lower = more eager to parallelize\n",[32,2195,2196],{"class":34,"line":97},[32,2197,94],{"emptyLinePlaceholder":93},[32,2199,2200],{"class":34,"line":103},[32,2201,2202],{"class":38},"-- Plan with parallelism:\n",[32,2204,2205],{"class":34,"line":109},[32,2206,2207],{"class":38},"-- Gather  (workers=4)\n",[32,2209,2210],{"class":34,"line":133},[32,2211,2212],{"class":38},"--   -> Parallel Seq Scan on orders\n",[32,2214,2215],{"class":34,"line":138},[32,2216,2217],{"class":38},"--        Filter: (amount > 100)\n",[32,2219,2220],{"class":34,"line":144},[32,2221,2222],{"class":38},"-- Each worker scans a subset of pages; results are merged at the Gather node\n",[32,2224,2225],{"class":34,"line":150},[32,2226,2227],{"class":38},"-- Overhead: worker startup + result merging. Not worth it for small tables.\n",[14,2229,2231],{"id":2230},"statistics-and-analyze","Statistics and ANALYZE",[19,2233,2234],{"language":21},[23,2235,2237],{"className":25,"code":2236,"language":21,"meta":27,"style":27},"-- Manually collect statistics (autovacuum does this, but sometimes stale)\nANALYZE orders;\n\n-- Analyze a specific column (faster than full table)\nANALYZE orders(customer_id);\n\n-- View stored statistics the planner uses\nSELECT attname, n_distinct, most_common_vals, most_common_freqs,\n       histogram_bounds, null_frac, avg_width\nFROM pg_stats\nWHERE tablename = 'orders' AND attname = 'customer_id';\n\n-- For skewed distributions (a few values dominate), increase statistics target\nALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 1000;\nANALYZE orders(customer_id);  -- default is 100; higher = more histogram buckets\n-- More buckets = better estimate for skewed data, at the cost of larger pg_statistic\n",[29,2238,2239,2244,2249,2253,2258,2263,2267,2272,2279,2284,2291,2315,2319,2324,2348,2356],{"__ignoreMap":27},[32,2240,2241],{"class":34,"line":35},[32,2242,2243],{"class":38},"-- Manually collect statistics (autovacuum does this, but sometimes stale)\n",[32,2245,2246],{"class":34,"line":42},[32,2247,2248],{"class":57},"ANALYZE orders;\n",[32,2250,2251],{"class":34,"line":48},[32,2252,94],{"emptyLinePlaceholder":93},[32,2254,2255],{"class":34,"line":54},[32,2256,2257],{"class":38},"-- Analyze a specific column (faster than full table)\n",[32,2259,2260],{"class":34,"line":90},[32,2261,2262],{"class":57},"ANALYZE orders(customer_id);\n",[32,2264,2265],{"class":34,"line":97},[32,2266,94],{"emptyLinePlaceholder":93},[32,2268,2269],{"class":34,"line":103},[32,2270,2271],{"class":38},"-- View stored statistics the planner uses\n",[32,2273,2274,2276],{"class":34,"line":109},[32,2275,62],{"class":61},[32,2277,2278],{"class":57}," attname, n_distinct, most_common_vals, most_common_freqs,\n",[32,2280,2281],{"class":34,"line":133},[32,2282,2283],{"class":57},"       histogram_bounds, null_frac, avg_width\n",[32,2285,2286,2288],{"class":34,"line":138},[32,2287,1111],{"class":61},[32,2289,2290],{"class":57}," pg_stats\n",[32,2292,2293,2295,2298,2300,2303,2305,2308,2310,2313],{"class":34,"line":144},[32,2294,74],{"class":61},[32,2296,2297],{"class":57}," tablename ",[32,2299,80],{"class":61},[32,2301,2302],{"class":1159}," 'orders'",[32,2304,1344],{"class":61},[32,2306,2307],{"class":57}," attname ",[32,2309,80],{"class":61},[32,2311,2312],{"class":1159}," 'customer_id'",[32,2314,87],{"class":57},[32,2316,2317],{"class":34,"line":150},[32,2318,94],{"emptyLinePlaceholder":93},[32,2320,2321],{"class":34,"line":174},[32,2322,2323],{"class":38},"-- For skewed distributions (a few values dominate), increase statistics target\n",[32,2325,2326,2329,2331,2333,2335,2338,2340,2343,2346],{"class":34,"line":179},[32,2327,2328],{"class":61},"ALTER",[32,2330,1899],{"class":61},[32,2332,71],{"class":57},[32,2334,2328],{"class":61},[32,2336,2337],{"class":57}," COLUMN customer_id ",[32,2339,339],{"class":61},[32,2341,2342],{"class":61}," STATISTICS",[32,2344,2345],{"class":83}," 1000",[32,2347,87],{"class":57},[32,2349,2350,2353],{"class":34,"line":185},[32,2351,2352],{"class":57},"ANALYZE orders(customer_id);  ",[32,2354,2355],{"class":38},"-- default is 100; higher = more histogram buckets\n",[32,2357,2358],{"class":34,"line":209},[32,2359,2360],{"class":38},"-- More buckets = better estimate for skewed data, at the cost of larger pg_statistic\n",[14,2362,2364],{"id":2363},"configuration-that-affects-plans","Configuration That Affects Plans",[422,2366,2367,2380],{},[425,2368,2369],{},[428,2370,2371,2374,2377],{},[431,2372,2373],{},"Setting",[431,2375,2376],{},"Effect",[431,2378,2379],{},"Tuning",[441,2381,2382,2397,2410,2423,2435,2456],{},[428,2383,2384,2388,2391],{},[446,2385,2386],{},[29,2387,844],{},[446,2389,2390],{},"Memory per sort\u002Fhash node",[446,2392,2393,2396],{},[29,2394,2395],{},"SET work_mem = '256MB'"," per-session for big queries. Default 4MB forces disk spills. Beware: per-node, so a 10-node plan × 256MB = 2.5GB.",[428,2398,2399,2404,2407],{},[446,2400,2401],{},[29,2402,2403],{},"shared_buffers",[446,2405,2406],{},"PostgreSQL's shared page cache",[446,2408,2409],{},"25% of RAM.",[428,2411,2412,2417,2420],{},[446,2413,2414],{},[29,2415,2416],{},"effective_cache_size",[446,2418,2419],{},"Hint about OS cache + shared_buffers",[446,2421,2422],{},"50-75% of RAM. Affects index-vs-seq-scan decisions. Low value → planner avoids indexes (thinks data isn't cached).",[428,2424,2425,2429,2432],{},[446,2426,2427],{},[29,2428,576],{},[446,2430,2431],{},"Cost of a random page read",[446,2433,2434],{},"Default 4.0 (spinning disk). Set to 1.1 on SSDs — makes planner prefer indexes.",[428,2436,2437,2446,2449],{},[446,2438,2439,2442,2443],{},[29,2440,2441],{},"jit"," \u002F ",[29,2444,2445],{},"jit_above_cost",[446,2447,2448],{},"JIT compile expressions if plan cost > threshold",[446,2450,2451,2452,2455],{},"Default on, threshold ~100000. For OLTP (short queries), disable: ",[29,2453,2454],{},"SET jit = off",". JIT compilation has startup cost that hurts short queries.",[428,2457,2458,2463,2466],{},[446,2459,2460],{},[29,2461,2462],{},"enable_seqscan",[446,2464,2465],{},"Toggle to force\u002Favoid seq scans",[446,2467,2468,2471,2472,2476],{},[29,2469,2470],{},"SET enable_seqscan = off"," to test if an index ",[2473,2474,2475],"em",{},"would"," be used. Never use in production — it doesn't disable seq scans, just makes them artificially expensive.",[19,2478,2479],{"language":21},[23,2480,2482],{"className":25,"code":2481,"language":21,"meta":27,"style":27},"-- Per-session tuning for a heavy analytical query\nSET work_mem = '512MB';\nSET random_page_cost = 1.1;          -- SSD\nSET effective_cache_size = '8GB';    -- hint: 8GB of OS+PG cache available\nSET max_parallel_workers_per_gather = 4;\n-- Run the query, then reset:\nRESET work_mem;\nRESET random_page_cost;\n",[29,2483,2484,2489,2502,2523,2541,2553,2558,2566],{"__ignoreMap":27},[32,2485,2486],{"class":34,"line":35},[32,2487,2488],{"class":38},"-- Per-session tuning for a heavy analytical query\n",[32,2490,2491,2493,2495,2497,2500],{"class":34,"line":42},[32,2492,339],{"class":61},[32,2494,1370],{"class":57},[32,2496,80],{"class":61},[32,2498,2499],{"class":1159}," '512MB'",[32,2501,87],{"class":57},[32,2503,2504,2506,2509,2511,2513,2515,2517,2520],{"class":34,"line":48},[32,2505,339],{"class":61},[32,2507,2508],{"class":57}," random_page_cost ",[32,2510,80],{"class":61},[32,2512,352],{"class":83},[32,2514,355],{"class":57},[32,2516,358],{"class":83},[32,2518,2519],{"class":57},";          ",[32,2521,2522],{"class":38},"-- SSD\n",[32,2524,2525,2527,2530,2532,2535,2538],{"class":34,"line":54},[32,2526,339],{"class":61},[32,2528,2529],{"class":57}," effective_cache_size ",[32,2531,80],{"class":61},[32,2533,2534],{"class":1159}," '8GB'",[32,2536,2537],{"class":57},";    ",[32,2539,2540],{"class":38},"-- hint: 8GB of OS+PG cache available\n",[32,2542,2543,2545,2547,2549,2551],{"class":34,"line":90},[32,2544,339],{"class":61},[32,2546,2147],{"class":57},[32,2548,80],{"class":61},[32,2550,2152],{"class":83},[32,2552,87],{"class":57},[32,2554,2555],{"class":34,"line":97},[32,2556,2557],{"class":38},"-- Run the query, then reset:\n",[32,2559,2560,2563],{"class":34,"line":103},[32,2561,2562],{"class":61},"RESET",[32,2564,2565],{"class":57}," work_mem;\n",[32,2567,2568,2570],{"class":34,"line":109},[32,2569,2562],{"class":61},[32,2571,2572],{"class":57}," random_page_cost;\n",[14,2574,2576],{"id":2575},"tips-tricks","💡 Tips & Tricks",[2578,2579,2580,2598,2614,2636,2645,2663,2674],"ul",{},[1251,2581,2582,2585,2586,2589,2590,2593,2594,2597],{},[297,2583,2584],{},"Idiom"," — run ",[29,2587,2588],{},"EXPLAIN (ANALYZE, BUFFERS)"," and compute the ",[297,2591,2592],{},"buffer hit ratio",": ",[29,2595,2596],{},"shared_blks_hit \u002F (shared_blks_hit + shared_blks_read)",". Below 90% means the working set doesn't fit in cache — data is I\u002FO-bound. Above 99% means it's CPU-bound — the bottleneck is computation (sorts, joins, aggregates), not I\u002FO. The fix differs entirely: I\u002FO-bound → index or more cache; CPU-bound → fewer rows, simpler computation, or parallelism.",[1251,2599,2600,2602,2603,2606,2607,2610,2611,355],{},[297,2601,2584],{}," — compare ",[297,2604,2605],{},"estimated rows vs actual rows"," at every node. A >10× discrepancy means stale statistics. The signature symptom: ",[29,2608,2609],{},"rows=1 (actual rows=1000000)"," on an inner scan → the planner chose a Nested Loop thinking the inner would return 1 row per loop, but it returned 1M per loop. Fix: ",[29,2612,2613],{},"ANALYZE table",[1251,2615,2616,2618,2619,2621,2622,2625,2626,2628,2629,2632,2633,2635],{},[297,2617,2584],{}," — use ",[29,2620,2470],{}," to ",[297,2623,2624],{},"test"," whether an index ",[2473,2627,2475],{}," be used. If the plan still shows a Seq Scan with ",[29,2630,2631],{},"enable_seqscan = off",", the index genuinely can't serve this query (function wrapping, type mismatch, or leading wildcard). If it switches to an Index Scan, the planner is choosing Seq Scan for cost reasons — possibly because ",[29,2634,576],{}," is too high for your SSD.",[1251,2637,2638,2640,2641,2644],{},[297,2639,2584],{}," — set ",[29,2642,2643],{},"random_page_cost = 1.1"," on SSD storage. The default 4.0 was calibrated for mechanical disks where random reads are 4× slower than sequential. On SSDs, random and sequential reads are nearly equal. Leaving the default makes the planner over-penalize index scans, choosing Seq Scans where Index Scans would be faster.",[1251,2646,2647,2650,2651,2653,2654,2656,2657,2659,2660,2662],{},[297,2648,2649],{},"Performance"," — increase ",[29,2652,844],{}," per-session, not globally. ",[29,2655,2395],{}," before a big sort\u002Fhash query, ",[29,2658,2562],{}," after. Global high ",[29,2661,844],{}," is dangerous: each connection × each sort node can allocate that much. 100 connections × 256MB × 3 sort nodes = 75GB.",[1251,2664,2665,2640,2667,2670,2671,2673],{},[297,2666,2649],{},[29,2668,2669],{},"jit = off"," for OLTP workloads. JIT compilation has a startup cost (~5-10ms) that's amortized over long analytical queries but pure overhead for sub-millisecond OLTP. The ",[29,2672,2445],{}," threshold (default 100000) usually excludes OLTP, but set it explicitly to be safe.",[1251,2675,2676,2618,2679,2682,2683,2686],{},[297,2677,2678],{},"Debug",[29,2680,2681],{},"EXPLAIN (ANALYZE, BUFFERS, TIMING)"," to see per-node wall time. Without ",[29,2684,2685],{},"TIMING",", you only see aggregate time. With it, you can pinpoint exactly which node (e.g., a Sort, or a Hash build) dominates, and focus your optimization there.",[14,2688,2690],{"id":2689},"️-edge-cases-gotchas","⚠️ Edge Cases & Gotchas",[2578,2692,2693,2708,2735,2748,2769,2786,2803,2827,2846],{},[1251,2694,2695,2593,2700,2703,2704,2707],{},[297,2696,2697,2699],{},[29,2698,294],{}," executes DML",[29,2701,2702],{},"EXPLAIN ANALYZE DELETE FROM orders WHERE ..."," actually deletes rows. Wrap in ",[29,2705,2706],{},"BEGIN; EXPLAIN ANALYZE ...; ROLLBACK;"," — the plan is still displayed after rollback.",[1251,2709,2710,2593,2713,2716,2717,2719,2720,2722,2723,2725,2726,2728,2729,2731,2732,2734],{},[297,2711,2712],{},"Cost units are arbitrary, not milliseconds",[29,2714,2715],{},"cost=100000"," doesn't mean 100 seconds. The units are derived from ",[29,2718,572],{}," (1.0), ",[29,2721,576],{}," (4.0), ",[29,2724,579],{}," (0.01). Only use costs for ",[297,2727,583],{}," comparison between plan alternatives, never as a time estimate. Use ",[29,2730,411],{},"'s ",[29,2733,520],{}," for wall-clock measurement.",[1251,2736,2737,2740,2741,2744,2745,2747],{},[297,2738,2739],{},"Index Only Scan requires a fresh visibility map",": even with a covering index, if the visibility map bit for a page is stale (pages modified since last VACUUM), the planner must do a heap fetch to check row visibility — ",[29,2742,2743],{},"Heap Fetches: N > 0",". ",[29,2746,666],{}," updates the visibility map. This is why autovacuum frequency directly affects Index Only Scan performance.",[1251,2749,2750,2753,2754,2757,2758,2761,2762,2765,2766,2768],{},[297,2751,2752],{},"Bitmap scan lossy mode",": when the bitmap exceeds memory, PostgreSQL switches to ",[297,2755,2756],{},"lossy"," mode — keeping only page-level granularity. This forces a ",[29,2759,2760],{},"Recheck Cond"," per heap row. The plan shows ",[29,2763,2764],{},"Rows Removed by Index Recheck: N",". Raise ",[29,2767,844],{}," to keep the bitmap exact.",[1251,2770,2771,2774,2775,2778,2779,2781,2782,2785],{},[297,2772,2773],{},"Nested Loop blowup on large inputs",": the inner is executed ",[29,2776,2777],{},"loops = outer_rows"," times. If the inner is a Seq Scan of a 1M-row table and outer has 10000 rows, that's $10^{10}$ row examinations. Always check ",[29,2780,552],{}," and multiply ",[29,2783,2784],{},"actual time × loops"," for total inner cost.",[1251,2787,2788,2791,2792,2795,2796,2799,2800,355],{},[297,2789,2790],{},"Parallel query overhead for small result sets",": the Gather node has worker startup + result merge cost. For small tables or selective queries, parallelism makes the query ",[297,2793,2794],{},"slower",". The planner only parallelizes when ",[29,2797,2798],{},"parallel_setup_cost + parallel_tuple_cost × rows"," is lower than the serial alternative. Don't force it with ",[29,2801,2802],{},"SET min_parallel_table_scan_size = 0",[1251,2804,2805,2808,2809,2811,2812,2815,2816,2818,2819,2822,2823,2826],{},[297,2806,2807],{},"Estimate vs actual mismatch from stale stats",": autovacuum runs ",[29,2810,411],{}," periodically, but between runs, bulk loads (",[29,2813,2814],{},"COPY","), large deletes, or skewed inserts leave stats stale. After any bulk data change, run ",[29,2817,411],{}," manually. The ",[29,2820,2821],{},"n_mod_since_analyze"," column in ",[29,2824,2825],{},"pg_stat_user_tables"," shows how many rows changed since the last analyze.",[1251,2828,2829,2834,2835,2837,2838,2841,2842,2845],{},[297,2830,2831,2833],{},[29,2832,411],{}," samples, doesn't scan everything",": by default, ",[29,2836,411],{}," samples ",[29,2839,2840],{},"300 × statistics_target"," rows (300 × 100 = 30000 rows). For highly skewed distributions, this may miss rare values. Increase ",[29,2843,2844],{},"SET STATISTICS"," to 1000 for columns with heavy skew.",[1251,2847,2848,2851,2852,2855,2856,2859,2860,2863,2864,355],{},[297,2849,2850],{},"Prepared statement plan caching",": a prepared statement's plan is generated once and reused. If the first call's parameters are unrepresentative (e.g., a rare selective value returns 1 row), the cached plan (Nested Loop) is catastrophic for subsequent calls (e.g., a common value returning 1M rows). PostgreSQL 16+ supports ",[29,2853,2854],{},"generic plans"," via ",[29,2857,2858],{},"plan_cache_mode = force_generic_plan",", or use ",[29,2861,2862],{},"REPREPARE"," after ",[29,2865,411],{},[14,2867,2869],{"id":2868},"spot-the-bug","🧠 Spot the Bug",[289,2871,2872],{},"A query runs fast in staging (10K rows) but is catastrophically slow in production (50M rows). The plan shows:",[19,2874,2875],{"language":394},[23,2876,2879],{"className":2877,"code":2878,"language":394,"meta":27},[398],"Nested Loop  (cost=0.58..12.87 rows=1 width=52) (actual time=0.05..87000.0 rows=5000 loops=1)\n  ->  Index Scan using users_email_idx on users  (cost=0.29..8.31 rows=1 width=4) (actual time=0.03..0.04 rows=1 loops=1)\n        Index Cond: (email = 'vip@enterprise.com')\n  ->  Index Scan using orders_customer_id_idx on orders  (cost=0.29..4.55 rows=1 width=12) (actual time=0.02..17.4 rows=5000 loops=1)\n        Index Cond: (customer_id = users.id)\n",[29,2880,2878],{"__ignoreMap":27},[289,2882,2883,2884,2887],{},"The index ",[29,2885,2886],{},"orders_customer_id_idx"," exists and is used. What's wrong?",[2889,2890,2891,2895,2925,2934,2937,2945,2985,2996,3030],"details",{},[2892,2893,2894],"summary",{},"Answer",[289,2896,2897,2898,2901,2902,2905,2906,2909,2910,2913,2914,2917,2918,2920,2921,2924],{},"The planner ",[297,2899,2900],{},"estimated 1 row"," from ",[29,2903,2904],{},"orders"," per user (",[29,2907,2908],{},"rows=1","), but the ",[297,2911,2912],{},"actual is 5000"," — this user (",[29,2915,2916],{},"vip@enterprise.com",") is an outlier with 5000 orders, while most users have ~1. The estimate comes from ",[29,2919,512],{}," which reflects the ",[2473,2922,2923],{},"average"," distribution, not this outlier.",[289,2926,2927,2928,2930,2931,355],{},"Because the planner expected 1 row, it chose a ",[297,2929,811],{}," — for 1 outer row × 1 inner row, that's 1 index lookup, cheap. But with 5000 actual inner rows, it's 5000 index lookups, each traversing the B-tree from the root — ",[29,2932,2933],{},"5000 × 17.4ms = 87000ms",[289,2935,2936],{},"The fix isn't more indexes (the index is already used). The fix is:",[1248,2938,2939],{},[1251,2940,2941,2944],{},[297,2942,2943],{},"Refresh statistics"," so the planner knows about the skew:",[19,2946,2947],{"language":21},[23,2948,2950],{"className":25,"code":2949,"language":21,"meta":27,"style":27},"ANALYZE orders;\n-- Or increase statistics target for better skew detection:\nALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 1000;\nANALYZE orders;\n",[29,2951,2952,2956,2961,2981],{"__ignoreMap":27},[32,2953,2954],{"class":34,"line":35},[32,2955,2248],{"class":57},[32,2957,2958],{"class":34,"line":42},[32,2959,2960],{"class":38},"-- Or increase statistics target for better skew detection:\n",[32,2962,2963,2965,2967,2969,2971,2973,2975,2977,2979],{"class":34,"line":48},[32,2964,2328],{"class":61},[32,2966,1899],{"class":61},[32,2968,71],{"class":57},[32,2970,2328],{"class":61},[32,2972,2337],{"class":57},[32,2974,339],{"class":61},[32,2976,2342],{"class":61},[32,2978,2345],{"class":83},[32,2980,87],{"class":57},[32,2982,2983],{"class":34,"line":54},[32,2984,2248],{"class":57},[1248,2986,2987],{"start":42},[1251,2988,2989,2992,2993,2995],{},[297,2990,2991],{},"If the skew persists and this query is common",", the planner should use a ",[297,2994,830],{}," instead (build a hash on all of this user's orders in one pass, not 5000 index probes). With accurate stats, the planner will choose Hash Join automatically. You can verify:",[19,2997,2998],{"language":21},[23,2999,3001],{"className":25,"code":3000,"language":21,"meta":27,"style":27},"SET enable_nestloop = off;  -- testing only — forces Hash\u002FMerge Join\nEXPLAIN ANALYZE [query];\n-- If Hash Join is faster, the stats fix should make the planner choose it\n",[29,3002,3003,3020,3025],{"__ignoreMap":27},[32,3004,3005,3007,3010,3012,3015,3017],{"class":34,"line":35},[32,3006,339],{"class":61},[32,3008,3009],{"class":57}," enable_nestloop ",[32,3011,80],{"class":61},[32,3013,3014],{"class":61}," off",[32,3016,377],{"class":57},[32,3018,3019],{"class":38},"-- testing only — forces Hash\u002FMerge Join\n",[32,3021,3022],{"class":34,"line":42},[32,3023,3024],{"class":57},"EXPLAIN ANALYZE [query];\n",[32,3026,3027],{"class":34,"line":48},[32,3028,3029],{"class":38},"-- If Hash Join is faster, the stats fix should make the planner choose it\n",[289,3031,3032,3035,3036,3039,3040,3043,3044,3047,3048,3050,3051,3054],{},[297,3033,3034],{},"The lesson",": an Index Scan with ",[29,3037,3038],{},"rows=1 (actual rows=5000)"," inside a Nested Loop is the signature of ",[297,3041,3042],{},"skewed statistics",". The index is used but the ",[2473,3045,3046],{},"join algorithm"," is wrong. ",[29,3049,411],{}," + higher ",[29,3052,3053],{},"STATISTICS"," target fixes the estimate, and the planner switches to Hash Join.",[3056,3057,3058],"style",{},"html pre.shiki code .sdCPZ, html code.shiki .sdCPZ{--shiki-default:#6A737D;--shiki-github-dark:#6A737D}html pre.shiki code .ssxIu, html code.shiki .ssxIu{--shiki-default:#24292E;--shiki-github-dark:#E1E4E8}html pre.shiki code .svdQ7, html code.shiki .svdQ7{--shiki-default:#D73A49;--shiki-github-dark:#F97583}html pre.shiki code .snvgF, html code.shiki .snvgF{--shiki-default:#005CC5;--shiki-github-dark:#79B8FF}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 .github-dark .shiki span {color: var(--shiki-github-dark);background: var(--shiki-github-dark-bg);font-style: var(--shiki-github-dark-font-style);font-weight: var(--shiki-github-dark-font-weight);text-decoration: var(--shiki-github-dark-text-decoration);}html.github-dark .shiki span {color: var(--shiki-github-dark);background: var(--shiki-github-dark-bg);font-style: var(--shiki-github-dark-font-style);font-weight: var(--shiki-github-dark-font-weight);text-decoration: var(--shiki-github-dark-text-decoration);}html pre.shiki code .sJ6F3, html code.shiki .sJ6F3{--shiki-default:#032F62;--shiki-github-dark:#9ECBFF}html pre.shiki code .sIsaT, html code.shiki .sIsaT{--shiki-default:#6F42C1;--shiki-github-dark:#B392F0}",{"title":27,"searchDepth":42,"depth":42,"links":3060},[3061,3062,3065,3068,3069,3070,3077,3078,3084,3085,3086,3087,3088],{"id":16,"depth":42,"text":17},{"id":383,"depth":42,"text":384,"children":3063},[3064],{"id":419,"depth":48,"text":420},{"id":587,"depth":42,"text":588,"children":3066},[3067],{"id":769,"depth":48,"text":770},{"id":781,"depth":42,"text":782},{"id":874,"depth":42,"text":875},{"id":1034,"depth":42,"text":1035,"children":3071},[3072,3073,3074,3075,3076],{"id":1038,"depth":48,"text":1039},{"id":1233,"depth":48,"text":1234},{"id":1245,"depth":48,"text":1246},{"id":1283,"depth":48,"text":1284},{"id":1399,"depth":48,"text":1400},{"id":1417,"depth":42,"text":1418},{"id":1552,"depth":42,"text":1553,"children":3079},[3080,3081,3082,3083],{"id":1556,"depth":48,"text":1557},{"id":1808,"depth":48,"text":1809},{"id":1879,"depth":48,"text":1880},{"id":2122,"depth":48,"text":2123},{"id":2230,"depth":42,"text":2231},{"id":2363,"depth":42,"text":2364},{"id":2575,"depth":42,"text":2576},{"id":2689,"depth":42,"text":2690},{"id":2868,"depth":42,"text":2869},"md",{},"\u002Fsql\u002F26-query-optimization",{"title":5,"description":27},"sql\u002F26-query-optimization","OOOO8BPXDn72dsp6kR_ma3smjBTh3CH1xD0tzzcmfpM",1789924655045]