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.
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
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:
-- 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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
VACUUMkeeps, 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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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
- PostgreSQL 18 Documentation: Index-Only Scans and Covering Indexes (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Partial Indexes (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Indexes on Expressions (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: CREATE INDEX (INCLUDE) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Visibility Map (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: PREPARE (generic and custom plans) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Function Volatility Categories (The PostgreSQL Global Development Group)
- SQLite: Partial Indexes (SQLite)
- SQLite: Indexes On Expressions (SQLite)
- SQLite: Query Planning (covering indexes) (SQLite)
- MySQL 8.4 Reference Manual: CREATE INDEX (functional key parts) (Oracle Corporation)
- MySQL 8.4 Reference Manual: Clustered and Secondary Indexes (Oracle Corporation)
- MySQL 8.4 Reference Manual: Unicode Character Sets (utf8mb4_0900_ai_ci) (Oracle Corporation)
- MySQL 8.4 Reference Manual: EXPLAIN Output Format (Using index) (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress