By WebNest Studio Engineering Team
Database Design Tutorial
Indexing Strategy in Database Design
An index is a data structure the database maintains alongside a table specifically to make certain lookups fast, at the cost of extra storage and slightly slower writes (since every index must also be updated whenever the underlying row changes). Indexing strategy means deciding, deliberately, which columns and column combinations deserve an index based on how the application actually queries the data — not by indexing every column defensively, and not by leaving performance-critical queries unindexed and hoping the table stays small.
A schema can be perfectly normalized and fully constraint-correct and still perform badly in production if its indexes don't match its real query patterns. Indexing is where schema design meets actual, measured application behavior.
Why it matters
Indexes decide whether a query takes milliseconds or seconds, and they are designed from the queries the application actually runs, not from guesses. An index on the wrong columns, or with the columns in the wrong order, is dead weight that slows every write and helps nothing.
Index Around Real Query Patterns, Not Guesses
The right index for a table depends entirely on what WHERE clauses, JOIN conditions, and ORDER BY clauses the application actually runs against it. A foreign key column used constantly in joins (orders.customer_id) almost always deserves an index; a column that is only ever read as part of a full row fetch by primary key usually does not need one of its own. The starting point for indexing decisions should be the list of real queries gathered during the requirement-to-schema workflow, not a blanket rule like "index every column."
Composite Indexes and Column Order
A composite (multi-column) index is built on more than one column together, and the order of those columns matters enormously: a composite index on (customer_id, placed_at) can efficiently serve a query filtering on customer_id alone, or on customer_id and placed_at together, but it cannot efficiently serve a query that filters on placed_at alone, because the index is physically sorted by customer_id first. The general rule is to put the column used for equality filtering (customer_id = ?) before the column used for range filtering or sorting (placed_at > ? or ORDER BY placed_at).
The Cost Side of Indexing
Every index speeds up the reads it matches, but slows down every INSERT, UPDATE, and DELETE on that table slightly, because the index must be updated too, and it consumes additional disk space. A table with ten indexes "just in case" pays that write cost ten times over on every insert, for indexes that may serve queries the application never actually runs. Indexing strategy is therefore a genuine trade-off decision, revisited as query patterns are learned or change, not a one-time exhaustive pass.
- Index: foreign key columns used in joins, columns in frequent WHERE equality/range filters, columns used for sorting on hot pages.
- Be selective about indexing: low-cardinality columns (like a boolean) rarely benefit from a standalone index.
- Composite index order: equality-filtered columns first, then range-filtered or sorted columns.
Example
A composite index designed around a real, known query pattern
-- Known application query: "show a customer's orders, most recent first"
-- SELECT * FROM orders
-- WHERE customer_id = ?
-- ORDER BY placed_at DESC;
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
placed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
);
-- customer_id first (equality filter), placed_at second (sort/range)
CREATE INDEX idx_orders_customer_placed_at ON orders (customer_id, placed_at DESC);
-- This index also serves "customer_id = ? alone" efficiently, but does
-- NOT efficiently serve a query that filters on placed_at without
-- customer_id, since the index is sorted by customer_id first.
The known query — filter by customer_id, sort by placed_at descending —
can be satisfied directly from the index without a separate sort step
or a full table scan. A different query that only filters on placed_at
(e.g. "all orders placed today across all customers") would need its
own separate index on placed_at alone to be efficient.
Common Mistakes
- Adding an index to every column "just in case," which slows down every write on the table without necessarily speeding up any real query.
- Building a composite index with columns in the wrong order (range/sort column first, equality column second), which prevents the index from being used efficiently for the actual query pattern.
- Indexing a low-cardinality column alone (like a boolean is_active), which rarely narrows a search enough to be worth the index's write overhead.
- Designing indexes from guesses about future queries instead of the concrete query list gathered during the requirement-to-schema workflow.
Exercise
The application's most frequent query lists one customer's orders, newest first. Create a composite index named idx_orders_customer_created designed for exactly that query, with the columns in the right order. Then run the query for customer 1, returning the two newest order ids.
Expected output: id, 3, 1 (one per line)
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, created_at DATE NOT NULL);
INSERT INTO orders (id, customer_id, created_at) VALUES
(1, 1, '2026-02-10'),
(2, 2, '2026-02-11'),
(3, 1, '2026-03-05'),
(4, 1, '2026-01-20');
-- TODO: the composite index for "orders of one customer, newest first"
-- TODO: the ids of customer 1's two newest orders
Show hints
- Put the column used for equality first and the column used for sorting second:
(customer_id, created_at). - The query filters on
customer_id, sorts bycreated_at DESCand takesLIMIT 2.
Show solution
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, created_at DATE NOT NULL);
INSERT INTO orders (id, customer_id, created_at) VALUES
(1, 1, '2026-02-10'),
(2, 2, '2026-02-11'),
(3, 1, '2026-03-05'),
(4, 1, '2026-01-20');
CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at);
SELECT id
FROM orders
WHERE customer_id = 1
ORDER BY created_at DESC
LIMIT 2;
Quick quiz
1. For a query with WHERE status = ? ORDER BY created_at, which composite index fits best?
- A. (created_at, status)
- B. (status, created_at)
- C. (id, status)
- D. Two separate single-column indexes
Show answer
B. The equality column comes first, so the matching rows are already in created_at order.
2. What should guide the choice of indexes?
- A. The queries the application actually runs
- B. Indexing every column
- C. The alphabetical order of the columns
- D. The size of the server
Show answer
A. An index that no query uses only costs storage and write time.
3. What does every additional index cost?
- A. Slower reads
- B. Fewer rows
- C. Weaker constraints
- D. Slower inserts, updates and deletes, and more storage
Show answer
D. Each write must also maintain every index on the table.
Interview questions
How do you decide the order of columns in a composite index?
Show answer
Put the columns compared with equality first, then the column used for a range condition or for sorting. An index is usable only from its leftmost column, so (customer_id, created_at) serves a filter on customer_id alone, or on both, but not on created_at alone. Among equality columns, the more selective one usually goes first. The order should follow the most important queries.
What is a covering index?
Show answer
An index that contains every column a query needs, so the database can answer from the index alone without reading the table rows, which is called an index-only scan. Extra columns can be added to the key, or, in PostgreSQL and SQL Server, attached with INCLUDE. It speeds up frequent queries that read a few columns, at the cost of a larger index.
Key Points to Remember
- Indexes should be chosen based on the application's actual, known query patterns — not applied defensively to every column.
- Composite index column order matters: put equality-filtered columns before range-filtered or sorted columns.
- Every index has a write and storage cost, so indexing is a deliberate trade-off, not a free performance upgrade.
- Foreign key columns used in frequent joins are strong default candidates for indexing; low-cardinality columns usually are not.
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs your SQL on SQLite in your browser. Standard SQL works; a few features that exist only in PostgreSQL or MySQL do not. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering