Menu
Try in Playground

SQLite Partial Indexes: CREATE INDEX ... WHERE

How partial indexes work in SQLite — indexing only the rows you actually query, and the patterns (soft deletes, partial uniqueness, hot subsets) that make them pay off.

This page includes runnable editors — edit, run, and see output instantly.

A Partial Index Covers Only Some Rows

A regular index has an entry for every row in the table. A partial index has entries only for rows matching a WHERE clause you supply when you create it. Smaller index, fewer pages to walk, less work on every insert and update that doesn't touch the indexed slice.

The syntax is a normal CREATE INDEX with a WHERE tacked on:

idx_orders_pending only contains entries for rows where status = 'pending'. Shipped, cancelled, and refunded orders aren't in it at all. If 95% of your orders table is historical and you mostly query the open ones, that's a 20× smaller index for the same query speed.

When the Planner Will Actually Use It

A partial index is only usable when SQLite can prove your query restricts to the same rows the index covers. The cleanest way is to repeat the index's WHERE clause in the query:

The plan should mention USING INDEX idx_orders_pending. Drop the status = 'pending' from the query and the planner falls back to a full table scan — it has no way to know the query stays inside the indexed subset.

The rule of thumb: the query's WHERE must imply the index's WHERE. Equality on the same column and value is the safe, obvious case. Inequalities and OR get hairier; check with EXPLAIN QUERY PLAN.

Why Bother — The Three Wins

Three concrete reasons partial indexes pay off:

  1. Smaller on disk. Only the matching rows are stored. For a "1% of the table is hot" workload, the index is roughly 1% of a full one.
  2. Cheaper writes. Inserts and updates only touch the index when the row matches the filter. A status = 'shipped' insert on the table above doesn't touch idx_orders_pending at all.
  3. Same lookup speed. A B-tree lookup is logarithmic in the index size. Smaller index, slightly faster lookups, but the bigger gain is everything around it — fewer cache misses, less I/O.

If a column is highly skewed — most rows have one value, you only care about the rare other values — that's the textbook case for a partial index.

Partial Unique Indexes (the Killer Feature)

Plain UNIQUE constraints apply to every row. That's a problem the moment you introduce soft deletes:

-- Fails: there are two rows with email = 'a@x.com', even though one is deleted.
CREATE UNIQUE INDEX idx_users_email ON users(email);

A partial unique index lets you enforce uniqueness only over the rows that matter:

Three rows, same email, no constraint violation — because only the deleted_at IS NULL row participates in the uniqueness check. Try inserting a second live row with the same email and SQLite raises UNIQUE constraint failed.

This pattern shows up everywhere: one active subscription per customer, one primary address per user, one open invoice per order. Partial unique indexes express it directly.

Indexing Around NULL

NULL interacts oddly with indexes. A common goal is "ignore the NULLs entirely" — say, you have a sparse external_id column where most rows are NULL but the populated ones must be unique:

Two NULLs coexist happily; the EXT-001 and EXT-002 rows are guaranteed unique. The index is also smaller — NULL rows aren't stored at all — so lookups by external_id are quick even when the table grows.

What the Filter Can Reference

The WHERE clause of a partial index is restrictive. It can reference:

  • Columns of the table being indexed.
  • Literal constants.
  • A small set of deterministic built-in functions.

It can't reference:

  • Other tables.
  • Subqueries.
  • Non-deterministic functions like random() or CURRENT_TIMESTAMP.
  • Parameters or variables.

This makes sense — SQLite has to evaluate the filter on every row insert and update, and the result has to be stable. So this works:

But WHERE created_at > date('now') would not — date('now') changes over time, so the set of indexed rows would shift under SQLite's feet.

A Sanity-Check Workflow

When you add a partial index, walk through three checks:

Query 1 should use idx_jobs_runnable. Queries 2 and 3 should fall back to a scan (or a different index, if you have one). If the planner picks the partial index for a query you didn't expect, re-read the filter — it might be broader than you think.

When Not to Reach for One

Partial indexes are sharp. Reasons to skip:

  • The filter matches most of the table. If "active" is 90% of your rows, a partial index is a regular index with extra steps. Just index the column.
  • Your queries don't include the filter literally. If your code uses an ORM that builds WHERE status IN (?, ?, ?) or computes the filter dynamically, the planner often won't recognize the match. Test with EXPLAIN QUERY PLAN, don't assume.
  • The hot subset shifts over time. A partial index on "orders from the last 30 days" sounds appealing but isn't expressible — the filter has to be deterministic. You'd need to rebuild the index, or pick a different schema (a separate recent_orders table or a archived boolean you flip nightly).

When the filter is stable and matches a small slice of a large table, partial indexes are some of the highest-leverage tuning you can do in SQLite.

Next: Reading Query Plans

Most of this page leaned on EXPLAIN QUERY PLAN to confirm an index actually got used. That tool deserves a page of its own — how to read its output, what the keywords mean, and how to spot the difference between a happy index lookup and a sneaky full scan. That's next.

Frequently Asked Questions

What is a partial index in SQLite?

A partial index only indexes rows that match a WHERE clause given at creation time. You write CREATE INDEX name ON table(col) WHERE condition and SQLite only stores entries for rows where the condition is true. Smaller index, faster writes, same lookup speed for the queries that match the filter.

When should I use a partial index instead of a full one?

When you query a small slice of a large table over and over — pending orders, active users, unprocessed jobs. Indexing only that slice keeps the index tiny and lets writes to other rows skip it entirely. If your queries don't include the same WHERE condition the index does, the planner can't use it.

Can a partial index enforce uniqueness?

Yes. CREATE UNIQUE INDEX ... WHERE ... enforces uniqueness only on rows matching the filter. The classic use is 'one active record per user' — soft-deleted rows are excluded, so you can have multiple deleted entries with the same key but only one live one.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED