{"post":{"slug":"partial-indexes-explained-2026","title":"Partial Indexes: Indexing Only the Rows That Matter","excerpt":"Most queries against a large table only ever touch a small slice of it. A partial index covers just that slice, and is a fraction of the size and cost.","category":"code","date":"August 12, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">A</span>n orders table holds 200 million rows. Around 40,000 of them are in a pending state at any moment, and the queries that matter — the ones the operations dashboard runs every few seconds — only ever look at those.\n\nA conventional index on the status column covers all 200 million rows. It is large, it slows every insert and update, it bloats, and it exists almost entirely to describe rows nobody queries.\n\nA partial index covers only the pending ones. Same query performance, a fraction of the size, and a fraction of the maintenance cost.\n\n## What changed in 2026\n\n- **High-churn status columns became ubiquitous.** Event-driven and queue-backed architectures produce exactly the pattern partial indexes suit — a small hot subset in a large cold table.\n- **Index bloat got more attention.** Once teams started measuring bloat, the appeal of indexes that simply do not contain most rows became obvious.\n- **Planner matching improved.** Better predicate implication logic widened the set of queries that can use a given partial index.\n- **Storage cost scrutiny increased.** Index size stopped being an afterthought as data volumes grew.\n\n## Why it helps more than it sounds\n\nThe size reduction is the visible benefit. The write reduction is usually the bigger one.\n\nEvery index on a table is maintained on every insert, update, and delete of a covered row. An index covering 200 million rows participates in every write. A partial index covering 40,000 participates only when a row enters, leaves, or changes within the predicate — rows outside it are invisible to the index entirely and cost nothing.\n\nFor a table with heavy writes and a small queried subset, that difference is substantial. It compounds with bloat: a smaller index has less to bloat, and rows that never enter it never leave dead entries behind — see [index bloat](/blog/index-bloat-explained-2026).\n\n| | Full index | Partial index |\n| --- | --- | --- |\n| Rows covered | All | Matching the predicate |\n| Size | Proportional to table | Proportional to the subset |\n| Write overhead | Every write | Only writes touching the subset |\n| Cache footprint | Large | Small |\n| Bloat exposure | Full | Limited to the subset |\n| Query coverage | Any predicate on the column | Only queries the planner can match |\n\n## The planner has to prove the match\n\nThis is where partial indexes disappoint people, and it is worth understanding precisely.\n\nFor the planner to use an index defined with `WHERE status = 'pending'`, it must be able to prove your query only touches rows satisfying that condition. A query filtering `WHERE status = 'pending'` matches trivially.\n\nA query filtering `WHERE status != 'complete'` does not — even if, in your data, the only other status is pending. The planner reasons about the predicate, not your data's contents.\n\nA query with `WHERE status = $1` and a parameter that happens to be `'pending'` may not match either, depending on when the plan is built. A generic plan prepared for any parameter value cannot assume the predicate holds — which is one way a partial index silently stops being used under [prepared statements](/blog/prepared-statements-explained-2026).\n\nThe practical rule: write the query predicate to match the index predicate as literally as possible, and verify with `EXPLAIN` that the index is actually chosen. A partial index the planner cannot match is pure cost — see [query planners](/blog/query-planner-explained-2026).\n\n## Conditional uniqueness\n\nA second use that has nothing to do with performance and solves a problem that is otherwise awkward.\n\nConsider a rule: each user may have at most one active subscription, and any number of cancelled ones. A plain unique constraint on `(user_id)` forbids the cancelled ones. A unique constraint on `(user_id, status)` allows two active ones with different casing or allows multiple cancelled, depending on how you model it, but does not express the rule.\n\nA unique partial index on `(user_id) WHERE status = 'active'` expresses it exactly. Uniqueness applies only within the active slice.\n\nThis pattern appears constantly — one primary address, one default payment method, one current version — and a partial unique index is the cleanest way to enforce it in the database rather than in application code that will eventually race with itself.\n\n## Common mistakes\n\n- **A predicate matching most rows.** No meaningful saving; just a slightly odd index.\n- **Query predicate not matching the index predicate.** Silently falls back to a scan.\n- **Not verifying with EXPLAIN.** An unused partial index is cost without benefit.\n- **Predicates on volatile expressions.** The index must be deterministic; a predicate referencing the current time cannot be.\n- **Forgetting it when the predicate's meaning changes.** Adding a new status value can silently change what the index covers.\n- **Using one where a full index is genuinely needed.** Queries outside the predicate get no help at all.\n\n## FAQ\n\n**How small does the subset need to be?**\nThe benefit scales with how much you exclude. Excluding 99% of rows is transformative; excluding 20% is barely worth the added complexity and matching risk.\n\n**Can I have both a partial and a full index on the same column?**\nYes, and it is sometimes right — the partial one serves the hot queries cheaply while the full one covers everything else. You are paying for both, so confirm both are actually used.\n\n**Does this work with multi-column indexes?**\nYes. The predicate is independent of the indexed columns, so you can index several columns while restricting which rows are included.\n\n**How does it interact with partitioning?**\nThey compose well and solve different problems. Partitioning splits by a lifecycle dimension; partial indexes narrow within whatever remains — see [table partitioning](/blog/table-partitioning-explained-2026).\n\n## Where to go next\n\nFor index fundamentals and when to add one at all, read [database indexing explained](/blog/database-indexing-explained-2026). For why an index you created is being ignored, [query planners](/blog/query-planner-explained-2026), and for the degradation partial indexes reduce, [index bloat](/blog/index-bloat-explained-2026).","hue":316,"metaTitle":"Partial Indexes: Index Only the Rows That Matter","metaDescription":"How partial indexes cut index size and write cost by covering a subset of rows, why the planner must match your predicate, and conditional uniqueness uses.","keywords":["partial index","filtered index","conditional unique constraint","index size reduction","postgres partial index","index write overhead","query predicate matching","database indexing 2026"],"tldr":["**Index only the rows your queries actually filter for** — a pending-orders index on a table where 99% are complete is tiny.","**Smaller means less bloat and cheaper writes** — rows outside the predicate cost nothing to maintain.","**The planner must prove your query matches the predicate** — near-misses silently fall back to a scan.","**It also enforces conditional uniqueness** — one active record per user, with any number of inactive ones.","**Skip:** partial indexes on a predicate that matches most of the table. The savings only appear when the slice is small."],"readTime":"5 min read","wordCount":995},"related":[{"slug":"gin-indexes-explained-2026","title":"GIN Indexes: Indexing Values Inside a Column","excerpt":"A B-tree indexes a column's value. A GIN index indexes the things inside it — array elements, document keys, words in a text field — which is a different problem.","category":"code","date":"January 7, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":316},{"slug":"database-indexing-explained-2026","title":"Database indexing explained in 2026: what every developer must know","excerpt":"Bad indexes cause more production incidents than almost any other root cause. Here is the 2026 guide to choosing, building, and maintaining the right ones.","category":"code","date":"March 10, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":318},{"slug":"trigram-indexes-explained-2026","title":"Trigram Indexes: Making LIKE Queries Fast","excerpt":"A leading-wildcard search cannot use a normal index, so it scans the table. Trigram indexing makes substring and fuzzy matching indexable by breaking text into three-character pieces.","category":"code","date":"January 26, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":310}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"open-weight-model-licensing-2026","title":"Open-Weight Model Licensing: Read Before You Ship","excerpt":"Open weights and open source are not the same thing. Here is what the common model licences actually permit, the clauses that catch commercial users, and how to check.","category":"ai","date":"August 12, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":196},"older":{"slug":"postgres-18-features-2026","title":"Postgres 18 Features: Async I/O and What It Means","excerpt":"Postgres 18 added asynchronous I/O, native UUIDv7, virtual generated columns, and OAuth authentication. Here is which changes affect real workloads.","category":"code","date":"August 12, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":205}}