Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 12 – Indexes, query plans and performance

Covering, partial and expression indexes

Answer queries from the index alone, index only the rows that matter, and index computed values such as lower(email), in PostgreSQL, MySQL and SQLite.

  • Advanced
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8, PostgreSQL 18.6 and MySQL 8.4.11
  • By MySmartCoPilot
Free intro, Pro: 7 of 30 minutes free
Free: Three ways to fit an index to a query. With a pass: the other 6 sections and 3 interview answers, about 23 minutes.

What you will learn

  • Avoid table lookups with covering indexes and index-only scans
  • Index a subset of rows with partial indexes, and write queries that can use them
  • Index computed values such as lower(email), and match them in queries
  • Choose between the three kinds by their size, write cost and conditions

Before you start

What you’ll learn · Before you start

What you will learn

  • Avoid table lookups with covering indexes and index-only scans
  • Index a subset of rows with partial indexes, and write queries that can use them
  • Index computed values such as lower(email), and match them in queries
  • Choose between the three kinds by their size, write cost and conditions

Before you start

On this page

Three kinds of index fit an index more closely to the queries it serves. A covering index holds every column a query needs, so the database answers from the index alone and never reads the table: PostgreSQL calls it an Index Only Scan, SQLite says USING COVERING INDEX and MySQL Using index. A partial index holds only the rows that match a WHERE clause written into the index, such as the active subscriptions, so it is smaller and cheaper to keep up to date. Expression indexes store a value computed from the columns, such as lower(email), so that a query comparing that expression can search instead of scanning. Each comes with a condition: the covering index must hold the columns, the query’s WHERE must imply the partial index’s predicate, and the query must use the indexed expression exactly.

Three ways to fit an index to a query

Three index shapes: a covering index holds every needed column, a partial index some rows, an expression index a computed value.Covering: (city, name)entry: Pune · User 15 · rowid 15every column the query needsno table readPartial: (user_id)WHERE status = 'active'6,000 of 60,000 rows indexedthe query must say status = 'active'Expression: (lower(email))entry: user42@example.in · rowid 42the query must use lower(email)smallercomputed

Covering, partial and expression indexes, and what each needs

Text description of the diagram

The diagram shows three boxes, one below the other, each an index on the tables of this lesson.

1. A covering index on (city, name), drawn with a thick border. Its entries hold the city, the name and the rowid, for example Pune, User 15 and rowid 15. A query that needs only these columns gets every column from the index, so the table is never read. 2. A partial index on (user_id) with WHERE status = 'active', drawn with a dashed border. Only 6,000 of the 60,000 subscriptions are in it, the active ones, and a query can use it only if its own WHERE clause says status = 'active' as well. 3. An expression index on (lower(email)). Its entries hold the lower-case email, for example user42@example.in, and the rowid 42. A query can use it only if it compares the same expression, lower(email).

An arrow from the first box to the second is labelled "smaller", and one from the second to the third "computed": the partial index stores fewer rows, and the expression index stores a value computed from a column.

All three on 20,000 users and 60,000 subscriptions, in your browser:

A covering, a partial and an expression index in SQLite SQL · sqlite/three_kinds.sql
-- 20,000 users whose emails were typed in three different cases, and 60,000 subscriptions, a tenth of them active.
CREATE TABLE users (
  id    INTEGER PRIMARY KEY,
  email TEXT NOT NULL,
  name  TEXT NOT NULL,
  city  TEXT NOT NULL
);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 20000)
INSERT INTO users
SELECT i,
       CASE i % 3 WHEN 0 THEN 'User' || i || '@Example.in' WHEN 1 THEN 'user' || i || '@example.in' ELSE 'USER' || i || '@EXAMPLE.IN' END,
       'User ' || i,
       CASE i % 5 WHEN 0 THEN 'Pune' WHEN 1 THEN 'Kochi' WHEN 2 THEN 'Jaipur' WHEN 3 THEN 'Gangtok' ELSE 'Indore' END
FROM n;
CREATE TABLE subscriptions (
  id      INTEGER PRIMARY KEY,
  user_id INTEGER NOT NULL,
  status  TEXT    NOT NULL,
  plan    TEXT    NOT NULL
);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 60000)
INSERT INTO subscriptions
SELECT i, 1 + (i * 7919) % 20000, CASE WHEN i % 10 = 0 THEN 'active' ELSE 'ended' END,
       CASE i % 2 WHEN 0 THEN 'monthly' ELSE 'yearly' END
FROM n;

-- 1. Covering: the index holds every column the query needs, so the table is never read.
CREATE INDEX users_city_name ON users (city, name);
EXPLAIN QUERY PLAN SELECT name FROM users WHERE city = 'Pune';

-- 2. Partial: only the active subscriptions are in the index; the query must say status = 'active' too.
CREATE INDEX subscriptions_active ON subscriptions (user_id) WHERE status = 'active';
EXPLAIN QUERY PLAN SELECT id, plan FROM subscriptions WHERE user_id = 42 AND status = 'active';

-- 3. Expression: the index holds lower(email), and the query compares the same expression.
CREATE INDEX users_email_lower ON users (lower(email));
EXPLAIN QUERY PLAN SELECT id, email FROM users WHERE lower(email) = lower('USER42@example.IN');
SELECT id, email FROM users WHERE lower(email) = lower('USER42@example.IN');

Output

┌────┬────────┬─────────┬────────────────────────────────────────────────────────────┐
│ id │ parent │ notused │                           detail                           │
├────┼────────┼─────────┼────────────────────────────────────────────────────────────┤
│ 2  │ 0      │ 55      │ SEARCH users USING COVERING INDEX users_city_name (city=?) │
└────┴────────┴─────────┴────────────────────────────────────────────────────────────┘
┌────┬────────┬─────────┬───────────────────────────────────────────────────────────────────┐
│ id │ parent │ notused │                              detail                               │
├────┼────────┼─────────┼───────────────────────────────────────────────────────────────────┤
│ 3  │ 0      │ 61      │ SEARCH subscriptions USING INDEX subscriptions_active (user_id=?) │
└────┴────────┴─────────┴───────────────────────────────────────────────────────────────────┘
┌────┬────────┬─────────┬───────────────────────────────────────────────────────┐
│ id │ parent │ notused │                        detail                         │
├────┼────────┼─────────┼───────────────────────────────────────────────────────┤
│ 3  │ 0      │ 61      │ SEARCH users USING INDEX users_email_lower (<expr>=?) │
└────┴────────┴─────────┴───────────────────────────────────────────────────────┘
┌────┬───────────────────┐
│ id │       email       │
├────┼───────────────────┤
│ 42 │ User42@Example.in │
└────┴───────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < three_kinds.sql

The covering plan says USING COVERING INDEX: name and city are in the index, and so is the rowid, so the 4,000 users of Pune are listed without a single table page being read. The partial index holds only the 6,000 active subscriptions, and it serves the query because the query’s own WHERE says status = 'active'. The expression plan searches (<expr>=?), the stored lower-case emails, and the row comes back whatever case was typed. None of the three changes what a query returns: an index is only a faster way to the same rows. What they change is how many pages a query reads, how large the index grows and which queries can use it. Expression indexes and partial indexes both trade generality for size or speed, and the rest of this lesson measures five details that decide whether they pay:

  • Index-only scans in PostgreSQL also need the visibility map that VACUUM keeps, or every row is checked in the table.
  • INCLUDE adds columns to a PostgreSQL index that are stored but not searched, to cover a query.
  • Partial unique indexes enforce a rule on some rows only, such as one active subscription per user.
  • MySQL has no partial indexes, so a unique index on an expression stands in for one.
  • Immutable expressions only: PostgreSQL refuses to index a value that can change, such as a day that depends on the time zone setting.

Covering indexes and index-only scans, with pages counted Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • Why the first index-only scan saved nothing, and VACUUM did
    • Covering in InnoDB and SQLite: the primary key is already there
  • Partial indexes, and the rule that decides whether they are used
    • Size, plans and a prepared statement that cannot use the index
    • What SQLite accepts as implying the predicate, reproduced
    • A partial unique rule in MySQL, which has no partial indexes
  • Expression indexes, and why the query must repeat the expression
    • Three ways to ignore case, and only one that uses the index
    • The expression PostgreSQL refused, and the fix
  • Choosing between them
    • An index for a case-insensitive login
  • Check yourself
  • Interview questions
    • What is a covering index, and how do you see in a plan that a query used one?
    • A users table of 20 million rows must find an account by email whatever case the person types. Design the index and the query for PostgreSQL, MySQL and SQLite, and say what breaks if a developer later writes WHERE email ILIKE ?.
    • Each user may have many ended subscriptions but at most one active one, and the app often asks for the active subscription of a user. Enforce the rule and make the lookup fast in PostgreSQL and in MySQL 8.4, which has no partial indexes.
    • You added INCLUDE (amount) to an index so that a dashboard query would use an index-only scan. The plan says Index Only Scan, yet it is no faster, and EXPLAIN ANALYZE shows Heap Fetches close to the number of rows. Explain why and what you do.
  • Key takeaways

5 runnable examples (Python, SQL) · 1 exercise with sample tests · 3 interview answers · about 22 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.