From 17ms to 0.04ms: How to Design the Right SQL Index

From 17ms to 0.04ms: How to Design the Right SQL Index

6 min read··

databaseperformancepostgresqlsql

A good SQL index comes from the queries your application runs, not from the table schema. Composite indexes need the right column order: equality columns first, then the column you sort or range on. EXPLAIN ANALYZE is how you verify it: a sequential scan over 1 million comments takes 17ms, and the right composite index answers in 0.04ms.

MongoDB Connects Live, Operational Data to the AI Tools Builders Use. Explore New MongoDB capabilities for the Agentic Era.

AI coding agents are fast, but they also burn tokens finding the code they need to change. Sonar Vortex feeds agents exact architectural context before they write a single line of code. Learn how Sonar gives agents the structure that saves your tokens.

What does a good SQL index look like?

The answer will vary based on your queries and access paths. The only way to confidently know is to examine the query plans with EXPLAIN ANALYZE and figure out from there which index might help.

So let's do exactly that. I seeded a Postgres 18 instance in Docker with an issue tracker: 100 users, 10,000 issues, and 1 million comments. By the end, one query drops from 436ms to half a millisecond.

What Is a SQL Index?

An index stores your chosen columns in sorted order, with every entry pointing back to its full row. The default kind in every major database is the B-tree: a shallow tree, a few levels deep even at millions of rows. A sequential scan reads all 1 million comments; an index scan descends those few levels and fetches only the matches.

Side by side comparison of a sequential scan reading every row in a one million row comments table versus a B-tree index scan descending three levels and fetching only the matching rows

Start With the Query, Not the Table

You don't pick indexes by staring at the schema; they come from the queries your application actually runs.

My comments table serves three access patterns:

  • All comments by a user
  • All comments for an issue
  • Comments for an issue from one user, newest first, last month only

Reading the First Plan

The first pattern, with no index beyond the primary key:

EXPLAIN ANALYZE
SELECT COUNT(*)
FROM comments
WHERE user_id = 1;

---
Finalize Aggregate
  ->  Gather
        ->  Partial Aggregate
              ->  Parallel Seq Scan on comments  (actual time=0.010..11.727 rows=3356.67 loops=3)
                    Filter: (user_id = 1)
                    Rows Removed by Filter: 329977
Execution Time: 17.066 ms

EXPLAIN ANALYZE runs the query for real and prints the plan Postgres used: a Parallel Seq Scan reads all 1 million rows to count 10,070, in 17ms.

Create the index and rerun the query:

CREATE INDEX ix_comments_user_id
ON comments (user_id);
Aggregate
  ->  Index Only Scan using ix_comments_user_id on comments  (actual time=0.024..0.348 rows=10070.00 loops=1)
        Index Cond: (user_id = 1)
        Heap Fetches: 0
Execution Time: 0.612 ms

17ms down to 0.6ms. It's an Index Only Scan because the index alone can answer a COUNT(*): Postgres never touches the table.

Column Order Is Everything

The third access pattern is the interesting one:

SELECT *
FROM comments
WHERE issue_id = 10
  AND user_id = 29
  AND created_at >= NOW() - INTERVAL '1 month'
ORDER BY created_at DESC;

With no index, it's another sequential scan: 16.6ms. With single-column indexes on issue_id and user_id, Postgres intersects them with a BitmapAnd and still sorts the survivors: 0.6ms, in three steps.

A composite index answers the whole query in one motion:

CREATE INDEX ix_comments_issue_user_date
ON comments (issue_id, user_id, created_at DESC);
Index Scan using ix_comments_issue_user_date on comments  (actual time=0.019..0.026 rows=2.00 loops=1)
  Index Cond: ((issue_id = 10) AND (user_id = 29) AND (created_at >= (now() - '1 mon'::interval)))
Execution Time: 0.039 ms

All three conditions moved into the Index Cond, and the Sort is gone: the index already returns rows ordered by created_at DESC. Runtime: 0.04ms, over 400x faster.

A composite index sorts by its first column, then the second within equal values, then the third. Postgres jumps straight to the issue_id = 10, user_id = 29 section and reads it in order.

Column order also decides what else the index can serve: issue_id alone works, issue_id plus user_id works, but user_id alone doesn't (its values are scattered across the whole tree). This is the leftmost prefix rule, and it's why the index on user_id stays.

The rule of thumb: equality columns first, then the column you sort or range on.

The Query Our New Index Can't Serve

Every issue tracker runs this dashboard query: the 25 newest open issues, each with its latest comment, fetched by a LATERAL subquery:

SELECT i.id, c.body, c.created_at
FROM issues i
CROSS JOIN LATERAL (
  SELECT body, created_at
  FROM comments
  WHERE issue_id = i.id
  ORDER BY created_at DESC
  LIMIT 1
) c
WHERE i.status = 'open'
ORDER BY i.created_at DESC
LIMIT 25;
Nested Loop  (actual time=0.790..352.076 rows=6537.00 loops=1)
  ->  Seq Scan on issues i  (rows=6537.00 loops=1)
  ->  Limit  (rows=1.00 loops=6537)
        ->  Sort  (actual time=0.053..0.053 rows=1.00 loops=6537)
              ->  Bitmap Index Scan on ix_comments_issue_user_date  (loops=6537)
Execution Time: 435.794 ms

The composite index gets used, but its entries are sorted by user_id before created_at, so a Sort runs 6,537 times, once per open issue: 436ms.

Column order strikes again. For this access path, created_at must come right after issue_id:

CREATE INDEX ix_comments_issue_date
ON comments (issue_id, created_at DESC);

Each probe becomes a one-row index scan: 25ms. But the LIMIT still can't stop the loop, because issues arrive unsorted. One more index streams them newest-first:

CREATE INDEX ix_issues_status_date
ON issues (status, created_at DESC);
Limit  (actual time=0.086..0.465 rows=25.00 loops=1)
  ->  Nested Loop  (actual time=0.085..0.463 rows=25.00 loops=1)
        ->  Index Scan using ix_issues_status_date on issues i  (rows=25.00 loops=1)
        ->  Limit  (rows=1.00 loops=25)
              ->  Index Scan using ix_comments_issue_date on comments  (rows=1.00 loops=25)
Execution Time: 0.489 ms

Every node reads only what it returns: 25 issues, 25 probes, one comment each. 0.5ms, nearly 900x faster.

What Do Indexes Cost?

Every insert, update, and delete now maintains every index, so each one you add slows writes a little. They take disk space, too:

SELECT indexrelname AS index_name,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'comments';

Each composite index weighs 30 MB for 1 million comments, against about 7 MB per single-column one. And (issue_id, created_at DESC) makes the plain issue_id index redundant, so drop it. Index the queries you actually run, not the ones you might run someday.

An index only helps if the query can use it: wrap the indexed column in a function and Postgres ignores it, a failure mode I covered in Why Postgres Ignores Your Index.

Summary

  • Design indexes from your queries, not your tables.
  • Composite indexes need the right column order: equality columns first, then the sort column.
  • LIMIT only helps when an index feeds it rows already in order.
  • EXPLAIN ANALYZE is the proof. Read the plan, not just the timing.
  • Every index costs writes and space.

Once the indexes are right, cursor pagination is the natural next step, built on exactly these composite indexes.

Thanks for reading.

And stay awesome!


Frequently Asked Questions

What is a composite index?

A composite index stores several columns together, sorted by the first column, then by the second within equal values, then by the third. Postgres can jump straight to the section matching the leading columns and read it in order.

What order should columns go in a composite index?

Equality columns first, then the column you sort or range on. On the demo query, an index on (issue_id, user_id, created_at DESC) moved all three conditions into the index condition and removed the sort, going from 16.6ms to 0.04ms.

What is the leftmost prefix rule?

A composite index only serves queries that use its leading columns. With (issue_id, user_id, created_at DESC), filtering on issue_id works and issue_id plus user_id works, but user_id alone doesn't, because its values are scattered across the whole tree.

Why is my query still doing a sort when it uses an index?

The index returns rows in the wrong order. A dashboard query hit the index on (issue_id, user_id, created_at DESC), which sorts by user_id before created_at, so Postgres ran a sort once per open issue, 6,537 times, and the query took 436ms.

What is an Index Only Scan in Postgres?

An Index Only Scan means the index alone can answer the query, so Postgres never touches the table. Counting comments for one user ran as an Index Only Scan with zero heap fetches, in 0.6ms instead of a 17ms sequential scan.

Do indexes slow down writes?

Yes. Every insert, update, and delete maintains every index on the table, so each one you add slows writes a little. Indexes take disk space too: each composite index here weighed 30 MB for 1 million comments, against about 7 MB per single-column index.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.