Free Online Code Editor
Learn what a database index is, how to read a query plan, how to design composite indexes, and the habits that quietly stop your indexes from being used.
Your query works, but it's slow, and it was fast when the table was small. That's the classic sign you need an index.
Think of the index at the back of a book. If you want every page that mentions "photosynthesis," you don't read the entire book. You look it up in the index and jump to the pages. A database index does the same for rows.
When you filter on a column that has no index, the database reads every row and checks each one. This is called a full table scan. It's fine for 500 rows and painful for 50 million.
Let's create a table with enough rows to be realistic and look at how the database plans to run a query. Run this in the SQL editor.
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, city TEXT, created_at TEXT);
WITH RECURSIVE seq(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 5000
)
INSERT INTO users (email, city, created_at)
SELECT 'user' || n || '@example.com',
CASE n % 4 WHEN 0 THEN 'Toronto' WHEN 1 THEN 'Ottawa' WHEN 2 THEN 'Calgary' ELSE 'Halifax' END,
date('2024-01-01', '+' || (n % 365) || ' days')
FROM seq;
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE email = 'user4321@example.com';SCAN usersThe plan says SCAN. The database will walk through all 5,000 rows to find that one email. (EXPLAIN is available in most databases. The wording of the plan differs, but it's the same idea.)
CREATE INDEX idx_users_email ON users (email);
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE email = 'user4321@example.com';SEARCH users USING INDEX idx_users_email (email=?)Now it says SEARCH using the index. Instead of checking thousands of rows, the database jumps straight to the right one. An index is typically stored as a sorted tree, so finding a value takes a handful of steps even with millions of rows.
An index can cover several columns. The order matters a lot. Suppose you often run this query:
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE city = 'Toronto' AND created_at >= '2024-06-01';SCAN usersIt's a scan again. Add an index on both columns, with the equality column first:
CREATE INDEX idx_users_city_created ON users (city, created_at);
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE city = 'Toronto' AND created_at >= '2024-06-01';SEARCH users USING INDEX idx_users_city_created (city=? AND created_at>?)Think of a phone book sorted by last name, then first name. You can find "Smith, Anna" quickly, and "all the Smiths." But you can't use it to find everyone named "Anna" without checking every page.
The same idea applies here. An index on (city, created_at) helps queries that filter on city, or on city and created_at. It doesn't help a query that filters on created_at alone. A good rule: put the columns you compare with = first, then the ones you compare with ranges (>, <, BETWEEN).
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE created_at >= '2024-06-01';SCAN usersHaving an index doesn't guarantee it gets used. These patterns are common culprits:
Wrapping the column in a function. The database can't use the index on email if you search on lower(email).
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE lower(email) = 'user4321@example.com';SCAN usersFix it by storing the data in a consistent form, or by creating an index on the expression itself (CREATE INDEX ... ON users (lower(email))), which SQLite and PostgreSQL both support.
A leading wildcard. LIKE 'user43%' can use an index on many databases. LIKE '%4321@example.com' cannot, because the start of the value is unknown.
Doing math on the column. WHERE age + 1 = 30 can't use an index on age. Write WHERE age = 29 instead.
Mismatched types. Comparing a number column to a string value can force a conversion on every row.
Low selectivity. An index on a column with only a few distinct values, such as a true/false flag, rarely helps because it doesn't narrow things down much.
Every index has a cost:
INSERT, UPDATE, and DELETE has to update each index on the table, so writes get slower.That means "index everything" is a bad plan. Add indexes for the queries you actually run, and drop ones nobody uses.
Primary keys and UNIQUE constraints create indexes automatically. That's one of the reasons they're also fast to look up. If a column is a foreign key you join on, such as orders.customer_id, it usually deserves an index too, since joins constantly look rows up by it.
EXPLAIN (or EXPLAIN QUERY PLAN in SQLite, EXPLAIN ANALYZE in PostgreSQL).WHERE, JOIN, and ORDER BY columns.EXPLAIN again to confirm it's being used, and time the query.EXPLAIN to see whether an index is being used.Reading helps, but typing the code is what makes it stick. Open the editor, paste any example from this article, and change it until it breaks.
Open the SQL editorUnderstand every SQL JOIN with two small tables you can run yourself. See what each returns, when rows go missing, and how to avoid duplicates.
Learn SQL window functions with a small sales table. Rank rows, compare to the previous row, calculate running totals, and find the top result per group.
Learn how SQL transactions use BEGIN, COMMIT, ROLLBACK, and SAVEPOINT to keep data consistent, with a bank transfer example you can run and break on purpose.