[{"data":1,"prerenderedAt":3085},["ShallowReactive",2],{"page-\u002Fsql\u002F13-indexes-and-performance":3},{"id":4,"title":5,"body":6,"description":3078,"extension":3079,"meta":3080,"navigation":103,"path":3081,"seo":3082,"stem":3083,"__hash__":3084},"content\u002Fsql\u002F13-indexes-and-performance.md","13 — Indexes & Performance",{"type":7,"value":8,"toc":3056},"minimark",[9,13,22,27,35,170,174,185,258,316,320,323,480,485,492,535,542,562,566,599,621,625,632,752,755,759,765,930,933,937,940,1125,1129,1271,1275,1479,1483,1583,1587,1590,1631,1816,1827,1831,1840,1982,1985,2011,2015,2252,2256,2514,2518,2615,2619,2818,2822,2828,2875,3052],[10,11,5],"h1",{"id":12},"_13-indexes-performance",[14,15,16,17,21],"p",{},"Indexes are the single most important performance tool in a database. The difference between a 10-second query and a 1-millisecond query is almost always an index. This chapter is a code-first reference for B-tree, composite, covering, partial, expression, GIN\u002FGiST\u002FBRIN indexes, and the ",[18,19,20],"code",{},"EXPLAIN"," skills to verify they're actually used.",[23,24,26],"h2",{"id":25},"what-an-index-does","What an Index Does",[14,28,29,30,34],{},"Without an index, finding rows matching a condition requires a ",[31,32,33],"strong",{},"sequential scan"," — reading every row in the table, O(N). An index is a separate sorted data structure that lets the database find matching rows in O(log N), then fetch just those heap tuples.",[36,37,39],"code-wrapper",{"language":38},"sql",[40,41,45],"pre",{"className":42,"code":43,"language":38,"meta":44,"style":44},"language-sql shiki shiki-themes github-light github-dark","-- Without an index: sequential scan reads all 10M heap pages.\nEXPLAIN SELECT * FROM orders WHERE customer_id = 42;\n-- Seq Scan on orders  (cost=0.00..183334.00 rows=5263 width=...)\n\n-- With an index: B-tree lookup → ~3-4 index pages → ~a few heap pages.\nCREATE INDEX orders_customer_id_idx ON orders(customer_id);\nEXPLAIN SELECT * FROM orders WHERE customer_id = 42;\n-- Index Scan using orders_customer_id_idx on orders  (cost=0.43..63.27 rows=53 width=...)\n\n-- The cost difference: 183334 → 63. That's 2900x cheaper.\n","",[18,46,47,56,92,98,105,111,130,153,159,164],{"__ignoreMap":44},[48,49,52],"span",{"class":50,"line":51},"line",1,[48,53,55],{"class":54},"sdCPZ","-- Without an index: sequential scan reads all 10M heap pages.\n",[48,57,59,63,67,70,73,76,79,82,85,89],{"class":50,"line":58},2,[48,60,62],{"class":61},"ssxIu","EXPLAIN ",[48,64,66],{"class":65},"svdQ7","SELECT",[48,68,69],{"class":65}," *",[48,71,72],{"class":65}," FROM",[48,74,75],{"class":61}," orders ",[48,77,78],{"class":65},"WHERE",[48,80,81],{"class":61}," customer_id ",[48,83,84],{"class":65},"=",[48,86,88],{"class":87},"snvgF"," 42",[48,90,91],{"class":61},";\n",[48,93,95],{"class":50,"line":94},3,[48,96,97],{"class":54},"-- Seq Scan on orders  (cost=0.00..183334.00 rows=5263 width=...)\n",[48,99,101],{"class":50,"line":100},4,[48,102,104],{"emptyLinePlaceholder":103},true,"\n",[48,106,108],{"class":50,"line":107},5,[48,109,110],{"class":54},"-- With an index: B-tree lookup → ~3-4 index pages → ~a few heap pages.\n",[48,112,114,117,120,124,127],{"class":50,"line":113},6,[48,115,116],{"class":65},"CREATE",[48,118,119],{"class":65}," INDEX",[48,121,123],{"class":122},"sIsaT"," orders_customer_id_idx",[48,125,126],{"class":65}," ON",[48,128,129],{"class":61}," orders(customer_id);\n",[48,131,133,135,137,139,141,143,145,147,149,151],{"class":50,"line":132},7,[48,134,62],{"class":61},[48,136,66],{"class":65},[48,138,69],{"class":65},[48,140,72],{"class":65},[48,142,75],{"class":61},[48,144,78],{"class":65},[48,146,81],{"class":61},[48,148,84],{"class":65},[48,150,88],{"class":87},[48,152,91],{"class":61},[48,154,156],{"class":50,"line":155},8,[48,157,158],{"class":54},"-- Index Scan using orders_customer_id_idx on orders  (cost=0.43..63.27 rows=53 width=...)\n",[48,160,162],{"class":50,"line":161},9,[48,163,104],{"emptyLinePlaceholder":103},[48,165,167],{"class":50,"line":166},10,[48,168,169],{"class":54},"-- The cost difference: 183334 → 63. That's 2900x cheaper.\n",[23,171,173],{"id":172},"b-tree-indexes-the-default","B-Tree Indexes (the default)",[14,175,176,177,180,181,184],{},"PostgreSQL's default index type is a ",[31,178,179],{},"B-tree"," — a balanced tree keyed by the indexed column(s). Each leaf page points to heap tuples (CTIDs: ",[18,182,183],{},"block_id, offset","). It supports:",[186,187,188,202,223,232,245],"ul",{},[189,190,191,194,195,197,198,201],"li",{},[31,192,193],{},"Equality"," (",[18,196,84],{},", ",[18,199,200],{},"IS NULL","): tree descent to the leaf.",[189,203,204,194,207,197,210,197,213,197,216,197,219,222],{},[31,205,206],{},"Range",[18,208,209],{},"\u003C",[18,211,212],{},"\u003C=",[18,214,215],{},">",[18,217,218],{},">=",[18,220,221],{},"BETWEEN","): scan leaves left\u002Fright from the match point.",[189,224,225,194,228,231],{},[31,226,227],{},"Sorting",[18,229,230],{},"ORDER BY col","): the index is pre-sorted, so the planner reads rows in order without a sort node.",[189,233,234,194,237,240,241,244],{},[31,235,236],{},"Prefix matching",[18,238,239],{},"LIKE 'prefix%'","): the tree is sorted lexicographically, so prefix matches are a range scan. ",[18,242,243],{},"LIKE '%middle'"," defeats the index (no fixed prefix).",[189,246,247,250,251,253,254,257],{},[31,248,249],{},"NULLs",": PG B-trees include NULL entries (some engines don't) — ",[18,252,200],{}," \u002F ",[18,255,256],{},"IS NOT NULL"," are indexable.",[36,259,260],{"language":38},[40,261,263],{"className":42,"code":262,"language":38,"meta":44,"style":44},"CREATE INDEX orders_customer_id_idx  ON orders(customer_id);\nCREATE INDEX orders_ordered_on_idx   ON orders(ordered_on);\nCREATE INDEX users_email_lower_idx   ON users(lower(email));  -- expression index\n",[18,264,265,278,293],{"__ignoreMap":44},[48,266,267,269,271,273,276],{"class":50,"line":51},[48,268,116],{"class":65},[48,270,119],{"class":65},[48,272,123],{"class":122},[48,274,275],{"class":65},"  ON",[48,277,129],{"class":61},[48,279,280,282,284,287,290],{"class":50,"line":58},[48,281,116],{"class":65},[48,283,119],{"class":65},[48,285,286],{"class":122}," orders_ordered_on_idx",[48,288,289],{"class":65},"   ON",[48,291,292],{"class":61}," orders(ordered_on);\n",[48,294,295,297,299,302,304,307,310,313],{"class":50,"line":94},[48,296,116],{"class":65},[48,298,119],{"class":65},[48,300,301],{"class":122}," users_email_lower_idx",[48,303,289],{"class":65},[48,305,306],{"class":61}," users(",[48,308,309],{"class":87},"lower",[48,311,312],{"class":61},"(email));  ",[48,314,315],{"class":54},"-- expression index\n",[23,317,319],{"id":318},"composite-multi-column-indexes","Composite (Multi-Column) Indexes",[14,321,322],{},"A composite index covers multiple columns. It's useful when queries filter on multiple columns together, and for \"covering\" queries.",[36,324,325],{"language":38},[40,326,328],{"className":42,"code":327,"language":38,"meta":44,"style":44},"CREATE INDEX orders_customer_date_idx ON orders(customer_id, ordered_on);\n\n-- ✅ Uses the index (leftmost prefix matches):\nSELECT * FROM orders WHERE customer_id = 42;\nSELECT * FROM orders WHERE customer_id = 42 AND ordered_on >= '2024-01-01';\n\n-- ❌ Cannot use the index efficiently (skips customer_id — leftmost rule):\nSELECT * FROM orders WHERE ordered_on >= '2024-01-01';\n-- Planner does a seq scan or a backward full index scan.\n\n-- ✅ Can use the index for ORDER BY matching the index order:\nSELECT * FROM orders WHERE customer_id = 42 ORDER BY ordered_on;\n-- Index scan returns rows already sorted by ordered_on — no Sort node.\n",[18,329,330,344,348,353,373,405,409,414,434,439,443,449,474],{"__ignoreMap":44},[48,331,332,334,336,339,341],{"class":50,"line":51},[48,333,116],{"class":65},[48,335,119],{"class":65},[48,337,338],{"class":122}," orders_customer_date_idx",[48,340,126],{"class":65},[48,342,343],{"class":61}," orders(customer_id, ordered_on);\n",[48,345,346],{"class":50,"line":58},[48,347,104],{"emptyLinePlaceholder":103},[48,349,350],{"class":50,"line":94},[48,351,352],{"class":54},"-- ✅ Uses the index (leftmost prefix matches):\n",[48,354,355,357,359,361,363,365,367,369,371],{"class":50,"line":100},[48,356,66],{"class":65},[48,358,69],{"class":65},[48,360,72],{"class":65},[48,362,75],{"class":61},[48,364,78],{"class":65},[48,366,81],{"class":61},[48,368,84],{"class":65},[48,370,88],{"class":87},[48,372,91],{"class":61},[48,374,375,377,379,381,383,385,387,389,391,394,397,399,403],{"class":50,"line":107},[48,376,66],{"class":65},[48,378,69],{"class":65},[48,380,72],{"class":65},[48,382,75],{"class":61},[48,384,78],{"class":65},[48,386,81],{"class":61},[48,388,84],{"class":65},[48,390,88],{"class":87},[48,392,393],{"class":65}," AND",[48,395,396],{"class":61}," ordered_on ",[48,398,218],{"class":65},[48,400,402],{"class":401},"sJ6F3"," '2024-01-01'",[48,404,91],{"class":61},[48,406,407],{"class":50,"line":113},[48,408,104],{"emptyLinePlaceholder":103},[48,410,411],{"class":50,"line":132},[48,412,413],{"class":54},"-- ❌ Cannot use the index efficiently (skips customer_id — leftmost rule):\n",[48,415,416,418,420,422,424,426,428,430,432],{"class":50,"line":155},[48,417,66],{"class":65},[48,419,69],{"class":65},[48,421,72],{"class":65},[48,423,75],{"class":61},[48,425,78],{"class":65},[48,427,396],{"class":61},[48,429,218],{"class":65},[48,431,402],{"class":401},[48,433,91],{"class":61},[48,435,436],{"class":50,"line":161},[48,437,438],{"class":54},"-- Planner does a seq scan or a backward full index scan.\n",[48,440,441],{"class":50,"line":166},[48,442,104],{"emptyLinePlaceholder":103},[48,444,446],{"class":50,"line":445},11,[48,447,448],{"class":54},"-- ✅ Can use the index for ORDER BY matching the index order:\n",[48,450,452,454,456,458,460,462,464,466,468,471],{"class":50,"line":451},12,[48,453,66],{"class":65},[48,455,69],{"class":65},[48,457,72],{"class":65},[48,459,75],{"class":61},[48,461,78],{"class":65},[48,463,81],{"class":61},[48,465,84],{"class":65},[48,467,88],{"class":87},[48,469,470],{"class":65}," ORDER BY",[48,472,473],{"class":61}," ordered_on;\n",[48,475,477],{"class":50,"line":476},13,[48,478,479],{"class":54},"-- Index scan returns rows already sorted by ordered_on — no Sort node.\n",[481,482,484],"h3",{"id":483},"the-leftmost-prefix-rule","The Leftmost-Prefix Rule",[14,486,487,488,491],{},"A composite index ",[18,489,490],{},"(a, b, c)"," can be used for:",[186,493,494,503,511,519],{},[189,495,496,499,500],{},[18,497,498],{},"WHERE a = ?"," — uses ",[18,501,502],{},"a",[189,504,505,499,508],{},[18,506,507],{},"WHERE a = ? AND b = ?",[18,509,510],{},"a, b",[189,512,513,499,516],{},[18,514,515],{},"WHERE a = ? AND b = ? AND c = ?",[18,517,518],{},"a, b, c",[189,520,521,499,524,526,527,530,531,534],{},[18,522,523],{},"WHERE a = ? AND c = ?",[18,525,502],{}," only (skips ",[18,528,529],{},"b",", can't use ",[18,532,533],{},"c",")",[14,536,537,538,541],{},"It ",[31,539,540],{},"cannot"," be used efficiently for:",[186,543,544,552],{},[189,545,546,549,550],{},[18,547,548],{},"WHERE b = ?"," — skips ",[18,551,502],{},[189,553,554,549,557,559,560],{},[18,555,556],{},"WHERE c = ?",[18,558,502],{}," and ",[18,561,529],{},[481,563,565],{"id":564},"column-ordering-strategy","Column Ordering Strategy",[567,568,569,578,587,593],"ol",{},[189,570,571,194,574,577],{},[31,572,573],{},"Equality columns first",[18,575,576],{},"WHERE customer_id = ?",").",[189,579,580,194,583,586],{},[31,581,582],{},"Range columns last",[18,584,585],{},"WHERE ordered_on >= ?",") — a range \"uses up\" the index for columns after it.",[189,588,589,592],{},[31,590,591],{},"Sort columns"," after equality, before or at range.",[189,594,595,598],{},[31,596,597],{},"Selectivity"," — high-cardinality columns first (more discriminating).",[14,600,601,602,197,605,608,609,612,613,616,617,620],{},"For ",[18,603,604],{},"WHERE customer_id = ? AND ordered_on >= ?",[18,606,607],{},"(customer_id, ordered_on)"," is optimal. The reverse ",[18,610,611],{},"(ordered_on, customer_id)"," can't efficiently combine both (the range on ",[18,614,615],{},"ordered_on"," blocks using ",[18,618,619],{},"customer_id"," as a seek key).",[23,622,624],{"id":623},"covering-indexes-include","Covering Indexes (INCLUDE)",[14,626,627,628,631],{},"A ",[31,629,630],{},"covering index"," includes all columns a query needs, so the database can answer from the index alone — no heap fetch (index-only scan).",[36,633,634],{"language":38},[40,635,637],{"className":42,"code":636,"language":38,"meta":44,"style":44},"CREATE INDEX orders_cust_date_amt_idx\n  ON orders(customer_id, ordered_on) INCLUDE (amount);\n\n-- This query can be answered entirely from the index (index-only scan).\n-- INCLUDE columns are stored in leaf pages but are NOT part of the sort key.\n-- They don't affect uniqueness or ordering — they're payload for covering scans.\nSELECT customer_id, ordered_on, amount\nFROM orders\nWHERE customer_id = 42;\n\n-- EXPLAIN shows \"Index Only Scan\" — zero heap fetches.\n-- The visibility map must mark the page as all-visible (maintained by VACUUM).\nEXPLAIN SELECT customer_id, ordered_on, amount\nFROM orders WHERE customer_id = 42;\n-- Index Only Scan using orders_cust_date_amt_idx  (cost=0.43..8.45 rows=1 width=...)\n",[18,638,639,648,661,665,670,675,680,687,695,707,711,716,721,729,746],{"__ignoreMap":44},[48,640,641,643,645],{"class":50,"line":51},[48,642,116],{"class":65},[48,644,119],{"class":65},[48,646,647],{"class":122}," orders_cust_date_amt_idx\n",[48,649,650,652,655,658],{"class":50,"line":58},[48,651,275],{"class":65},[48,653,654],{"class":61}," orders(customer_id, ordered_on) ",[48,656,657],{"class":65},"INCLUDE",[48,659,660],{"class":61}," (amount);\n",[48,662,663],{"class":50,"line":94},[48,664,104],{"emptyLinePlaceholder":103},[48,666,667],{"class":50,"line":100},[48,668,669],{"class":54},"-- This query can be answered entirely from the index (index-only scan).\n",[48,671,672],{"class":50,"line":107},[48,673,674],{"class":54},"-- INCLUDE columns are stored in leaf pages but are NOT part of the sort key.\n",[48,676,677],{"class":50,"line":113},[48,678,679],{"class":54},"-- They don't affect uniqueness or ordering — they're payload for covering scans.\n",[48,681,682,684],{"class":50,"line":132},[48,683,66],{"class":65},[48,685,686],{"class":61}," customer_id, ordered_on, amount\n",[48,688,689,692],{"class":50,"line":155},[48,690,691],{"class":65},"FROM",[48,693,694],{"class":61}," orders\n",[48,696,697,699,701,703,705],{"class":50,"line":161},[48,698,78],{"class":65},[48,700,81],{"class":61},[48,702,84],{"class":65},[48,704,88],{"class":87},[48,706,91],{"class":61},[48,708,709],{"class":50,"line":166},[48,710,104],{"emptyLinePlaceholder":103},[48,712,713],{"class":50,"line":445},[48,714,715],{"class":54},"-- EXPLAIN shows \"Index Only Scan\" — zero heap fetches.\n",[48,717,718],{"class":50,"line":451},[48,719,720],{"class":54},"-- The visibility map must mark the page as all-visible (maintained by VACUUM).\n",[48,722,723,725,727],{"class":50,"line":476},[48,724,62],{"class":61},[48,726,66],{"class":65},[48,728,686],{"class":61},[48,730,732,734,736,738,740,742,744],{"class":50,"line":731},14,[48,733,691],{"class":65},[48,735,75],{"class":61},[48,737,78],{"class":65},[48,739,81],{"class":61},[48,741,84],{"class":65},[48,743,88],{"class":87},[48,745,91],{"class":61},[48,747,749],{"class":50,"line":748},15,[48,750,751],{"class":54},"-- Index Only Scan using orders_cust_date_amt_idx  (cost=0.43..8.45 rows=1 width=...)\n",[14,753,754],{},"Index-only scans are dramatically faster than heap fetches, especially on large tables where the heap pages aren't in cache. The heap fetch is often the dominant cost — eliminating it is the single biggest index optimization.",[23,756,758],{"id":757},"partial-indexes","Partial Indexes",[14,760,761,762,764],{},"A partial index only includes rows matching a ",[18,763,78],{}," — smaller, faster, stays hot in cache, and targets a specific query pattern.",[36,766,767],{"language":38},[40,768,770],{"className":42,"code":769,"language":38,"meta":44,"style":44},"-- Index only unshipped orders (a small, hot subset of a 50M-row table).\n-- The full index would be 50M entries; this one is maybe 50K entries → fits in RAM.\nCREATE INDEX orders_unshipped_idx\n  ON orders(customer_id) WHERE status = 'unshipped';\n\n-- The query MUST match the index predicate for the planner to use it.\nSELECT * FROM orders WHERE customer_id = 42 AND status = 'unshipped';\n-- ✅ Uses partial index — predicate matches exactly.\n\nSELECT * FROM orders WHERE customer_id = 42 AND status = 'shipped';\n-- ❌ Cannot use the partial index — predicate doesn't match. Seq scan or other index.\n\n-- Partial unique: only one active session per user.\nCREATE UNIQUE INDEX sessions_active_one\n  ON sessions(user_id) WHERE active = true;\n",[18,771,772,777,782,791,811,815,820,848,853,857,886,891,895,900,910],{"__ignoreMap":44},[48,773,774],{"class":50,"line":51},[48,775,776],{"class":54},"-- Index only unshipped orders (a small, hot subset of a 50M-row table).\n",[48,778,779],{"class":50,"line":58},[48,780,781],{"class":54},"-- The full index would be 50M entries; this one is maybe 50K entries → fits in RAM.\n",[48,783,784,786,788],{"class":50,"line":94},[48,785,116],{"class":65},[48,787,119],{"class":65},[48,789,790],{"class":122}," orders_unshipped_idx\n",[48,792,793,795,798,800,803,806,809],{"class":50,"line":100},[48,794,275],{"class":65},[48,796,797],{"class":61}," orders(customer_id) ",[48,799,78],{"class":65},[48,801,802],{"class":65}," status",[48,804,805],{"class":65}," =",[48,807,808],{"class":401}," 'unshipped'",[48,810,91],{"class":61},[48,812,813],{"class":50,"line":107},[48,814,104],{"emptyLinePlaceholder":103},[48,816,817],{"class":50,"line":113},[48,818,819],{"class":54},"-- The query MUST match the index predicate for the planner to use it.\n",[48,821,822,824,826,828,830,832,834,836,838,840,842,844,846],{"class":50,"line":132},[48,823,66],{"class":65},[48,825,69],{"class":65},[48,827,72],{"class":65},[48,829,75],{"class":61},[48,831,78],{"class":65},[48,833,81],{"class":61},[48,835,84],{"class":65},[48,837,88],{"class":87},[48,839,393],{"class":65},[48,841,802],{"class":65},[48,843,805],{"class":65},[48,845,808],{"class":401},[48,847,91],{"class":61},[48,849,850],{"class":50,"line":155},[48,851,852],{"class":54},"-- ✅ Uses partial index — predicate matches exactly.\n",[48,854,855],{"class":50,"line":161},[48,856,104],{"emptyLinePlaceholder":103},[48,858,859,861,863,865,867,869,871,873,875,877,879,881,884],{"class":50,"line":166},[48,860,66],{"class":65},[48,862,69],{"class":65},[48,864,72],{"class":65},[48,866,75],{"class":61},[48,868,78],{"class":65},[48,870,81],{"class":61},[48,872,84],{"class":65},[48,874,88],{"class":87},[48,876,393],{"class":65},[48,878,802],{"class":65},[48,880,805],{"class":65},[48,882,883],{"class":401}," 'shipped'",[48,885,91],{"class":61},[48,887,888],{"class":50,"line":445},[48,889,890],{"class":54},"-- ❌ Cannot use the partial index — predicate doesn't match. Seq scan or other index.\n",[48,892,893],{"class":50,"line":451},[48,894,104],{"emptyLinePlaceholder":103},[48,896,897],{"class":50,"line":476},[48,898,899],{"class":54},"-- Partial unique: only one active session per user.\n",[48,901,902,904,907],{"class":50,"line":731},[48,903,116],{"class":65},[48,905,906],{"class":65}," UNIQUE INDEX",[48,908,909],{"class":122}," sessions_active_one\n",[48,911,912,914,917,920,922,925,927],{"class":50,"line":748},[48,913,275],{"class":65},[48,915,916],{"class":65}," sessions",[48,918,919],{"class":61},"(user_id) ",[48,921,78],{"class":65},[48,923,924],{"class":61}," active ",[48,926,84],{"class":65},[48,928,929],{"class":61}," true;\n",[14,931,932],{},"Partial indexes are ideal for \"the small interesting subset\" pattern — unshipped orders, pending approvals, active sessions — where the full index would be mostly wasted on the cold majority.",[23,934,936],{"id":935},"expression-indexes","Expression Indexes",[14,938,939],{},"Index the result of an expression — useful for case-insensitive lookups, computed columns, JSON keys:",[36,941,942],{"language":38},[40,943,945],{"className":42,"code":944,"language":38,"meta":44,"style":44},"-- Case-insensitive email lookup.\n-- The index stores lower(email) as the key; the query must use the SAME expression.\nCREATE INDEX users_email_lower_idx ON users(lower(email));\nSELECT * FROM users WHERE lower(email) = 'alice@example.com';  -- ✅ uses index\nSELECT * FROM users WHERE email = 'alice@example.com';          -- ❌ does NOT use index\n\n-- Index a JSON field extraction.\n-- ->> returns text; -> returns jsonb. Index the one your queries use.\nCREATE INDEX events_payload_type_idx ON events((payload->>'type'));\nSELECT * FROM events WHERE payload->>'type' = 'click';  -- ✅ uses index\n\n-- ⚠️ The expression MUST be IMMUTABLE: same input → same output, always.\n-- lower() is immutable. now() is stable (not immutable). Date functions that\n-- depend on timezone are stable, not immutable — can't be used in expression indexes.\n-- ❌ CREATE INDEX ... ON events (created_at::date);  -- ::date depends on DateStyle, not immutable\n-- ✅ CREATE INDEX ... ON events ((created_at AT TIME ZONE 'UTC')::date);  -- immutable with explicit TZ\n",[18,946,947,952,957,974,1004,1029,1033,1038,1043,1066,1095,1099,1104,1109,1114,1119],{"__ignoreMap":44},[48,948,949],{"class":50,"line":51},[48,950,951],{"class":54},"-- Case-insensitive email lookup.\n",[48,953,954],{"class":50,"line":58},[48,955,956],{"class":54},"-- The index stores lower(email) as the key; the query must use the SAME expression.\n",[48,958,959,961,963,965,967,969,971],{"class":50,"line":94},[48,960,116],{"class":65},[48,962,119],{"class":65},[48,964,301],{"class":122},[48,966,126],{"class":65},[48,968,306],{"class":61},[48,970,309],{"class":87},[48,972,973],{"class":61},"(email));\n",[48,975,976,978,980,982,985,987,990,993,995,998,1001],{"class":50,"line":100},[48,977,66],{"class":65},[48,979,69],{"class":65},[48,981,72],{"class":65},[48,983,984],{"class":61}," users ",[48,986,78],{"class":65},[48,988,989],{"class":87}," lower",[48,991,992],{"class":61},"(email) ",[48,994,84],{"class":65},[48,996,997],{"class":401}," 'alice@example.com'",[48,999,1000],{"class":61},";  ",[48,1002,1003],{"class":54},"-- ✅ uses index\n",[48,1005,1006,1008,1010,1012,1014,1016,1019,1021,1023,1026],{"class":50,"line":107},[48,1007,66],{"class":65},[48,1009,69],{"class":65},[48,1011,72],{"class":65},[48,1013,984],{"class":61},[48,1015,78],{"class":65},[48,1017,1018],{"class":61}," email ",[48,1020,84],{"class":65},[48,1022,997],{"class":401},[48,1024,1025],{"class":61},";          ",[48,1027,1028],{"class":54},"-- ❌ does NOT use index\n",[48,1030,1031],{"class":50,"line":113},[48,1032,104],{"emptyLinePlaceholder":103},[48,1034,1035],{"class":50,"line":132},[48,1036,1037],{"class":54},"-- Index a JSON field extraction.\n",[48,1039,1040],{"class":50,"line":155},[48,1041,1042],{"class":54},"-- ->> returns text; -> returns jsonb. Index the one your queries use.\n",[48,1044,1045,1047,1049,1052,1054,1057,1060,1063],{"class":50,"line":161},[48,1046,116],{"class":65},[48,1048,119],{"class":65},[48,1050,1051],{"class":122}," events_payload_type_idx",[48,1053,126],{"class":65},[48,1055,1056],{"class":61}," events((payload",[48,1058,1059],{"class":65},"->>",[48,1061,1062],{"class":401},"'type'",[48,1064,1065],{"class":61},"));\n",[48,1067,1068,1070,1072,1074,1077,1079,1082,1084,1086,1088,1091,1093],{"class":50,"line":166},[48,1069,66],{"class":65},[48,1071,69],{"class":65},[48,1073,72],{"class":65},[48,1075,1076],{"class":61}," events ",[48,1078,78],{"class":65},[48,1080,1081],{"class":61}," payload",[48,1083,1059],{"class":65},[48,1085,1062],{"class":401},[48,1087,805],{"class":65},[48,1089,1090],{"class":401}," 'click'",[48,1092,1000],{"class":61},[48,1094,1003],{"class":54},[48,1096,1097],{"class":50,"line":445},[48,1098,104],{"emptyLinePlaceholder":103},[48,1100,1101],{"class":50,"line":451},[48,1102,1103],{"class":54},"-- ⚠️ The expression MUST be IMMUTABLE: same input → same output, always.\n",[48,1105,1106],{"class":50,"line":476},[48,1107,1108],{"class":54},"-- lower() is immutable. now() is stable (not immutable). Date functions that\n",[48,1110,1111],{"class":50,"line":731},[48,1112,1113],{"class":54},"-- depend on timezone are stable, not immutable — can't be used in expression indexes.\n",[48,1115,1116],{"class":50,"line":748},[48,1117,1118],{"class":54},"-- ❌ CREATE INDEX ... ON events (created_at::date);  -- ::date depends on DateStyle, not immutable\n",[48,1120,1122],{"class":50,"line":1121},16,[48,1123,1124],{"class":54},"-- ✅ CREATE INDEX ... ON events ((created_at AT TIME ZONE 'UTC')::date);  -- immutable with explicit TZ\n",[23,1126,1128],{"id":1127},"index-types","Index Types",[1130,1131,1132,1151],"table",{},[1133,1134,1135],"thead",{},[1136,1137,1138,1142,1145,1148],"tr",{},[1139,1140,1141],"th",{},"Type",[1139,1143,1144],{},"Use Case",[1139,1146,1147],{},"Size",[1139,1149,1150],{},"Write Overhead",[1152,1153,1154,1170,1189,1212,1241,1255],"tbody",{},[1136,1155,1156,1162,1165,1168],{},[1157,1158,1159,1161],"td",{},[31,1160,179],{}," (default)",[1157,1163,1164],{},"Equality, range, sorting. The workhorse.",[1157,1166,1167],{},"Medium",[1157,1169,1167],{},[1136,1171,1172,1178,1183,1186],{},[1157,1173,1174,1177],{},[31,1175,1176],{},"Hash"," (PG)",[1157,1179,1180,1181,577],{},"Equality only (",[18,1182,84],{},[1157,1184,1185],{},"Small",[1157,1187,1188],{},"Low",[1136,1190,1191,1196,1207,1209],{},[1157,1192,1193,1177],{},[31,1194,1195],{},"GiST",[1157,1197,1198,1199,1202,1203,1206],{},"Geometric, range, full-text, custom ops. Used by ",[18,1200,1201],{},"EXCLUDE",", PostGIS, ",[18,1204,1205],{},"pg_trgm",".",[1157,1208,1167],{},[1157,1210,1211],{},"Medium-High",[1136,1213,1214,1219,1235,1238],{},[1157,1215,1216,1177],{},[31,1217,1218],{},"GIN",[1157,1220,1221,1222,1225,1226,197,1229,197,1232,577],{},"Composite values: arrays, JSONB, ",[18,1223,1224],{},"tsvector",". Fast containment (",[18,1227,1228],{},"@>",[18,1230,1231],{},"?",[18,1233,1234],{},"@@",[1157,1236,1237],{},"Large",[1157,1239,1240],{},"High (slow insert)",[1136,1242,1243,1248,1251,1253],{},[1157,1244,1245,1177],{},[31,1246,1247],{},"SP-GiST",[1157,1249,1250],{},"Space-partitioned GiST — tries, quadtrees, non-balanced structures.",[1157,1252,1167],{},[1157,1254,1167],{},[1136,1256,1257,1262,1265,1268],{},[1157,1258,1259,1177],{},[31,1260,1261],{},"BRIN",[1157,1263,1264],{},"Block Range Index — tiny, min\u002Fmax per block range. For huge append-only tables.",[1157,1266,1267],{},"Tiny",[1157,1269,1270],{},"Very Low",[481,1272,1274],{"id":1273},"gin-for-jsonb-and-arrays","GIN for JSONB and Arrays",[36,1276,1277],{"language":38},[40,1278,1280],{"className":42,"code":1279,"language":38,"meta":44,"style":44},"-- GIN index on JSONB: indexes every key\u002Fpath in the column.\n-- The default jsonb_path_ops operator class is smaller but only supports @>.\nCREATE INDEX events_payload_gin ON events USING gin(payload jsonb_path_ops);\n\n-- Containment query uses the index (most JSONB-friendly operator):\nSELECT * FROM events WHERE payload @> '{\"type\": \"click\"}';\n-- ✅ GIN index scan — fast.\n\n-- Key existence:\nSELECT * FROM events WHERE payload ? 'user_id';\n-- ✅ GIN index scan (with default opclass, not jsonb_path_ops).\n\n-- Text extraction — needs a separate expression index, NOT the GIN:\nSELECT * FROM events WHERE payload->>'type' = 'click';\n-- ❌ Does NOT use the GIN index. Create: CREATE INDEX ... ON events((payload->>'type'));\n\n-- GIN on arrays:\nCREATE INDEX tags_idx ON articles USING gin(tags);\nSELECT * FROM articles WHERE tags @> ARRAY['postgres','indexing'];\n-- ✅ GIN index scan — checks containment in the array.\n",[18,1281,1282,1287,1292,1311,1315,1320,1342,1347,1351,1356,1376,1381,1385,1390,1414,1419,1423,1429,1449,1473],{"__ignoreMap":44},[48,1283,1284],{"class":50,"line":51},[48,1285,1286],{"class":54},"-- GIN index on JSONB: indexes every key\u002Fpath in the column.\n",[48,1288,1289],{"class":50,"line":58},[48,1290,1291],{"class":54},"-- The default jsonb_path_ops operator class is smaller but only supports @>.\n",[48,1293,1294,1296,1298,1301,1303,1305,1308],{"class":50,"line":94},[48,1295,116],{"class":65},[48,1297,119],{"class":65},[48,1299,1300],{"class":122}," events_payload_gin",[48,1302,126],{"class":65},[48,1304,1076],{"class":61},[48,1306,1307],{"class":65},"USING",[48,1309,1310],{"class":61}," gin(payload jsonb_path_ops);\n",[48,1312,1313],{"class":50,"line":100},[48,1314,104],{"emptyLinePlaceholder":103},[48,1316,1317],{"class":50,"line":107},[48,1318,1319],{"class":54},"-- Containment query uses the index (most JSONB-friendly operator):\n",[48,1321,1322,1324,1326,1328,1330,1332,1335,1337,1340],{"class":50,"line":113},[48,1323,66],{"class":65},[48,1325,69],{"class":65},[48,1327,72],{"class":65},[48,1329,1076],{"class":61},[48,1331,78],{"class":65},[48,1333,1334],{"class":61}," payload @",[48,1336,215],{"class":65},[48,1338,1339],{"class":401}," '{\"type\": \"click\"}'",[48,1341,91],{"class":61},[48,1343,1344],{"class":50,"line":132},[48,1345,1346],{"class":54},"-- ✅ GIN index scan — fast.\n",[48,1348,1349],{"class":50,"line":155},[48,1350,104],{"emptyLinePlaceholder":103},[48,1352,1353],{"class":50,"line":161},[48,1354,1355],{"class":54},"-- Key existence:\n",[48,1357,1358,1360,1362,1364,1366,1368,1371,1374],{"class":50,"line":166},[48,1359,66],{"class":65},[48,1361,69],{"class":65},[48,1363,72],{"class":65},[48,1365,1076],{"class":61},[48,1367,78],{"class":65},[48,1369,1370],{"class":61}," payload ? ",[48,1372,1373],{"class":401},"'user_id'",[48,1375,91],{"class":61},[48,1377,1378],{"class":50,"line":445},[48,1379,1380],{"class":54},"-- ✅ GIN index scan (with default opclass, not jsonb_path_ops).\n",[48,1382,1383],{"class":50,"line":451},[48,1384,104],{"emptyLinePlaceholder":103},[48,1386,1387],{"class":50,"line":476},[48,1388,1389],{"class":54},"-- Text extraction — needs a separate expression index, NOT the GIN:\n",[48,1391,1392,1394,1396,1398,1400,1402,1404,1406,1408,1410,1412],{"class":50,"line":731},[48,1393,66],{"class":65},[48,1395,69],{"class":65},[48,1397,72],{"class":65},[48,1399,1076],{"class":61},[48,1401,78],{"class":65},[48,1403,1081],{"class":61},[48,1405,1059],{"class":65},[48,1407,1062],{"class":401},[48,1409,805],{"class":65},[48,1411,1090],{"class":401},[48,1413,91],{"class":61},[48,1415,1416],{"class":50,"line":748},[48,1417,1418],{"class":54},"-- ❌ Does NOT use the GIN index. Create: CREATE INDEX ... ON events((payload->>'type'));\n",[48,1420,1421],{"class":50,"line":1121},[48,1422,104],{"emptyLinePlaceholder":103},[48,1424,1426],{"class":50,"line":1425},17,[48,1427,1428],{"class":54},"-- GIN on arrays:\n",[48,1430,1432,1434,1436,1439,1441,1444,1446],{"class":50,"line":1431},18,[48,1433,116],{"class":65},[48,1435,119],{"class":65},[48,1437,1438],{"class":122}," tags_idx",[48,1440,126],{"class":65},[48,1442,1443],{"class":61}," articles ",[48,1445,1307],{"class":65},[48,1447,1448],{"class":61}," gin(tags);\n",[48,1450,1452,1454,1456,1458,1460,1462,1465,1467,1470],{"class":50,"line":1451},19,[48,1453,66],{"class":65},[48,1455,69],{"class":65},[48,1457,72],{"class":65},[48,1459,1443],{"class":61},[48,1461,78],{"class":65},[48,1463,1464],{"class":61}," tags @",[48,1466,215],{"class":65},[48,1468,1469],{"class":65}," ARRAY",[48,1471,1472],{"class":61},"['postgres','indexing'];\n",[48,1474,1476],{"class":50,"line":1475},20,[48,1477,1478],{"class":54},"-- ✅ GIN index scan — checks containment in the array.\n",[481,1480,1482],{"id":1481},"brin-for-time-series","BRIN for Time-Series",[36,1484,1485],{"language":38},[40,1486,1488],{"className":42,"code":1487,"language":38,"meta":44,"style":44},"-- BRIN stores only min\u002Fmax per block range (~128 pages, ~1MB default).\n-- For naturally-ordered data (time-series logs), consecutive blocks have\n-- narrow ranges → range queries skip entire block ranges.\n-- A BRIN index on a billion-row table is a few KB vs. GB for a B-tree.\nCREATE INDEX logs_ts_brin ON logs USING brin(ts) WITH (pages_per_range = 32);\n\nSELECT * FROM logs WHERE ts BETWEEN '2024-01-01' AND '2024-01-02';\n-- BRIN index scan: skips block ranges whose min\u002Fmax don't overlap the query range.\n-- For random data, every block has wide min\u002Fmax → BRIN skips nothing → useless.\n",[18,1489,1490,1495,1500,1505,1510,1543,1547,1573,1578],{"__ignoreMap":44},[48,1491,1492],{"class":50,"line":51},[48,1493,1494],{"class":54},"-- BRIN stores only min\u002Fmax per block range (~128 pages, ~1MB default).\n",[48,1496,1497],{"class":50,"line":58},[48,1498,1499],{"class":54},"-- For naturally-ordered data (time-series logs), consecutive blocks have\n",[48,1501,1502],{"class":50,"line":94},[48,1503,1504],{"class":54},"-- narrow ranges → range queries skip entire block ranges.\n",[48,1506,1507],{"class":50,"line":100},[48,1508,1509],{"class":54},"-- A BRIN index on a billion-row table is a few KB vs. GB for a B-tree.\n",[48,1511,1512,1514,1516,1519,1521,1524,1526,1529,1532,1535,1537,1540],{"class":50,"line":107},[48,1513,116],{"class":65},[48,1515,119],{"class":65},[48,1517,1518],{"class":122}," logs_ts_brin",[48,1520,126],{"class":65},[48,1522,1523],{"class":61}," logs ",[48,1525,1307],{"class":65},[48,1527,1528],{"class":61}," brin(ts) ",[48,1530,1531],{"class":65},"WITH",[48,1533,1534],{"class":61}," (pages_per_range ",[48,1536,84],{"class":65},[48,1538,1539],{"class":87}," 32",[48,1541,1542],{"class":61},");\n",[48,1544,1545],{"class":50,"line":113},[48,1546,104],{"emptyLinePlaceholder":103},[48,1548,1549,1551,1553,1555,1557,1559,1562,1564,1566,1568,1571],{"class":50,"line":132},[48,1550,66],{"class":65},[48,1552,69],{"class":65},[48,1554,72],{"class":65},[48,1556,1523],{"class":61},[48,1558,78],{"class":65},[48,1560,1561],{"class":61}," ts ",[48,1563,221],{"class":65},[48,1565,402],{"class":401},[48,1567,393],{"class":65},[48,1569,1570],{"class":401}," '2024-01-02'",[48,1572,91],{"class":61},[48,1574,1575],{"class":50,"line":155},[48,1576,1577],{"class":54},"-- BRIN index scan: skips block ranges whose min\u002Fmax don't overlap the query range.\n",[48,1579,1580],{"class":50,"line":161},[48,1581,1582],{"class":54},"-- For random data, every block has wide min\u002Fmax → BRIN skips nothing → useless.\n",[23,1584,1586],{"id":1585},"when-indexes-hurt","When Indexes Hurt",[14,1588,1589],{},"Indexes aren't free. Every index adds overhead:",[567,1591,1592,1613,1619,1625],{},[189,1593,1594,1597,1598,1601,1602,1601,1605,1608,1609,1612],{},[31,1595,1596],{},"Write amplification",": every ",[18,1599,1600],{},"INSERT","\u002F",[18,1603,1604],{},"UPDATE",[18,1606,1607],{},"DELETE"," updates every index on the table. A table with 10 indexes takes ~10x longer to write than one with 0. ",[18,1610,1611],{},"HOT"," (Heap-Only Tuple) updates can skip index updates if no indexed column changed and the new tuple fits on the same page.",[189,1614,1615,1618],{},[31,1616,1617],{},"Storage",": indexes take disk space — sometimes more than the table. GIN indexes on JSONB can be 2-3x the table size.",[189,1620,1621,1624],{},[31,1622,1623],{},"Planner overhead",": too many indexes give the planner more plans to consider, slowing planning.",[189,1626,1627,1630],{},[31,1628,1629],{},"Unused indexes",": an index never used for reads still slows writes. Find and drop them:",[36,1632,1633],{"language":38},[40,1634,1636],{"className":42,"code":1635,"language":38,"meta":44,"style":44},"-- PostgreSQL: find unused indexes (since last stats reset).\n-- idx_scan = 0 means the planner never chose this index for any query.\nSELECT relname      AS table_name,\n       indexrelname AS index_name,\n       idx_scan     AS scans,\n       pg_size_pretty(pg_relation_size(indexrelid)) AS size\nFROM pg_stat_user_indexes\nJOIN pg_index ON pg_index.indexrelid = pg_stat_user_indexes.indexrelid\nWHERE idx_scan = 0\n  AND indisunique = false    -- don't suggest dropping unique constraints\n  AND indisprimary = false   -- don't suggest dropping PKs\nORDER BY pg_relation_size(indexrelid) DESC;\n\n-- Also check duplicate indexes (same columns, same opclass):\nSELECT pg_get_indexdef(indexrelid) FROM pg_index WHERE indrelid = 'orders'::regclass;\n",[18,1637,1638,1643,1648,1661,1671,1681,1691,1698,1727,1739,1755,1770,1783,1787,1792],{"__ignoreMap":44},[48,1639,1640],{"class":50,"line":51},[48,1641,1642],{"class":54},"-- PostgreSQL: find unused indexes (since last stats reset).\n",[48,1644,1645],{"class":50,"line":58},[48,1646,1647],{"class":54},"-- idx_scan = 0 means the planner never chose this index for any query.\n",[48,1649,1650,1652,1655,1658],{"class":50,"line":94},[48,1651,66],{"class":65},[48,1653,1654],{"class":61}," relname      ",[48,1656,1657],{"class":65},"AS",[48,1659,1660],{"class":61}," table_name,\n",[48,1662,1663,1666,1668],{"class":50,"line":100},[48,1664,1665],{"class":61},"       indexrelname ",[48,1667,1657],{"class":65},[48,1669,1670],{"class":61}," index_name,\n",[48,1672,1673,1676,1678],{"class":50,"line":107},[48,1674,1675],{"class":61},"       idx_scan     ",[48,1677,1657],{"class":65},[48,1679,1680],{"class":61}," scans,\n",[48,1682,1683,1686,1688],{"class":50,"line":113},[48,1684,1685],{"class":61},"       pg_size_pretty(pg_relation_size(indexrelid)) ",[48,1687,1657],{"class":65},[48,1689,1690],{"class":65}," size\n",[48,1692,1693,1695],{"class":50,"line":132},[48,1694,691],{"class":65},[48,1696,1697],{"class":61}," pg_stat_user_indexes\n",[48,1699,1700,1703,1706,1709,1712,1714,1717,1719,1722,1724],{"class":50,"line":155},[48,1701,1702],{"class":65},"JOIN",[48,1704,1705],{"class":61}," pg_index ",[48,1707,1708],{"class":65},"ON",[48,1710,1711],{"class":87}," pg_index",[48,1713,1206],{"class":61},[48,1715,1716],{"class":87},"indexrelid",[48,1718,805],{"class":65},[48,1720,1721],{"class":87}," pg_stat_user_indexes",[48,1723,1206],{"class":61},[48,1725,1726],{"class":87},"indexrelid\n",[48,1728,1729,1731,1734,1736],{"class":50,"line":161},[48,1730,78],{"class":65},[48,1732,1733],{"class":61}," idx_scan ",[48,1735,84],{"class":65},[48,1737,1738],{"class":87}," 0\n",[48,1740,1741,1744,1747,1749,1752],{"class":50,"line":166},[48,1742,1743],{"class":65},"  AND",[48,1745,1746],{"class":61}," indisunique ",[48,1748,84],{"class":65},[48,1750,1751],{"class":61}," false    ",[48,1753,1754],{"class":54},"-- don't suggest dropping unique constraints\n",[48,1756,1757,1759,1762,1764,1767],{"class":50,"line":445},[48,1758,1743],{"class":65},[48,1760,1761],{"class":61}," indisprimary ",[48,1763,84],{"class":65},[48,1765,1766],{"class":61}," false   ",[48,1768,1769],{"class":54},"-- don't suggest dropping PKs\n",[48,1771,1772,1775,1778,1781],{"class":50,"line":451},[48,1773,1774],{"class":65},"ORDER BY",[48,1776,1777],{"class":61}," pg_relation_size(indexrelid) ",[48,1779,1780],{"class":65},"DESC",[48,1782,91],{"class":61},[48,1784,1785],{"class":50,"line":476},[48,1786,104],{"emptyLinePlaceholder":103},[48,1788,1789],{"class":50,"line":731},[48,1790,1791],{"class":54},"-- Also check duplicate indexes (same columns, same opclass):\n",[48,1793,1794,1796,1799,1801,1803,1805,1808,1810,1813],{"class":50,"line":748},[48,1795,66],{"class":65},[48,1797,1798],{"class":61}," pg_get_indexdef(indexrelid) ",[48,1800,691],{"class":65},[48,1802,1705],{"class":61},[48,1804,78],{"class":65},[48,1806,1807],{"class":61}," indrelid ",[48,1809,84],{"class":65},[48,1811,1812],{"class":401}," 'orders'",[48,1814,1815],{"class":61},"::regclass;\n",[14,1817,1818,1821,1822,1826],{},[31,1819,1820],{},"Rule of thumb",": index for the queries you actually run, not every column you ",[1823,1824,1825],"em",{},"might"," filter on. Drop indexes that haven't been used in a representative period.",[23,1828,1830],{"id":1829},"explain-basics","EXPLAIN Basics",[14,1832,1833,1835,1836,1839],{},[18,1834,20],{}," shows the query plan; ",[18,1837,1838],{},"EXPLAIN ANALYZE"," executes it and shows actual timings. Learn to read these — they're the only way to know if an index is actually used.",[36,1841,1842],{"language":38},[40,1843,1845],{"className":42,"code":1844,"language":38,"meta":44,"style":44},"-- EXPLAIN: planner's estimate (no execution).\nEXPLAIN SELECT * FROM orders WHERE customer_id = 42;\n-- Seq Scan on orders  (cost=0.00..183334.00 rows=5263 width=...)\n-- ^ If rows estimate is close to total rows, planner chooses seq scan on purpose\n--   (cheaper than random heap fetches for a large fraction of the table).\n\n-- EXPLAIN ANALYZE: actual execution with timings (modifies data for DML).\nEXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;\n-- Index Scan using orders_customer_id_idx on orders\n--   (cost=0.43..63.27 rows=53 width=...) (actual time=0.015..0.042 rows=53 loops=1)\n--   Index Cond: (customer_id = 42)\n-- Planning Time: 0.083 ms\n-- Execution Time: 0.065 ms\n\n-- BUFFERS: shows buffer (cache) hit\u002Fmiss — critical for understanding I\u002FO.\nEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;\n-- Buffers: shared hit=4 read=1  → 4 pages from cache, 1 from disk\n",[18,1846,1847,1852,1874,1878,1883,1888,1892,1897,1920,1925,1930,1935,1940,1945,1949,1954,1977],{"__ignoreMap":44},[48,1848,1849],{"class":50,"line":51},[48,1850,1851],{"class":54},"-- EXPLAIN: planner's estimate (no execution).\n",[48,1853,1854,1856,1858,1860,1862,1864,1866,1868,1870,1872],{"class":50,"line":58},[48,1855,62],{"class":61},[48,1857,66],{"class":65},[48,1859,69],{"class":65},[48,1861,72],{"class":65},[48,1863,75],{"class":61},[48,1865,78],{"class":65},[48,1867,81],{"class":61},[48,1869,84],{"class":65},[48,1871,88],{"class":87},[48,1873,91],{"class":61},[48,1875,1876],{"class":50,"line":94},[48,1877,97],{"class":54},[48,1879,1880],{"class":50,"line":100},[48,1881,1882],{"class":54},"-- ^ If rows estimate is close to total rows, planner chooses seq scan on purpose\n",[48,1884,1885],{"class":50,"line":107},[48,1886,1887],{"class":54},"--   (cheaper than random heap fetches for a large fraction of the table).\n",[48,1889,1890],{"class":50,"line":113},[48,1891,104],{"emptyLinePlaceholder":103},[48,1893,1894],{"class":50,"line":132},[48,1895,1896],{"class":54},"-- EXPLAIN ANALYZE: actual execution with timings (modifies data for DML).\n",[48,1898,1899,1902,1904,1906,1908,1910,1912,1914,1916,1918],{"class":50,"line":155},[48,1900,1901],{"class":61},"EXPLAIN ANALYZE ",[48,1903,66],{"class":65},[48,1905,69],{"class":65},[48,1907,72],{"class":65},[48,1909,75],{"class":61},[48,1911,78],{"class":65},[48,1913,81],{"class":61},[48,1915,84],{"class":65},[48,1917,88],{"class":87},[48,1919,91],{"class":61},[48,1921,1922],{"class":50,"line":161},[48,1923,1924],{"class":54},"-- Index Scan using orders_customer_id_idx on orders\n",[48,1926,1927],{"class":50,"line":166},[48,1928,1929],{"class":54},"--   (cost=0.43..63.27 rows=53 width=...) (actual time=0.015..0.042 rows=53 loops=1)\n",[48,1931,1932],{"class":50,"line":445},[48,1933,1934],{"class":54},"--   Index Cond: (customer_id = 42)\n",[48,1936,1937],{"class":50,"line":451},[48,1938,1939],{"class":54},"-- Planning Time: 0.083 ms\n",[48,1941,1942],{"class":50,"line":476},[48,1943,1944],{"class":54},"-- Execution Time: 0.065 ms\n",[48,1946,1947],{"class":50,"line":731},[48,1948,104],{"emptyLinePlaceholder":103},[48,1950,1951],{"class":50,"line":748},[48,1952,1953],{"class":54},"-- BUFFERS: shows buffer (cache) hit\u002Fmiss — critical for understanding I\u002FO.\n",[48,1955,1956,1959,1961,1963,1965,1967,1969,1971,1973,1975],{"class":50,"line":1121},[48,1957,1958],{"class":61},"EXPLAIN (ANALYZE, BUFFERS) ",[48,1960,66],{"class":65},[48,1962,69],{"class":65},[48,1964,72],{"class":65},[48,1966,75],{"class":61},[48,1968,78],{"class":65},[48,1970,81],{"class":61},[48,1972,84],{"class":65},[48,1974,88],{"class":87},[48,1976,91],{"class":61},[48,1978,1979],{"class":50,"line":1425},[48,1980,1981],{"class":54},"-- Buffers: shared hit=4 read=1  → 4 pages from cache, 1 from disk\n",[14,1983,1984],{},"Key plan node types:",[186,1986,1987,1993,1999,2005],{},[189,1988,1989,1992],{},[31,1990,1991],{},"Seq Scan",": reads the whole table. Fine for small tables or when the query matches a large fraction of rows.",[189,1994,1995,1998],{},[31,1996,1997],{},"Index Scan",": reads the index, then fetches matching heap tuples. Good for selective queries.",[189,2000,2001,2004],{},[31,2002,2003],{},"Index Only Scan",": reads only the index, no heap fetch. Best case — needs all-visible pages.",[189,2006,2007,2010],{},[31,2008,2009],{},"Bitmap Index Scan + Bitmap Heap Scan",": index produces a bitmap of matching tuple IDs, then heap scan fetches them in physical order. Good for medium selectivity (avoids random I\u002FO of pure index scan).",[23,2012,2014],{"id":2013},"complex-implementation-production-indexing-strategy","Complex Implementation: Production Indexing Strategy",[36,2016,2017],{"language":38},[40,2018,2020],{"className":42,"code":2019,"language":38,"meta":44,"style":44},"-- A multi-column query pattern on a 200M-row orders table.\n-- Query patterns:\n--   1. WHERE customer_id = ? AND status = ? AND ordered_on >= ?\n--   2. WHERE customer_id = ? AND ordered_on >= ?  (no status filter)\n--   3. WHERE status = 'pending' (small hot subset)\n\n-- Index 1: composite for pattern 1 & 2.\n-- customer_id (equality) first, ordered_on (range) last, status in middle.\n-- INCLUDE amount for covering index-only scan on the common SELECT.\nCREATE INDEX orders_cust_date_status_idx\n  ON orders(customer_id, ordered_on, status) INCLUDE (amount);\n\n-- Pattern 1: WHERE customer_id = ? AND ordered_on >= ? AND status = ?\n-- → Index Scan using all 3 key columns, amount from INCLUDE. Index-only scan.\n\n-- Pattern 2: WHERE customer_id = ? AND ordered_on >= ?\n-- → Index Scan using customer_id + ordered_on (leftmost prefix), status as filter.\n--   Not index-only if status is in SELECT (it's a key, so it IS available).\n\n-- Index 2: partial for pattern 3 (small hot subset — pending orders only).\nCREATE INDEX orders_pending_idx\n  ON orders(customer_id, ordered_on) WHERE status = 'pending';\n-- Much smaller than full index, stays hot in cache.\n\n-- Verify with EXPLAIN:\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT customer_id, ordered_on, amount\nFROM orders\nWHERE customer_id = 42 AND ordered_on >= '2024-01-01' AND status = 'shipped';\n-- Index Only Scan using orders_cust_date_status_idx\n--   Index Cond: (customer_id = 42 AND ordered_on >= '2024-01-01')\n--   Filter: (status = 'shipped')\n--   Buffers: shared hit=5\n",[18,2021,2022,2027,2032,2037,2042,2047,2051,2056,2061,2066,2075,2092,2096,2101,2106,2110,2115,2120,2125,2129,2134,2144,2162,2168,2173,2179,2185,2192,2199,2228,2234,2240,2246],{"__ignoreMap":44},[48,2023,2024],{"class":50,"line":51},[48,2025,2026],{"class":54},"-- A multi-column query pattern on a 200M-row orders table.\n",[48,2028,2029],{"class":50,"line":58},[48,2030,2031],{"class":54},"-- Query patterns:\n",[48,2033,2034],{"class":50,"line":94},[48,2035,2036],{"class":54},"--   1. WHERE customer_id = ? AND status = ? AND ordered_on >= ?\n",[48,2038,2039],{"class":50,"line":100},[48,2040,2041],{"class":54},"--   2. WHERE customer_id = ? AND ordered_on >= ?  (no status filter)\n",[48,2043,2044],{"class":50,"line":107},[48,2045,2046],{"class":54},"--   3. WHERE status = 'pending' (small hot subset)\n",[48,2048,2049],{"class":50,"line":113},[48,2050,104],{"emptyLinePlaceholder":103},[48,2052,2053],{"class":50,"line":132},[48,2054,2055],{"class":54},"-- Index 1: composite for pattern 1 & 2.\n",[48,2057,2058],{"class":50,"line":155},[48,2059,2060],{"class":54},"-- customer_id (equality) first, ordered_on (range) last, status in middle.\n",[48,2062,2063],{"class":50,"line":161},[48,2064,2065],{"class":54},"-- INCLUDE amount for covering index-only scan on the common SELECT.\n",[48,2067,2068,2070,2072],{"class":50,"line":166},[48,2069,116],{"class":65},[48,2071,119],{"class":65},[48,2073,2074],{"class":122}," orders_cust_date_status_idx\n",[48,2076,2077,2079,2082,2085,2088,2090],{"class":50,"line":445},[48,2078,275],{"class":65},[48,2080,2081],{"class":61}," orders(customer_id, ordered_on, ",[48,2083,2084],{"class":65},"status",[48,2086,2087],{"class":61},") ",[48,2089,657],{"class":65},[48,2091,660],{"class":61},[48,2093,2094],{"class":50,"line":451},[48,2095,104],{"emptyLinePlaceholder":103},[48,2097,2098],{"class":50,"line":476},[48,2099,2100],{"class":54},"-- Pattern 1: WHERE customer_id = ? AND ordered_on >= ? AND status = ?\n",[48,2102,2103],{"class":50,"line":731},[48,2104,2105],{"class":54},"-- → Index Scan using all 3 key columns, amount from INCLUDE. Index-only scan.\n",[48,2107,2108],{"class":50,"line":748},[48,2109,104],{"emptyLinePlaceholder":103},[48,2111,2112],{"class":50,"line":1121},[48,2113,2114],{"class":54},"-- Pattern 2: WHERE customer_id = ? AND ordered_on >= ?\n",[48,2116,2117],{"class":50,"line":1425},[48,2118,2119],{"class":54},"-- → Index Scan using customer_id + ordered_on (leftmost prefix), status as filter.\n",[48,2121,2122],{"class":50,"line":1431},[48,2123,2124],{"class":54},"--   Not index-only if status is in SELECT (it's a key, so it IS available).\n",[48,2126,2127],{"class":50,"line":1451},[48,2128,104],{"emptyLinePlaceholder":103},[48,2130,2131],{"class":50,"line":1475},[48,2132,2133],{"class":54},"-- Index 2: partial for pattern 3 (small hot subset — pending orders only).\n",[48,2135,2137,2139,2141],{"class":50,"line":2136},21,[48,2138,116],{"class":65},[48,2140,119],{"class":65},[48,2142,2143],{"class":122}," orders_pending_idx\n",[48,2145,2147,2149,2151,2153,2155,2157,2160],{"class":50,"line":2146},22,[48,2148,275],{"class":65},[48,2150,654],{"class":61},[48,2152,78],{"class":65},[48,2154,802],{"class":65},[48,2156,805],{"class":65},[48,2158,2159],{"class":401}," 'pending'",[48,2161,91],{"class":61},[48,2163,2165],{"class":50,"line":2164},23,[48,2166,2167],{"class":54},"-- Much smaller than full index, stays hot in cache.\n",[48,2169,2171],{"class":50,"line":2170},24,[48,2172,104],{"emptyLinePlaceholder":103},[48,2174,2176],{"class":50,"line":2175},25,[48,2177,2178],{"class":54},"-- Verify with EXPLAIN:\n",[48,2180,2182],{"class":50,"line":2181},26,[48,2183,2184],{"class":61},"EXPLAIN (ANALYZE, BUFFERS)\n",[48,2186,2188,2190],{"class":50,"line":2187},27,[48,2189,66],{"class":65},[48,2191,686],{"class":61},[48,2193,2195,2197],{"class":50,"line":2194},28,[48,2196,691],{"class":65},[48,2198,694],{"class":61},[48,2200,2202,2204,2206,2208,2210,2212,2214,2216,2218,2220,2222,2224,2226],{"class":50,"line":2201},29,[48,2203,78],{"class":65},[48,2205,81],{"class":61},[48,2207,84],{"class":65},[48,2209,88],{"class":87},[48,2211,393],{"class":65},[48,2213,396],{"class":61},[48,2215,218],{"class":65},[48,2217,402],{"class":401},[48,2219,393],{"class":65},[48,2221,802],{"class":65},[48,2223,805],{"class":65},[48,2225,883],{"class":401},[48,2227,91],{"class":61},[48,2229,2231],{"class":50,"line":2230},30,[48,2232,2233],{"class":54},"-- Index Only Scan using orders_cust_date_status_idx\n",[48,2235,2237],{"class":50,"line":2236},31,[48,2238,2239],{"class":54},"--   Index Cond: (customer_id = 42 AND ordered_on >= '2024-01-01')\n",[48,2241,2243],{"class":50,"line":2242},32,[48,2244,2245],{"class":54},"--   Filter: (status = 'shipped')\n",[48,2247,2249],{"class":50,"line":2248},33,[48,2250,2251],{"class":54},"--   Buffers: shared hit=5\n",[23,2253,2255],{"id":2254},"anti-pattern-indexing-without-checking-explain","Anti-Pattern: Indexing Without Checking EXPLAIN",[36,2257,2258],{"language":38},[40,2259,2261],{"className":42,"code":2260,"language":38,"meta":44,"style":44},"-- ❌ WRONG: adding indexes blindly \"just in case\" on every column.\n-- This table has 12 indexes, most unused. Every INSERT updates 12 indexes.\nCREATE INDEX idx1 ON orders(customer_id);\nCREATE INDEX idx2 ON orders(status);\nCREATE INDEX idx3 ON orders(ordered_on);\nCREATE INDEX idx4 ON orders(amount);\nCREATE INDEX idx5 ON orders(customer_id, status);\n-- ... 7 more ...\n\n-- The planner may not even use most of these — and write performance is 12x worse.\n\n-- ✅ CORRECT: measure first, index for actual query patterns.\n-- Step 1: run EXPLAIN ANALYZE on slow queries.\nEXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';\n-- Seq Scan on orders (actual time=1234.567..5678.901 rows=53)\n\n-- Step 2: design the minimal index set that covers the query patterns.\nCREATE INDEX orders_cust_status_idx ON orders(customer_id, status);\n\n-- Step 3: verify the index is used.\nEXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';\n-- Index Scan using orders_cust_status_idx (actual time=0.015..0.042 rows=53)\n\n-- Step 4: monitor usage over time and drop unused indexes.\nSELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE relname = 'orders';\n",[18,2262,2263,2268,2273,2286,2304,2317,2331,2349,2354,2358,2363,2367,2372,2377,2407,2412,2416,2421,2438,2442,2447,2477,2482,2486,2491],{"__ignoreMap":44},[48,2264,2265],{"class":50,"line":51},[48,2266,2267],{"class":54},"-- ❌ WRONG: adding indexes blindly \"just in case\" on every column.\n",[48,2269,2270],{"class":50,"line":58},[48,2271,2272],{"class":54},"-- This table has 12 indexes, most unused. Every INSERT updates 12 indexes.\n",[48,2274,2275,2277,2279,2282,2284],{"class":50,"line":94},[48,2276,116],{"class":65},[48,2278,119],{"class":65},[48,2280,2281],{"class":122}," idx1",[48,2283,126],{"class":65},[48,2285,129],{"class":61},[48,2287,2288,2290,2292,2295,2297,2300,2302],{"class":50,"line":100},[48,2289,116],{"class":65},[48,2291,119],{"class":65},[48,2293,2294],{"class":122}," idx2",[48,2296,126],{"class":65},[48,2298,2299],{"class":61}," orders(",[48,2301,2084],{"class":65},[48,2303,1542],{"class":61},[48,2305,2306,2308,2310,2313,2315],{"class":50,"line":107},[48,2307,116],{"class":65},[48,2309,119],{"class":65},[48,2311,2312],{"class":122}," idx3",[48,2314,126],{"class":65},[48,2316,292],{"class":61},[48,2318,2319,2321,2323,2326,2328],{"class":50,"line":113},[48,2320,116],{"class":65},[48,2322,119],{"class":65},[48,2324,2325],{"class":122}," idx4",[48,2327,126],{"class":65},[48,2329,2330],{"class":61}," orders(amount);\n",[48,2332,2333,2335,2337,2340,2342,2345,2347],{"class":50,"line":132},[48,2334,116],{"class":65},[48,2336,119],{"class":65},[48,2338,2339],{"class":122}," idx5",[48,2341,126],{"class":65},[48,2343,2344],{"class":61}," orders(customer_id, ",[48,2346,2084],{"class":65},[48,2348,1542],{"class":61},[48,2350,2351],{"class":50,"line":155},[48,2352,2353],{"class":54},"-- ... 7 more ...\n",[48,2355,2356],{"class":50,"line":161},[48,2357,104],{"emptyLinePlaceholder":103},[48,2359,2360],{"class":50,"line":166},[48,2361,2362],{"class":54},"-- The planner may not even use most of these — and write performance is 12x worse.\n",[48,2364,2365],{"class":50,"line":445},[48,2366,104],{"emptyLinePlaceholder":103},[48,2368,2369],{"class":50,"line":451},[48,2370,2371],{"class":54},"-- ✅ CORRECT: measure first, index for actual query patterns.\n",[48,2373,2374],{"class":50,"line":476},[48,2375,2376],{"class":54},"-- Step 1: run EXPLAIN ANALYZE on slow queries.\n",[48,2378,2379,2381,2383,2385,2387,2389,2391,2393,2395,2397,2399,2401,2403,2405],{"class":50,"line":731},[48,2380,1901],{"class":61},[48,2382,66],{"class":65},[48,2384,69],{"class":65},[48,2386,72],{"class":65},[48,2388,75],{"class":61},[48,2390,78],{"class":65},[48,2392,81],{"class":61},[48,2394,84],{"class":65},[48,2396,88],{"class":87},[48,2398,393],{"class":65},[48,2400,802],{"class":65},[48,2402,805],{"class":65},[48,2404,2159],{"class":401},[48,2406,91],{"class":61},[48,2408,2409],{"class":50,"line":748},[48,2410,2411],{"class":54},"-- Seq Scan on orders (actual time=1234.567..5678.901 rows=53)\n",[48,2413,2414],{"class":50,"line":1121},[48,2415,104],{"emptyLinePlaceholder":103},[48,2417,2418],{"class":50,"line":1425},[48,2419,2420],{"class":54},"-- Step 2: design the minimal index set that covers the query patterns.\n",[48,2422,2423,2425,2427,2430,2432,2434,2436],{"class":50,"line":1431},[48,2424,116],{"class":65},[48,2426,119],{"class":65},[48,2428,2429],{"class":122}," orders_cust_status_idx",[48,2431,126],{"class":65},[48,2433,2344],{"class":61},[48,2435,2084],{"class":65},[48,2437,1542],{"class":61},[48,2439,2440],{"class":50,"line":1451},[48,2441,104],{"emptyLinePlaceholder":103},[48,2443,2444],{"class":50,"line":1475},[48,2445,2446],{"class":54},"-- Step 3: verify the index is used.\n",[48,2448,2449,2451,2453,2455,2457,2459,2461,2463,2465,2467,2469,2471,2473,2475],{"class":50,"line":2136},[48,2450,1901],{"class":61},[48,2452,66],{"class":65},[48,2454,69],{"class":65},[48,2456,72],{"class":65},[48,2458,75],{"class":61},[48,2460,78],{"class":65},[48,2462,81],{"class":61},[48,2464,84],{"class":65},[48,2466,88],{"class":87},[48,2468,393],{"class":65},[48,2470,802],{"class":65},[48,2472,805],{"class":65},[48,2474,2159],{"class":401},[48,2476,91],{"class":61},[48,2478,2479],{"class":50,"line":2146},[48,2480,2481],{"class":54},"-- Index Scan using orders_cust_status_idx (actual time=0.015..0.042 rows=53)\n",[48,2483,2484],{"class":50,"line":2164},[48,2485,104],{"emptyLinePlaceholder":103},[48,2487,2488],{"class":50,"line":2170},[48,2489,2490],{"class":54},"-- Step 4: monitor usage over time and drop unused indexes.\n",[48,2492,2493,2495,2498,2500,2503,2505,2508,2510,2512],{"class":50,"line":2175},[48,2494,66],{"class":65},[48,2496,2497],{"class":61}," indexrelname, idx_scan ",[48,2499,691],{"class":65},[48,2501,2502],{"class":61}," pg_stat_user_indexes ",[48,2504,78],{"class":65},[48,2506,2507],{"class":61}," relname ",[48,2509,84],{"class":65},[48,2511,1812],{"class":401},[48,2513,91],{"class":61},[23,2515,2517],{"id":2516},"tips-tricks","💡 Tips & Tricks",[186,2519,2520,2535,2547,2556,2569,2575,2588,2606],{},[189,2521,2522,2525,2526,2528,2529,559,2531,2534],{},[31,2523,2524],{},"Idiom — composite index column ordering",": equality columns first, range columns last, sort columns after equality. ",[18,2527,607],{}," serves ",[18,2530,604],{},[18,2532,2533],{},"ORDER BY customer_id, ordered_on"," — one index replacing three.",[189,2536,2537,2540,2541,2543,2544,2546],{},[31,2538,2539],{},"Performance — covering indexes eliminate heap fetches",": ",[18,2542,657],{}," the columns your query ",[18,2545,66],{},"s. The heap fetch is often the dominant cost; index-only scans can be 10-100x faster on large tables.",[189,2548,2549,2540,2552,2555],{},[31,2550,2551],{},"Idiom — partial indexes for \"small hot subset\" queries",[18,2553,2554],{},"WHERE status = 'pending'"," — the partial index is a fraction of the size, stays hot in cache, and the planner prefers it when the predicate matches.",[189,2557,2558,2540,2561,2564,2565,2568],{},[31,2559,2560],{},"Idiom — expression indexes for case-insensitive search",[18,2562,2563],{},"CREATE INDEX ON users(lower(email))"," + ",[18,2566,2567],{},"WHERE lower(email) = ?",". Or use a case-insensitive ICU collation on the column to avoid the expression entirely.",[189,2570,2571,2574],{},[31,2572,2573],{},"Performance — BRIN for large append-only tables",": a BRIN index on a billion-row time-series table is a few KB vs. GB for a B-tree. Useless on random data, ideal on naturally-ordered data.",[189,2576,2577,2540,2584,2587],{},[31,2578,2579,2580,2583],{},"Debug — ",[18,2581,2582],{},"pg_stat_user_indexes"," for finding unused indexes",[18,2585,2586],{},"idx_scan = 0"," means never used (since last stats reset). Drop unused non-unique indexes to improve write performance.",[189,2589,2590,2597,2598,2601,2602,2605],{},[31,2591,2592,2593,2596],{},"Idiom — ",[18,2594,2595],{},"CREATE INDEX CONCURRENTLY"," for production",": doesn't block writes (slower, two table scans). Can't run in a transaction. May leave an invalid index if it fails — check ",[18,2599,2600],{},"pg_index.indisvalid"," and drop with ",[18,2603,2604],{},"DROP INDEX CONCURRENTLY"," if invalid.",[189,2607,2608,2611,2612,1206],{},[31,2609,2610],{},"Performance — VACUUM maintains the visibility map",": index-only scans need all-visible pages. A table with a covering index but stale visibility map does heap fetches anyway. Autovacuum usually handles this, but bulk-loaded tables may need a manual ",[18,2613,2614],{},"VACUUM",[23,2616,2618],{"id":2617},"️-edge-cases-gotchas","⚠️ Edge Cases & Gotchas",[186,2620,2621,2632,2645,2662,2682,2704,2724,2739,2750,2773,2779,2796],{},[189,2622,2623,2628,2629,2631],{},[31,2624,2625,2627],{},[18,2626,243],{}," can't use a B-tree index",": leading wildcards defeat the sorted tree. Use ",[18,2630,1205],{}," GiST\u002FGIN index for substring search, or full-text search.",[189,2633,2634,2540,2637,2640,2641,2644],{},[31,2635,2636],{},"Type mismatches defeat indexes",[18,2638,2639],{},"WHERE id = '42'"," (string vs integer column) may not use the index on ",[18,2642,2643],{},"id"," — the planner applies a cast per row, which isn't indexable. Match the column type, or use an expression index on the cast.",[189,2646,2647,2540,2650,2653,2654,2657,2658,2661],{},[31,2648,2649],{},"Functions defeat indexes",[18,2651,2652],{},"WHERE lower(email) = 'x'"," doesn't use an index on ",[18,2655,2656],{},"email"," — the function is applied per row. Create an expression index on ",[18,2659,2660],{},"lower(email)",", or use a case-insensitive collation.",[189,2663,2664,2673,2674,2677,2678,2681],{},[31,2665,2666,559,2669,2672],{},[18,2667,2668],{},"!=",[18,2670,2671],{},"\u003C>"," rarely use indexes",": B-trees are good at finding matching values, not \"all values except this one\" (which is most of the table). ",[18,2675,2676],{},"WHERE status != 'deleted'"," usually seq-scans. Rewrite as ",[18,2679,2680],{},"status IN ('active', 'pending')"," or use a partial index on the complement.",[189,2683,2684,2540,2690,2693,2694,2696,2697,2699,2700,2703],{},[31,2685,2686,2689],{},[18,2687,2688],{},"OR"," conditions and indexes",[18,2691,2692],{},"WHERE a = 1 OR b = 2"," may not use indexes on ",[18,2695,502],{}," or ",[18,2698,529],{},". The planner may do a bitmap OR (if both indexed) or a seq scan. Rewrite as ",[18,2701,2702],{},"UNION ALL"," of two indexed queries for guaranteed index usage.",[189,2705,2706,2540,2709,1601,2711,2713,2714,2716,2717,2696,2720,2723],{},[31,2707,2708],{},"Index bloat",[18,2710,1607],{},[18,2712,1604],{}," leave dead tuples; indexes accumulate bloat. ",[18,2715,2614],{}," reclaims dead tuples but doesn't shrink indexes — ",[18,2718,2719],{},"VACUUM FULL",[18,2721,2722],{},"pg_repack"," does. Monitor index size vs. table size.",[189,2725,2726,2731,2732,2735,2736,1206],{},[31,2727,2728,2730],{},[18,2729,2595],{}," can leave invalid indexes",": if it fails (e.g., unique violation during build), it leaves an invalid index (",[18,2733,2734],{},"pg_index.indisvalid = false",") that the planner won't use but still takes space and write overhead. Check: ",[18,2737,2738],{},"SELECT ... FROM pg_index WHERE NOT indisvalid",[189,2740,2741,2746,2747,2749],{},[31,2742,2743,2744],{},"Index-only scans need ",[18,2745,2614],{},": the visibility map that enables index-only scans is maintained by ",[18,2748,2614],{},". A table with a covering index but stale visibility map does heap fetches anyway.",[189,2751,2752,2540,2755,2757,2758,2760,2761,2764,2765,2768,2769,2772],{},[31,2753,2754],{},"Composite index leftmost rule",[18,2756,490],{}," can't help ",[18,2759,548],{}," alone. A common mistake is indexing ",[18,2762,2763],{},"(tenant_id, created_at)"," and expecting it to serve ",[18,2766,2767],{},"WHERE created_at > ?"," — it can't. Add a separate index on ",[18,2770,2771],{},"created_at"," if that query matters.",[189,2774,2775,2778],{},[31,2776,2777],{},"BRIN is useless on random data",": BRIN works on naturally-ordered data (time-series, append-only). On shuffled data, every block range has wide min\u002Fmax, and BRIN skips nothing.",[189,2780,2781,2540,2784,2787,2788,2791,2792,2795],{},[31,2782,2783],{},"Partial index predicate must match the query",[18,2785,2786],{},"CREATE INDEX ... WHERE status = 'pending'"," is only used if the query has ",[18,2789,2790],{},"status = 'pending'"," in its WHERE. ",[18,2793,2794],{},"WHERE status IN ('pending', 'shipped')"," won't use it.",[189,2797,2798,2801,2802,2805,2806,2809,2810,2813,2814,2817],{},[31,2799,2800],{},"Expression index immutability requirement",": the expression must be ",[18,2803,2804],{},"IMMUTABLE"," — same input always produces the same output. ",[18,2807,2808],{},"lower()"," is immutable; ",[18,2811,2812],{},"now()",", date functions depending on timezone, and functions reading tables are not. PG will reject non-immutable expressions at ",[18,2815,2816],{},"CREATE INDEX"," time.",[23,2819,2821],{"id":2820},"spot-the-bug","🧠 Spot the Bug",[14,2823,2824,2825,2827],{},"This query is slow despite an index on ",[18,2826,2656],{},". Why, and what's the fix?",[36,2829,2830],{"language":38},[40,2831,2833],{"className":42,"code":2832,"language":38,"meta":44,"style":44},"CREATE INDEX users_email_idx ON users(email);\n\nSELECT * FROM users WHERE lower(email) = 'alice@example.com';\n",[18,2834,2835,2849,2853],{"__ignoreMap":44},[48,2836,2837,2839,2841,2844,2846],{"class":50,"line":51},[48,2838,116],{"class":65},[48,2840,119],{"class":65},[48,2842,2843],{"class":122}," users_email_idx",[48,2845,126],{"class":65},[48,2847,2848],{"class":61}," users(email);\n",[48,2850,2851],{"class":50,"line":58},[48,2852,104],{"emptyLinePlaceholder":103},[48,2854,2855,2857,2859,2861,2863,2865,2867,2869,2871,2873],{"class":50,"line":94},[48,2856,66],{"class":65},[48,2858,69],{"class":65},[48,2860,72],{"class":65},[48,2862,984],{"class":61},[48,2864,78],{"class":65},[48,2866,989],{"class":87},[48,2868,992],{"class":61},[48,2870,84],{"class":65},[48,2872,997],{"class":401},[48,2874,91],{"class":61},[2876,2877,2878,2882,2900,2903,3038],"details",{},[2879,2880,2881],"summary",{},"Answer",[14,2883,2884,2885,2887,2888,2890,2891,2893,2894,2896,2897,2899],{},"The index is on ",[18,2886,2656],{},", but the query filters on ",[18,2889,2660],{},". The index is organized by the raw ",[18,2892,2656],{}," value, not by ",[18,2895,2660],{},". Applying ",[18,2898,2808],{}," to every row and comparing is a per-row operation — the planner can't use the B-tree for a lookup. It falls back to a seq scan.",[14,2901,2902],{},"Three fixes:",[36,2904,2905],{"language":38},[40,2906,2908],{"className":42,"code":2907,"language":38,"meta":44,"style":44},"-- Option 1: expression index on lower(email) — most common solution.\nCREATE INDEX users_email_lower_idx ON users(lower(email));\nSELECT * FROM users WHERE lower(email) = 'alice@example.com';  -- ✅ uses index\n\n-- Option 2: drop the lower() and query the raw column (if emails are stored consistently).\nSELECT * FROM users WHERE email = 'Alice@example.com';\n-- ✅ uses the email index, but fails if stored as 'alice@...' (case mismatch).\n\n-- Option 3: case-insensitive ICU collation on the email column.\nALTER TABLE users ALTER COLUMN email TYPE text COLLATE \"und-x-icu\";\n-- Now email = 'ALICE@...' matches 'alice@...' and the plain index works.\n-- Requires ICU support and affects all queries on the column.\n",[18,2909,2910,2915,2931,2955,2959,2964,2985,2990,2994,2999,3028,3033],{"__ignoreMap":44},[48,2911,2912],{"class":50,"line":51},[48,2913,2914],{"class":54},"-- Option 1: expression index on lower(email) — most common solution.\n",[48,2916,2917,2919,2921,2923,2925,2927,2929],{"class":50,"line":58},[48,2918,116],{"class":65},[48,2920,119],{"class":65},[48,2922,301],{"class":122},[48,2924,126],{"class":65},[48,2926,306],{"class":61},[48,2928,309],{"class":87},[48,2930,973],{"class":61},[48,2932,2933,2935,2937,2939,2941,2943,2945,2947,2949,2951,2953],{"class":50,"line":94},[48,2934,66],{"class":65},[48,2936,69],{"class":65},[48,2938,72],{"class":65},[48,2940,984],{"class":61},[48,2942,78],{"class":65},[48,2944,989],{"class":87},[48,2946,992],{"class":61},[48,2948,84],{"class":65},[48,2950,997],{"class":401},[48,2952,1000],{"class":61},[48,2954,1003],{"class":54},[48,2956,2957],{"class":50,"line":100},[48,2958,104],{"emptyLinePlaceholder":103},[48,2960,2961],{"class":50,"line":107},[48,2962,2963],{"class":54},"-- Option 2: drop the lower() and query the raw column (if emails are stored consistently).\n",[48,2965,2966,2968,2970,2972,2974,2976,2978,2980,2983],{"class":50,"line":113},[48,2967,66],{"class":65},[48,2969,69],{"class":65},[48,2971,72],{"class":65},[48,2973,984],{"class":61},[48,2975,78],{"class":65},[48,2977,1018],{"class":61},[48,2979,84],{"class":65},[48,2981,2982],{"class":401}," 'Alice@example.com'",[48,2984,91],{"class":61},[48,2986,2987],{"class":50,"line":132},[48,2988,2989],{"class":54},"-- ✅ uses the email index, but fails if stored as 'alice@...' (case mismatch).\n",[48,2991,2992],{"class":50,"line":155},[48,2993,104],{"emptyLinePlaceholder":103},[48,2995,2996],{"class":50,"line":161},[48,2997,2998],{"class":54},"-- Option 3: case-insensitive ICU collation on the email column.\n",[48,3000,3001,3004,3007,3009,3011,3014,3017,3020,3023,3026],{"class":50,"line":166},[48,3002,3003],{"class":65},"ALTER",[48,3005,3006],{"class":65}," TABLE",[48,3008,984],{"class":61},[48,3010,3003],{"class":65},[48,3012,3013],{"class":61}," COLUMN email ",[48,3015,3016],{"class":65},"TYPE",[48,3018,3019],{"class":65}," text",[48,3021,3022],{"class":65}," COLLATE",[48,3024,3025],{"class":401}," \"und-x-icu\"",[48,3027,91],{"class":61},[48,3029,3030],{"class":50,"line":445},[48,3031,3032],{"class":54},"-- Now email = 'ALICE@...' matches 'alice@...' and the plain index works.\n",[48,3034,3035],{"class":50,"line":451},[48,3036,3037],{"class":54},"-- Requires ICU support and affects all queries on the column.\n",[14,3039,3040,3043,3044,3047,3048,3051],{},[31,3041,3042],{},"The lesson",": an index on ",[18,3045,3046],{},"col"," is not an index on ",[18,3049,3050],{},"f(col)",". If you filter on a function of a column, index the function (expression index) or store the pre-computed value (generated column).",[3053,3054,3055],"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 pre.shiki code .sIsaT, html code.shiki .sIsaT{--shiki-default:#6F42C1;--shiki-github-dark:#B392F0}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}",{"title":44,"searchDepth":58,"depth":58,"links":3057},[3058,3059,3060,3064,3065,3066,3067,3071,3072,3073,3074,3075,3076,3077],{"id":25,"depth":58,"text":26},{"id":172,"depth":58,"text":173},{"id":318,"depth":58,"text":319,"children":3061},[3062,3063],{"id":483,"depth":94,"text":484},{"id":564,"depth":94,"text":565},{"id":623,"depth":58,"text":624},{"id":757,"depth":58,"text":758},{"id":935,"depth":58,"text":936},{"id":1127,"depth":58,"text":1128,"children":3068},[3069,3070],{"id":1273,"depth":94,"text":1274},{"id":1481,"depth":94,"text":1482},{"id":1585,"depth":58,"text":1586},{"id":1829,"depth":58,"text":1830},{"id":2013,"depth":58,"text":2014},{"id":2254,"depth":58,"text":2255},{"id":2516,"depth":58,"text":2517},{"id":2617,"depth":58,"text":2618},{"id":2820,"depth":58,"text":2821},"Indexes are the single most important performance tool in a database. The difference between a 10-second query and a 1-millisecond query is almost always an index. This chapter is a code-first reference for B-tree, composite, covering, partial, expression, GIN\u002FGiST\u002FBRIN indexes, and the EXPLAIN skills to verify they're actually used.","md",{},"\u002Fsql\u002F13-indexes-and-performance",{"title":5,"description":3078},"sql\u002F13-indexes-and-performance","toU-_oZJpPlFHdZMAeFHnAilc0i4hlEm2sTBNC_m9I0",1789924654557]