Back to Indexing
Q15Question 15 of 18

What is a partial index, and when is it worth it

Key Concepts
  • A partial index covers only the rows matching a condition specified in the index's WHERE clause, making it dramatically smaller than a full index.
  • The optimizer uses a partial index only when the query's predicate is implied by the index's filter.
  • Partial indexes solve the low-selectivity problem: a full index on a status column where 95% is ACTIVE is nearly useless, but a partial index on PENDING is highly effective.
  • Write cost drops because inserts and updates only maintain the index when the row matches the filter.
  • A unique partial index enforces conditional uniqueness, like at most one primary account per customer.
  • Partial indexes work best with immutable boolean or enum filters; dynamic or vague predicates may not match.

Definition

A partial index covers only the rows matching a condition, so it is small and fast – worth it when your queries always target a small slice of a big table.
  • Partial index – An index built over only the subset of rows that satisfy a predicate, specified with a WHERE clause on the index definition (called a filtered index in SQL Server).
  • Size benefit – Because it indexes fewer rows, it is dramatically smaller, faster to scan, and fits in memory more easily.
  • Write benefit – Inserts and updates only touch the index when the row matches the filter, so the vast majority of writes pay nothing.
  • Optimizer matching – The optimizer uses a partial index only when the query's predicate is implied by the index's filter.
  • Conditional uniqueness – A unique partial index enforces uniqueness only within the matching subset, such as at most one primary account per customer.

At the National Bank of India, imagine ninety-nine percent of loan applications are already CLOSED and only a tiny fraction are PENDING review. A full index on status would waste space indexing millions of closed loans that nobody searches for. A partial index is like keeping a small, separate tray on the officer's desk holding only the pending applications. It is tiny, instantly scannable, and exactly matches what the officer actually looks for all day. You do not index the giant pile of closed loans at all, because no query ever hunts through them.

Understanding the Concepts

What is a partial index?

A partial index is defined with a predicate in the WHERE clause of the CREATE INDEX statement. Only rows matching that predicate are included in the index.

SQL
-- National Bank of India: partial index on PENDING loans only
CREATE INDEX ix_loans_pending
    ON loans(applied_at)
    WHERE status = 'PENDING';

-- The index stores entries ONLY for rows where status = 'PENDING'
-- All CLOSED and APPROVED rows are NOT in the index

No matter how large the table grows, the partial index stays tiny because it only tracks the small slice that matters.

Why is a full index on a low-selectivity column nearly useless?

When 95% of rows share the same value, a full index on that column matches almost everything. The optimizer skips it because random index lookups for 95% of the table cost more than one sequential scan.

SQL
-- Full index on status: 95% of loans are CLOSED
CREATE INDEX ix_loans_status ON loans(status);

-- Optimizer ignores the index for CLOSED (too many rows)
EXPLAIN ANALYZE
SELECT * FROM loans WHERE status = 'CLOSED';
-- Plan: Seq Scan (9.5M rows match – index is useless)

-- Optimizer might use it for PENDING (few rows)
EXPLAIN ANALYZE
SELECT * FROM loans WHERE status = 'PENDING';
-- Plan: Index Scan (only 200 rows match)
-- But the full index is huge: stores entries for all 10M rows

How does a partial index solve this?

A partial index on the rare value is tiny and always effective.

SQL
-- Partial index: only PENDING rows
CREATE INDEX ix_loans_pending
    ON loans(applied_at)
    WHERE status = 'PENDING';

-- The index has ~200 entries instead of 10,000,000
EXPLAIN ANALYZE
SELECT loan_id, applied_at, amount
FROM   loans
WHERE  status = 'PENDING'
  AND  applied_at < CURRENT_DATE - INTERVAL '30 days';
TEXT
Index Scan using ix_loans_pending on loans
  Index Cond: (applied_at < '2026-09-25')
  Filter: (status = 'PENDING')
  Rows: 15
  Execution Time: 0.1 ms

What are the three benefits of a partial index?

The benefits are size, write cost, and selectivity.

SQL
-- 1. Size: partial index is ~1% of a full index
-- Full index on (applied_at): 10,000,000 entries, ~200 MB
-- Partial index on (applied_at) WHERE PENDING: ~200 entries, ~16 KB

-- 2. Write cost: only PENDING rows maintain the index
-- 99% of INSERTs (for CLOSED/APPROVED loans) never touch this index
INSERT INTO loans (status, applied_at, amount)
VALUES ('CLOSED', '2026-10-01', 500000.00);
-- This INSERT does NOT update ix_loans_pending (status != PENDING)

INSERT INTO loans (status, applied_at, amount)
VALUES ('PENDING', '2026-10-25', 250000.00);
-- This INSERT DOES update ix_loans_pending (status = PENDING)

-- 3. Selectivity: the partial index is always highly selective
-- because it only contains the rare rows that queries target

How does the optimizer decide to use a partial index?

The optimizer uses a partial index only when it can prove the query's predicate is a subset of the index's filter.

SQL
-- This query MATCHES the partial index filter
SELECT * FROM loans
WHERE status = 'PENDING' AND applied_at < '2026-09-01';
-- Optimizer: query requires status = 'PENDING'
-- Index filter: status = 'PENDING'
-- Match: query is a subset of filter -> use the index

-- This query does NOT match the partial index filter
SELECT * FROM loans
WHERE applied_at < '2026-09-01';
-- Optimizer: query does not require status = 'PENDING'
-- Rows outside the filter are absent from the index
-- Cannot use the partial index -> falls back to full scan

How do you enforce conditional uniqueness with a partial index?

A unique partial index guarantees uniqueness only within the matching subset.

SQL
-- Each customer can have at most ONE primary account
CREATE UNIQUE INDEX ux_one_primary
    ON accounts(customer_id)
    WHERE is_primary = true;

-- Priya can have many non-primary accounts
INSERT INTO accounts (account_id, customer_id, is_primary)
VALUES ('SAV3001', 'CUST100', false);  -- OK
INSERT INTO accounts (account_id, customer_id, is_primary)
VALUES ('SAV3002', 'CUST100', false);  -- OK (not primary)

-- Priya can have exactly one primary account
INSERT INTO accounts (account_id, customer_id, is_primary)
VALUES ('SAV3003', 'CUST100', true);   -- OK (first primary)
INSERT INTO accounts (account_id, customer_id, is_primary)
VALUES ('SAV3004', 'CUST100', true);   -- FAILS!
-- ERROR: duplicate key value violates unique constraint "ux_one_primary"

What is the soft-delete pattern with partial indexes?

Partial indexes are perfect for the soft-delete pattern. Queries over live data stay fast while deleted rows never bloat the index.

SQL
-- Soft-delete: rows are marked deleted but not physically removed
ALTER TABLE accounts ADD COLUMN deleted BOOLEAN DEFAULT false;

-- Partial index on live (non-deleted) accounts only
CREATE INDEX ix_accounts_live
    ON accounts(branch_code)
    WHERE deleted = false;

-- Application queries always include WHERE deleted = false
SELECT * FROM accounts
WHERE branch_code = 'MUM001'
  AND deleted = false;
-- Plan: Index Scan on ix_accounts_live (tiny, fast)
JAVA
// com.bank.service.LoanReviewService
package com.bank.service;

import java.math.BigDecimal;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;

public class LoanReviewService {

    /**
     * Uses the partial index ix_loans_pending.
     * Only PENDING loans are in the index, so this is instant
     * even on a table with millions of closed loans.
     */
    public List<String> getOverduePendingLoans(
            Connection conn, int daysOld) throws SQLException {

        String sql = """
            SELECT loan_id, applicant_name, amount, applied_at
            FROM   loans
            WHERE  status = 'PENDING'
              AND  applied_at < CURRENT_DATE - ? * INTERVAL '1 day'
            ORDER BY applied_at
            """;

        List<String> results = new ArrayList<>();
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setInt(1, daysOld);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    BigDecimal amount = rs.getBigDecimal("amount");
                    results.add(rs.getString("loan_id")
                        + " | " + rs.getString("applicant_name")
                        + " | " + amount.toPlainString());
                }
            }
        }
        return results;
    }
}

What should a senior engineer know?

  • In PostgreSQL, the query predicate must be clearly implied by the index predicate for the planner to use it. Vague or dynamic predicates can silently miss the index.
  • SQL Server calls this a filtered index and supports it from SQL Server 2008.
  • Immutable boolean or enum filters work best. A filter on a computed expression may not match.
  • Combine partial indexes with covering (INCLUDE) for maximum performance on hot queries.
SQL
-- Partial + covering: the ultimate hot-path index
CREATE INDEX ix_loans_pending_cover
    ON loans(applied_at)
    INCLUDE (loan_id, applicant_name, amount)
    WHERE status = 'PENDING';

-- Index-only scan on a tiny partial index
EXPLAIN ANALYZE
SELECT loan_id, applicant_name, amount, applied_at
FROM   loans
WHERE  status = 'PENDING'
ORDER BY applied_at;
-- Plan: Index Only Scan, Heap Fetches: 0, ~200 entries

What are common interview follow-up questions?

  • How does a partial index compare to a full index with low selectivity?
  • Can you combine a partial index with a unique constraint?
  • How does the soft-delete pattern benefit from partial indexes?
  • What happens if the query does not include the partial index's filter condition?

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────────┐
│   Partial Index: Index Only the Rows You Need   │
├─────────────────────────────────────────────────┤
│                                                 │
│  Full Index (10M entries):                      │
│  ┌──────────────────────────────────┐           │
│  │ CLOSED  | 2026-01-01 |           │           │
│  │ CLOSED  | 2026-01-02 |  9.5M     │           │
│  │ ...     | ...        |  entries  │           │
│  │ CLOSED  | 2026-10-25 |           │           │
│  │ PENDING | 2026-10-20 |  200      │           │
│  │ PENDING | 2026-10-25 |  entries  │           │
│  └──────────────────────────────────┘           │
│  Size: ~200 MB, mostly useless entries          │
│                                                 │
│  Partial Index (200 entries):                   │
│  ┌──────────────────────────────────┐           │
│  │ PENDING | 2026-10-20 |           │           │
│  │ PENDING | 2026-10-25 |  200      │           │
│  │ (only PENDING rows)  |  entries  │           │
│  └──────────────────────────────────┘           │
│  Size: ~16 KB, every entry is useful            │
│                                                 │
│  Conditional Uniqueness:                        │
│  UNIQUE INDEX ON accounts(customer_id)          │
│    WHERE is_primary = true                      │
│  ──► At most 1 primary account per customer     │
└─────────────────────────────────────────────────┘

Key Takeaway

A partial index, which SQL Server calls a filtered index, is an index built over only the rows that satisfy a predicate, written as a WHERE clause on the index itself. Because it indexes a subset rather than the whole table, it is dramatically smaller, faster to scan, and cheaper to maintain, and the optimizer uses it only for queries whose predicate is implied by the index's filter. It is worth it exactly when your queries consistently target a small, well-defined slice of a large table – active rows, pending rows, or non-deleted rows.

The benefits are threefold. First, size: a partial index over one percent of rows is roughly one percent of the size of a full index, so it stays in memory and scans quickly. Second, write cost: inserts and updates only maintain the index when the row matches the filter, so the vast majority of writes pay nothing. Third, selectivity: it neatly solves the low-selectivity problem, because a full index on a status column where almost everything is closed is nearly useless, whereas a partial index on the rare pending value is highly effective. As a bonus, a unique partial index enforces conditional uniqueness, like at most one primary account per customer.

At the National Bank of India the loan-review dashboard only ever listed pending loans, but the loans table held tens of millions of mostly closed ones. A full status index was huge and barely selective, so Rajesh built a partial index on applied-at with the filter status equals pending. It indexed only the few hundred pending loans, the dashboard became instant, and the millions of closed-loan writes stopped paying to maintain it. He also used a partial unique index on the primary-account flag to guarantee each customer had at most one primary account while allowing many ordinary ones.

The nuance Pankaj always raises is that in PostgreSQL the query's predicate must be clearly implied by the index's filter for the planner to use the partial index, so the team keeps the filter on immutable boolean or enum columns and confirms with EXPLAIN that the small partial index is actually chosen rather than a full scan.

Remember one sentence: a partial index covers only the rows matching a condition, so it is small and fast – worth it when your queries always target a small slice of a big table.

---

Share
Use ← → arrow keys to navigate