Skip to content

Expert: design, speed and engines · Indexes, query plans and views · topic 48 of 62 · 35 min

Composite, covering, unique and partial indexes

Explanation

A composite index covers several columns, and its column order matters: it helps queries that use its leftmost columns. A covering index holds every column a query needs, so the table is never touched. Unique indexes enforce rules, partial indexes cover only the rows you search often, and expression indexes cover values such as lower(email).

Composite indexes and column order

A composite (multi-column) index sorts by the first column, then by the second within each first value, and so on. An index on employees(dept_id, salary) lists all of department 1 sorted by salary, then all of department 2 sorted by salary, and so on.

Think of it like this: A phone directory is sorted by surname, then first name. Finding "Kumar, Ravi" is quick, and so is finding every Kumar. But finding everyone named Ravi, whatever the surname, means reading the whole book, because first names are only sorted inside each surname.

This gives the leftmost-prefix rule: an index on (a, b, c) can help queries that filter on a, on a and b, or on a, b and c, but usually not on b or c alone. SQLite's own composite key on order_items(order_id, product_id) shows it:

output
WHERE order_id = 101    ->  SEARCH order_items USING INDEX sqlite_autoindex_order_items_1 (order_id=?)
WHERE product_id = 2    ->  SCAN order_items
Real EXPLAIN QUERY PLAN results on SHOP. product_id is the second column, so it cannot be searched alone.

How to choose the order: put columns tested with = first, then the one used for a range (>, <, BETWEEN) or for sorting. With employees(dept_id, salary), WHERE dept_id = 1 AND salary > 90000 is one quick search, and WHERE dept_id = 2 ORDER BY salary needs no extra sort, because the rows come out of the index already in salary order.

sql
CREATE INDEX idx_emp_dept_salary ON employees(dept_id, salary);
SELECT seqno, name FROM pragma_index_info('idx_emp_dept_salary');
-- 0 | dept_id
-- 1 | salary
pragma_index_info is a table-valued function: you can SELECT from it like a table. seqno is the position in the index.

Covering indexes

Normally the database finds entries in the index, then follows each pointer to the table to fetch the other columns. If the index already contains every column the query needs, that second step disappears. Such an index is called covering for that query. SQLite reports USING COVERING INDEX; PostgreSQL shows an Index Only Scan.

output
SELECT dept_id, salary FROM employees WHERE dept_id = 2;
SEARCH employees USING COVERING INDEX idx_emp_dept_salary (dept_id=?)
Both selected columns are in the index, so the table is not read.

PostgreSQL (11 and later) and SQL Server let you add non-key columns just for covering: CREATE INDEX ... ON orders (customer_id) INCLUDE (order_date, status). The INCLUDE columns are stored in the index but not used for sorting.

Unique indexes

CREATE UNIQUE INDEX both speeds up lookups and enforces a rule: no two rows may share the value. It is how you add uniqueness to an existing table, since SQLite's ALTER TABLE cannot add a UNIQUE constraint. NULLs do not count as equal in SQLite, PostgreSQL or MySQL, so several rows may have a NULL email. SQL Server is different: its unique indexes allow only one NULL.

sql
CREATE UNIQUE INDEX idx_customers_email ON customers(email);
INSERT INTO customers (id, name, city, email, joined)
VALUES (7, 'Asha V', 'Pune', 'asha@example.com', '2025-05-01');
-- Error: UNIQUE constraint failed: customers.email
INSERT OR IGNORE INTO customers (id, name, city, email, joined)
VALUES (7, 'Asha V', 'Pune', 'asha@example.com', '2025-05-01');
-- no error, and no row added
INSERT OR IGNORE skips rows that break a constraint. PostgreSQL writes ON CONFLICT DO NOTHING; MySQL writes INSERT IGNORE.

Partial indexes

A partial index covers only the rows that match a WHERE condition. If the warehouse screen always asks for orders still in Placed status, and those are a small share of all orders, index just them. The index is small, cheap to maintain, and very selective. SQLite and PostgreSQL support partial indexes; SQL Server calls them filtered indexes; MySQL does not have them.

sql
CREATE INDEX idx_orders_placed ON orders(order_date) WHERE status = 'Placed';
SELECT name, partial FROM pragma_index_list('orders');
-- idx_orders_placed | 1
The planner uses it only when the query's WHERE clause includes status = 'Placed'.

A partial unique index is the standard trick for soft deletes (module 9): CREATE UNIQUE INDEX ... ON users(email) WHERE deleted_at IS NULL keeps emails unique among live users while letting a deleted account's email be reused.

Expression indexes

Users type emails in any case. If you search with WHERE lower(email) = 'asha@example.com', a normal index on email cannot help, because the index holds the original values. An index on the expression itself can: CREATE INDEX idx_customers_email_lower ON customers(lower(email)). The query must use exactly the same expression. PostgreSQL supports this too; MySQL 8.0.13 and later supports it as a functional key part, written with extra brackets: ((lower(email))).

Common mistakes

  • Wrong column order: an index on (salary, dept_id) for queries that filter on dept_id. Fix: equality columns first.
  • Separate single-column indexes on a and b, expecting them to act like one (a, b) index. Fix: create the composite index the query needs.
  • A partial index the query cannot use, because the query's WHERE does not repeat the index condition. Fix: match the condition exactly.
  • Searching lower(email) with only a plain email index. Fix: an expression index, or store emails in lower case.
  • Adding a composite index whose leading column is already a separate index you no longer need. Fix: (a, b) also serves queries on a alone, so drop the redundant (a) index.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Composite, covering, unique and partial indexes · 1/7

Composite indexes

One index can cover several columns. But the order of those columns matters a lot.

Prefer a video by a teacher? Find free videos on “composite index leftmost prefix covering index partial index explained” on YouTube ↗

In real life: A delivery dashboard that stays fast

A food delivery platform keeps every order ever placed, but the live operations screen only needs orders still being prepared or out for delivery, a tiny share of the table. A partial index on (restaurant_id, created_at) WHERE status IN ('Preparing', 'OutForDelivery') stays small and quick, while a composite index on (customer_id, created_at) serves the "my past orders" page, newest first. One unique index on the payment reference stops a payment from being recorded twice.

sql
CREATE INDEX idx_orders_live ON orders(restaurant_id, created_at)
  WHERE status IN ('Preparing', 'OutForDelivery');
CREATE INDEX idx_orders_customer_time ON orders(customer_id, created_at);
CREATE UNIQUE INDEX idx_payments_ref ON payments(gateway_ref);
Three indexes, each written for a specific screen or rule.

Practice (0/4 done)

Task 1 · Code · SQL

Make email unique with CREATE UNIQUE INDEX idx_customers_email ON customers(email). Then run two inserts with INSERT OR IGNORE, naming the columns (id, name, city, email, joined): (7, 'Asha V', 'Pune', 'asha@example.com', '2025-05-01') and (8, 'Kiran Rao', 'Bengaluru', 'kiran@example.com', '2025-05-02'). Finally return id, name and email of every customer, sorted by id.

Task 2 · Code · SQL

Create a normal index idx_orders_customer on orders(customer_id) and a partial index idx_orders_placed on orders(order_date) that covers only rows WHERE status = 'Placed'. Then list the indexes of orders with the table-valued function pragma_index_list('orders'): return its name and partial columns, sorted by name.

Task 3 · Code · SQL

In COMPANY, create a composite index idx_emp_dept_salary on employees(dept_id, salary). Then check its column order with pragma_index_info('idx_emp_dept_salary'): return seqno and name, sorted by seqno.

Task 4 · Question

orders has one index: (customer_id, order_date). Which query can NOT use it to search?

Quick quiz

Question 1

1.Queries filter on city = ? AND joined > ?. Which index fits best?

Question 2

2.What makes an index covering for a query?

Question 3

3.A unique index on email exists. How many rows with a NULL email can SQLite or PostgreSQL store?

Question 4

4.When does a partial index WHERE status = 'Placed' help most?

Question 5

5.You have an index on email but search with WHERE lower(email) = ?. What is the fix?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

Queries filter on city = ? AND joined > ?. Which index fits best?

Answer: (city, joined)

Equality column first, then the range column.

What makes an index covering for a query?

Answer: It contains every column the query reads, so the table is not visited

With all needed columns in the index, the lookup in the table is skipped.

A unique index on email exists. How many rows with a NULL email can SQLite or PostgreSQL store?

Answer: As many as you like

NULLs are not equal to each other, so they do not clash. SQL Server differs and allows one.

When does a partial index WHERE status = 'Placed' help most?

Answer: When Placed rows are a small share and queries filter on status = 'Placed'

A partial index pays off when it covers a small, often-searched slice.

You have an index on email but search with WHERE lower(email) = ?. What is the fix?

Answer: Create an index on the expression lower(email)

The index must hold the same expression the query uses.

Why use a UNIQUE index on email instead of only checking for duplicates in your Node.js code?Related

Answer: Two signups at the same moment can both pass the code check; the database rule cannot be skipped

Two requests can both check "no such email" before either inserts. The unique index is enforced by the database for every write.

From Indexes: making queries fast

Which column gets an index automatically in SQLite and PostgreSQL?Related

Answer: A column with a UNIQUE constraint

UNIQUE and PRIMARY KEY are enforced with an index. Foreign keys are not indexed automatically.

From How indexes work

Which SQLite plan line means the index alone answered the query?Related

Answer: SEARCH orders USING COVERING INDEX idx_x (customer_id=?)

COVERING means every needed column was in the index.

From Reading query plans

Which statement deletes a table and all its rows, with no undo?Related

Answer: DROP TABLE

DROP TABLE removes the table itself. Always have a backup before using it on real data.

From CREATE TABLE and constraints

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 3 more topics in “Indexes, query plans and views” before its exam: Reading query plans, Writing fast queries, Views and materialized views.
  2. Module 10 exam: Indexes, query plans and views · 13 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Procedures, triggers, SQL engines and NoSQL. 62 topics left in the course before the final exam.

← Previous: How indexes work