dacospace

Lesson 03 · 10 min

Finding one card in a deck

seq scan, B-tree index, column order, EXPLAIN

The problem

A dealer opens "my last 20 orders". The orders table has hundreds of thousands of rows; this dealer owns a few hundred of them. The query is one line:

SELECT * FROM orders WHERE dealer_id = 42 ORDER BY created_at DESC LIMIT 20;

It returns 20 rows. Yet it can take longer than queries returning thousands of rows. The number of rows you get back says nothing about the number of rows the database had to look at.

Why it is hard

SQL hides the "how". You say what you want and Postgres decides how to find it. Most of the time it chooses well. When it has no good option, it does the only thing it can: read everything. You won't see that from your Java code. You only see it with EXPLAIN.

The idea: a shuffled deck

Take a normal 52-card deck and shuffle it. Someone asks you: "give me the 7 of hearts".

You have no choice: flip cards one by one from the top until you find it. On average you look at 26 cards; in the worst case all 52. That is a sequential scan (seq scan): read every row, keep the ones that match.

Now someone asks: "give me all the hearts, highest first". You must flip all 52 (any card could be a heart), pull out 13, then sort them. Read everything, then sort.

Now sort the deck first

This time, before anyone asks, you sort the deck: first by suit (all clubs, then diamonds, hearts, spades), and inside each suit by number. Same request: "all hearts, highest first".

You jump straight to where hearts start, and they are already in order. You read 13 cards and do no sorting. If they ask "the top 3 hearts" you read 3 cards.

That pre-sorted copy is an index. Postgres's default index type, the B-tree, keeps the values of some columns in sorted order with a pointer back to each row, and lets it jump to the right spot in a few steps instead of reading everything.

Column order matters

You sorted by suit first, then number. That order fits the question "hearts, highest first": suit is the filter, number is the sort.

What if you had sorted by number first, then suit (all aces, all twos, ...)? Now "all hearts, highest first" is awkward: hearts are spread across 13 groups. You have to visit every group. The deck is sorted, just not in a way that helps this question.

A two-column index works exactly like the deck. The rule of thumb:

  • Columns you filter with = go first.
  • The column you ORDER BY (or filter with a range like >) goes after them.

The order of columns in an index is a decision about which questions it answers fast.

Why not index everything?

Indexes are not free. Every sorted copy must be kept sorted. When a new card (row) arrives, you have to insert it into every sorted copy you keep. One index makes writes slightly slower and uses disk; ten indexes make every insert do ten extra jobs. So you index for the queries you actually run often, not "just in case".

Also, an index does not help when the filter matches most of the table. If 90% of the cards are hearts, jumping to "hearts" saves nothing: reading the whole deck is just as fast.

Reading EXPLAIN

EXPLAIN ANALYZE runs the query and shows the plan Postgres chose, with real timings. The parts to read first:

You seeIt means
Seq Scan on ordersread every row of the table
Index Scan using ...jumped into an index, then fetched matching rows
Sortthe rows had to be sorted after reading (the index didn't give that order)
rows=how many rows a step produced
Rows Removed by Filterrows read and thrown away: wasted work
Execution Timetotal time inside Postgres

A plan that reads hundreds of thousands of rows to return 20 is the deck-flipping you just did by hand.

Changing the schema the right way: migrations

In production nobody types CREATE INDEX into a console and walks away. Every schema change is a migration: a numbered SQL file committed with the code (V2__add_index.sql). On startup, Flyway checks which migrations the database has already run and runs the new ones in order. Every environment (your laptop, a test container, production) ends up with the same schema, and the change is reviewed like any other code.

A note for later: on a big, busy table, a plain CREATE INDEX blocks writes while it builds. Real teams use CREATE INDEX CONCURRENTLY. You don't need it in the lab, but remember the word.

Where you will meet this

  • Any "list my things" screen: orders by customer, messages by chat, invoices by tenant.
  • Foreign keys: Postgres does not create an index on a foreign key column for you. A missing one is one of the most common slow-query causes in real codebases.
  • Code review: "which index serves this new query?" is a fair question for every new query.

One sentence summary

Without an index Postgres flips every card; a B-tree index is a pre-sorted copy whose column order (filter columns first, sort column after) decides which questions it can answer fast.

Check question

The query is WHERE dealer_id = 42 AND status = 'PAID' ORDER BY created_at DESC LIMIT 20. Which three columns would you put in the index, in which order, and why? Use the deck to explain.

Go to mission 03: The dealer's order history →Answer the check question in your head before you start the mission.