Module 7 closed with a change of question. For seven modules the question has been does this return what I want?; from here on it's how long does it take?. And the answer almost always runs through the same object: the index, an auxiliary data structure the engine maintains alongside the table and that turns "look at every row until you find it" into "go straight to where it is".

In this lesson you won't create a single index yet. First you have to understand what problem they solve and how they work inside, because almost every mistake made with indexes —creating the useless ones, not creating the necessary ones, being surprised that the engine ignores them— comes from not having the right mental model. By the end you'll know why searching among 20 million rows can cost four disk accesses, why LIKE '%organic%' can't take advantage of any of them, and —what will surprise you most— which indexes GreenStore already has without anybody creating them and which ones it's missing.

Contents

  1. The book-index analogy, and where it breaks down
  2. How the engine reads a table with no index
  3. The B-tree structure
  4. What an index really stores: the key and the pointer
  5. Index Scan, Index Only Scan and covering indexes
  6. What a B-tree speeds up and what it doesn't
  7. The indexes GreenStore already has
  8. The hole: PostgreSQL doesn't index foreign keys
  9. The price of an index
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. The book-index analogy, and where it breaks down

You have an 800-page manual and you want to know where HAVING is discussed. There are two ways:

  • Leaf through the whole book until you find it. It always works, and it costs 800 pages.
  • Go to the alphabetical index at the back, look up "HAVING" —which is sorted, so you locate it in seconds— and read "p. 412".

A book's index is exactly a database index: a sorted copy of part of the information (the terms) together with a pointer to where the rest is (the page). It doesn't contain the book; it contains just enough to jump into it.

The analogy is a good one, but it's worth marking where it breaks down, because that's precisely where the interesting decisions begin:

A book's index A database index
There's one, at the back There can be many on the same table, each one over different columns
It's composed once, at printing time It's kept alive: every INSERT, UPDATE and DELETE updates it
It takes up 10 pages out of 800 It can take up as much as the table itself, or more
You're always the one using it The planner uses it, and sometimes it decides it isn't worth it
It's only good for looking up terms It's good for searching, for sorting and for grouping

Those five differences are, in fact, the script of the whole module. The fourth is the hardest to accept: creating an index doesn't guarantee it gets used. You'll see it demonstrated over GreenStore in lesson 08-05.

  1. How the engine reads a table with no index

With no index there's only one possible strategy: the sequential scan (Seq Scan in PostgreSQL jargon). The engine reads the table block by block from the start, checks the WHERE on each row and discards the ones that don't qualify.

SELECT id, name, price
FROM products AS p
WHERE p.price > 10
ORDER BY p.price DESC;
id name price
15 Ceremonial matcha green tea 30 g 22.00
6 Aloe vera face cream 50 ml 18.90
20 Spirulina capsules 120 units 16.40
8 Almond body oil 200 ml 14.25
13 Soy wax candles (pack of 2) 13.75
1 Extra virgin olive oil 500 ml 12.50
10 Concentrated eco laundry detergent 1 L 11.20

Seven rows out of twenty. To hand them to you, the engine has read all twenty. With 20 rows that's free. The key is how it scales:

Rows in the table Rows read by a Seq Scan Rows read with a B-tree index Order of magnitude of the time
20 20 ~2 Imperceptible in both cases
1,000 1,000 ~3 Imperceptible
100,000 100,000 ~3 Tenths of a second → microseconds
10,000,000 10,000,000 ~4 Seconds → microseconds
1,000,000,000 1,000,000,000 ~5 Minutes → microseconds

(Intuition figures, not laboratory ones: the exact number depends on row width, on the cache and on the disk. What matters is the shape of the two columns.)

The Seq Scan column grows linearly: twice the rows, twice the time. The index column grows logarithmically: multiplying the rows by a thousand adds one access. That difference between O(n) and O(log n) is the whole module in one line, and it explains why a query that runs perfectly with development data can bring production down six months later.

A Seq Scan isn't a mistake. It's the right strategy when the table is small or when you're going to return a good part of it. You'll see it in detail in 08-03 and demonstrated in 08-05.

  1. The B-tree structure

The default index in PostgreSQL —and in every relational engine— is the B-tree (balanced tree). It's a sorted tree with three kinds of node:

  • Root: a single node, the entry point of every search.
  • Internal nodes: they contain separator values and pointers to nodes on the level below. They say "the values lower than 5.50 are over here".
  • Leaves: they contain the key's actual values, each with the pointer to the table row. On top of that, the leaves are linked to each other in order, which makes it possible to walk a range without climbing back up the tree.

This is what a B-tree index over products.price would look like, with the catalogue's 20 prices spread across five leaves of four entries each:

flowchart TD
    R["<b>ROOT</b><br/>3.90 · 5.50 · 9.90 · 14.25"]
    H1["<b>Leaf 1</b><br/>1.95 → prod 5<br/>2.80 → prod 4<br/>3.25 → prod 14<br/>3.50 → prod 18"]
    H2["<b>Leaf 2</b><br/>3.90 → prod 2<br/>4.60 → prod 9<br/>4.95 → prod 16<br/>5.40 → prod 17"]
    H3["<b>Leaf 3</b><br/>5.50 → prod 11<br/>7.80 → prod 19<br/>8.40 → prod 7<br/>9.75 → prod 3"]
    H4["<b>Leaf 4</b><br/>9.90 → prod 12<br/>11.20 → prod 10<br/>12.50 → prod 1<br/>13.75 → prod 13"]
    H5["<b>Leaf 5</b><br/>14.25 → prod 8<br/>16.40 → prod 20<br/>18.90 → prod 6<br/>22.00 → prod 15"]
    R --> H1
    R --> H2
    R --> H3
    R --> H4
    R --> H5
    H1 -.-> H2 -.-> H3 -.-> H4 -.-> H5

Look for the €12.50 product: you come in through the root, you see that 12.50 sits between 9.90 and 14.25, you go down to leaf 4 and there it is. Two accesses, not twenty. And for WHERE price BETWEEN 9 AND 15 you come in once at 9.00 and then follow the dotted arrows between leaves until you go past 15: that's what makes a B-tree just as good for ranges as for equalities.

Why the height grows so slowly

In PostgreSQL each node of the tree is an 8 kB page. A lot of entries fit on that page: for a 4-byte integer key, on the order of 250 usable entries once the headers and the free margin the engine leaves are discounted. That number is called the branching factor (fanout), and it's what makes the tree wide and short instead of narrow and tall.

Tree height Rows it can index (fanout ≈ 250) Accesses to locate a row
1 (root only) 250 1
2 62,500 2
3 ~15.6 million 3
4 ~3.9 billion 4

Read it slowly, because it's the figure worth memorising from the module: a table of fifteen million rows is traversed in three accesses. And of those three, the upper levels are almost always in cache because every query goes through them, so in practice the real cost is usually one disk access, or none.

The fanout depends on the width of the key: indexing an INTEGER gives very wide trees; indexing a VARCHAR(150) with long names gives entries five times as big, fewer entries per page and, with the same rows, one extra level of height. It's the first reason why indexing narrow columns comes cheaper.

The "balanced" in the name means that all the leaves sit at the same depth, and the engine keeps it that way by splitting and merging pages on insert and delete. That's why there are no "unbalanced indexes" that need rebuilding by hand: the cost of keeping them balanced is paid on every write, and that's where a good part of the price section 9 talks about comes from.

  1. What an index really stores: the key and the pointer

A leaf entry doesn't contain the row. It contains two things:

  1. The key's value (12.50).
  2. A physical pointer to the row, which in PostgreSQL is called the ctid and is a pair (block, position within the block).

The ctid is a system column you can query:

SELECT ctid, id, name, price
FROM products AS p
WHERE p.id IN (1, 5, 15);
ctid id name price
(0,1) 1 Extra virgin olive oil 500 ml 12.50
(0,5) 5 Organic crushed tomato 400 g 1.95
(0,15) 15 Ceremonial matcha green tea 30 g 22.00

All twenty products are in block 0: they fit comfortably in a single 8 kB page. That apparently incidental detail is the reason no index is any use at all on this table, and it'll come back in section 7 and in 08-05.

Careful: the ctid isn't a stable identifier. It changes when the row is updated, because PostgreSQL writes a new version somewhere else. Never store it in a column or use it as a key: that's what id is for. Here we're only using it to look at the machinery from the inside.

The consequence of the index storing a pointer and not the row is important: an index lookup involves two steps. First you go down the tree to the leaf and get the ctid; then you have to go to the table (the heap) to read the row and pick up the columns you asked for. That second step is called a heap fetch, and if your query returns a thousand rows, that's a thousand jumps to possibly scattered positions on disk.

  1. Index Scan, Index Only Scan and covering indexes

Two of the plan nodes you'll see constantly in 08-05 come out of that:

Node What it does When it appears
Index Scan Walks the index and goes to the table for each row found The normal case: you need columns that aren't in the index
Index Only Scan Walks the index and doesn't touch the table Every column the query asks for is in the index

The second is the jackpot: the whole heap fetch is saved. And it's achieved with what's called a covering index: an index that covers every column the query needs, both for filtering and for displaying.

-- If an index on (price) exists, this query has to go to the table
-- to fetch the name: Index Scan.
SELECT p.name, p.price FROM products AS p WHERE p.price > 15;

-- This other one only asks for the indexed column: it can be resolved
-- entirely inside the index, without touching the table: Index Only Scan.
SELECT p.price FROM products AS p WHERE p.price > 15;

In lesson 08-02 you'll see how to build covering indexes on purpose with the INCLUDE clause, which adds columns to the index just to be read, without using them for sorting.

An honest nuance: in PostgreSQL an Index Only Scan doesn't always avoid 100 % of the table accesses. The index doesn't know whether a row is visible to your session, so it consults an auxiliary map (the visibility map) and, for the blocks marked as not entirely clean, it does go to the heap. That shows up in the plan as Heap Fetches: N. If that number is high, the table needs maintenance (VACUUM), and that's covered in 08-05.

  1. What a B-tree speeds up and what it doesn't

A B-tree is sorted. Anything that can be expressed as "go to a point in the order and move forward" it solves; everything else, it doesn't. This table closes three promises the course has been carrying since modules 2, 4 and 6:

Operation Does it use a B-tree? Why
price = 12.50 ✅ Yes Equality: you go down to the exact point
price > 10, price BETWEEN 5 AND 15 ✅ Yes Range: one entry point and you walk the linked leaves
status IN ('paid','shipped') ✅ Yes It's equivalent to several equality lookups
name LIKE 'Organic%' ✅ Yes A prefix is a range: from 'Organic' to 'Organid'
ORDER BY price ✅ Yes The index is already sorted: it's read in order and the Sort is saved
MIN(price), MAX(price) ✅ Yes They're the first and the last entry of the index
ORDER BY price DESC LIMIT 5 ✅ Yes 5 entries are read from the end and it stops
name LIKE '%organic%' No With no prefix there's no entry point: it could be in any leaf
LOWER(email) = 'a@b.com' No The index stores email, not LOWER(email): they're different values
EXTRACT(YEAR FROM order_date) = 2025 No Same reason: the index doesn't contain the function's result
status <> 'delivered' ❌ Almost never The negation describes nearly the whole table; it isn't a useful range
price + 2 > 15 No There's an operation on the filtered column

The four red rows at the end all have the same cause: to use an index, the column has to appear bare on one side of the comparison. The moment you wrap it in a function, in a calculation or in a leading wildcard, the engine can no longer translate your condition into "a point in the order and move forward".

Two practical consequences you'll resolve in the coming lessons, not now:

  • The conditions in the red rows can almost always be rewritten. EXTRACT(YEAR FROM order_date) = 2025 is identical to order_date >= '2025-01-01' AND order_date < '2026-01-01', and this second version does use the index: they're the same 16 orders from 2025. That rewrite has a name —sargability— and it's the heart of lesson 08-04.
  • When they can't be rewritten, there are specific tools: an index on the expression for LOWER(email) (08-02) and the pg_trgm extension with a GIN index for the LIKE '%organic%' that 04-01 left pending (08-03).

  1. The indexes GreenStore already has

Here comes the surprise: you've never written a CREATE INDEX and GreenStore already has eleven indexes. In 05-01 it was mentioned in passing that PRIMARY KEY and UNIQUE "are implemented by creating an index underneath, and the structures are covered in module 8". The moment has come.

greenstore=> \d products
                                  Table "public.products"
    Column    |          Type          | Nullable |               Default
--------------+------------------------+----------+-------------------------------------
 id           | integer                | not null | generated by default as identity
 name         | character varying(150) | not null |
 category_id  | integer                |          |
 supplier_id  | integer                |          |
 price        | numeric(10,2)          | not null |
 cost         | numeric(10,2)          |          |
 stock        | integer                | not null | 0
 active       | boolean                | not null | true
 added_date   | date                   | not null | CURRENT_DATE
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "products_category_id_fkey" FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT
    "products_supplier_id_fkey" FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE RESTRICT
Referenced by:
    TABLE "order_lines" CONSTRAINT "order_lines_product_id_fkey" FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT
    ...

A single index, products_pkey, with btree written out explicitly: it's section 3's structure. To see them all at once, the pg_indexes catalog view:

SELECT tablename, indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname;
tablename indexname indexdef
categories categories_name_key CREATE UNIQUE INDEX ... ON public.categories USING btree (name)
categories categories_pkey CREATE UNIQUE INDEX ... USING btree (id)
customers customers_email_key CREATE UNIQUE INDEX ... USING btree (email)
customers customers_pkey CREATE UNIQUE INDEX ... USING btree (id)
employees employees_pkey CREATE UNIQUE INDEX ... USING btree (id)
order_lines order_lines_pkey CREATE UNIQUE INDEX ... USING btree (id)
orders orders_pkey CREATE UNIQUE INDEX ... USING btree (id)
products products_pkey CREATE UNIQUE INDEX ... USING btree (id)
returns returns_pkey CREATE UNIQUE INDEX ... USING btree (id)
reviews reviews_pkey CREATE UNIQUE INDEX ... USING btree (id)
suppliers suppliers_pkey CREATE UNIQUE INDEX ... USING btree (id)

Eleven indexes: nine from the primary keys and two from the UNIQUE constraints on categories.name and customers.email. They're all UNIQUE INDEX ... USING btree, and they all exist because a constraint needs them: the only reasonable way of checking "this value isn't repeated" before accepting an INSERT is to have the values sorted. That's why in PostgreSQL the constraint and the index are the same physical object, and that's why an INSERT into customers with a duplicate email fails in microseconds rather than by reading the 15 rows.

This also explains why WHERE c.id = 7 or WHERE c.email = 'lucia.martinez@example.com' have always been fast in the course, even though nobody had mentioned indexes.

  1. The hole: PostgreSQL doesn't index foreign keys

And now the most valuable point of the lesson. Look at the list again: products.category_id doesn't appear. Nor does orders.customer_id. Nor order_lines.order_id.

PostgreSQL creates an index automatically for PRIMARY KEY and for UNIQUE, but NOT for foreign keys. The index exists on the referenced side (the parent's PK), never on the referencing side (the child column).

These are GreenStore's eleven foreign keys, all of them unindexed:

Table FK column References Action Queries that suffer
products category_id categories(id) RESTRICT Products in a category
products supplier_id suppliers(id) RESTRICT Products from a supplier
customers referred_by_id customers(id) SET NULL A customer's referrals
employees manager_id employees(id) SET NULL A manager's reports
orders customer_id customers(id) RESTRICT A customer's orders
orders employee_id employees(id) SET NULL A sales rep's orders
order_lines order_id orders(id) CASCADE An order's lines
order_lines product_id products(id) RESTRICT A product's sales
reviews product_id products(id) CASCADE A product's reviews
reviews customer_id customers(id) CASCADE A customer's reviews
returns order_id orders(id) CASCADE An order's returns

Why it hurts, on two fronts.

First, the JOINs. The course's canonical query joins order_lines with orders, customers and products. Each ON ol.order_id = o.id can be resolved in two directions: looking up orders by id (indexed, fast) or looking up order_lines by order_id (unindexed, full scan). With 47 lines it makes no difference; with 20 million, "the lines of order 8,412,006" with no index means reading all 20 million.

Second, and less well known: deletes on the parent. When you run DELETE FROM orders WHERE id = 5, PostgreSQL is obliged to check every table referencing ordersorder_lines and returns— to apply the CASCADE. If those columns have no index, every order deletion triggers a full scan of the child tables. It's one of the classic causes of "deleting a row takes 40 seconds" in large databases, and the engine doesn't warn you: the slow query isn't the one you wrote.

Dialect note: this isn't the case in every engine. MySQL with InnoDB creates an index automatically over every foreign-key column if a suitable one doesn't already exist, and in fact it requires one. Oracle and SQL Server behave like PostgreSQL: they don't create it, and their official documentation recommends creating it by hand. If you're coming from MySQL, this is probably the performance difference that will bite you hardest when migrating.

The practical rule you take away —and that you'll apply in the next lesson— is a short one: almost every foreign-key column wants its index. "Almost", because there are exceptions (a tiny child table, or an FK you never filter or delete by), and those exceptions are 08-03's subject.

  1. The price of an index

If indexes were free, the answer would be to index everything. They aren't, and it's worth keeping the cost in mind from the first minute:

Cost What it consists of
Disk space An index over an integer column can take up between 10 % and 40 % of the table's size. Five indexes can take up more than the data
Slower writes Every INSERT and every DELETE updates all the table's indexes. An UPDATE updates the ones on the columns it touches (and, in PostgreSQL, often all of them)
Planner work More possible paths to evaluate before deciding on the plan
Maintenance VACUUM and backups have more objects to walk through

The consequence is that an index is a bet: you speed up the reads that use it in exchange for making every write on the table more expensive. On a table read a thousand times per write, the bet is excellent. On an event-log table written to constantly and queried once a month, it's a bad deal. Quantifying that bet —and the list of cases in which you should not index— is all of lesson 08-03.

Common Mistakes and Tips

  • Believing that creating an index guarantees it gets used. The planner decides. With small tables, with poorly selective filters or with stale statistics, it'll choose the Seq Scan and it'll be right.
  • Thinking the index contains the row. It contains the key and a pointer. Hence the second access to the table, the Index Scan against the Index Only Scan and the very existence of covering indexes.
  • Assuming foreign keys are indexed. In PostgreSQL they aren't. It's the most profitable discovery in this lesson.
  • Indexing a column and going on filtering with a function on top of it. LOWER(email) doesn't use the index on email. Either you rewrite the query, or you create an index on the expression (08-02).
  • Expecting miracles from LIKE '%text%'. No B-tree can help you without a prefix. The solution exists, but it's another family of indexes (08-03).
  • Confusing the ctid with an identifier. It changes with every UPDATE. To identify a row there's its primary key.
  • Tip: think in terms of "a point in the order and move forward". If your condition can be translated into that, the index is useful. If not, it isn't. It's the best mental filter there is for predicting a plan without running it.
  • Tip: always look at \d table before creating an index. It's the quickest way of discovering an equivalent one already exists.
  • Tip: memorise the height table. Knowing that 15 million rows are 3 accesses saves you whole arguments about whether "the table is too big to search".

Exercises

Exercise 1

For each condition, say whether a B-tree index on the column involved could be used, and justify it in one sentence.

-- a)
WHERE p.price BETWEEN 5 AND 15
-- b)
WHERE UPPER(c.city) = 'VALENCIA'
-- c)
WHERE p.name LIKE '%eco%'
-- d)
WHERE o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'
-- e)
WHERE ol.quantity * ol.unit_price > 50
-- f)
ORDER BY p.price DESC LIMIT 5

Exercise 2

Without running anything, answer:

  1. How many indexes does the order_lines table have today and on which columns?
  2. Which of its columns are foreign keys and which of those are indexed?
  3. DELETE FROM orders WHERE id = 5 cascaded and deleted three lines in lesson 01-06. Describe what the engine has to do in order_lines to locate them, and how it would change with 20 million lines.

Exercise 3

A colleague proposes: "Since indexes speed up queries, let's create one on every column of orders: customer_id, employee_id, order_date, status, payment_method and shipping_cost."

  1. Give two technical arguments against, using what you saw in sections 6 and 9.
  2. Which of those six columns strike you as reasonable candidates and which don't? Justify it with the nature of GreenStore's data.

Solutions

Solution 1

# Does it use the index? Why
a) price BETWEEN 5 AND 15 ✅ Yes A range is an entry point plus a walk over the linked leaves. It would return 10 of the 20 products, so the planner might prefer the Seq Scan anyway: usable isn't the same as used
b) UPPER(c.city) = 'VALENCIA' ❌ No The index stores city, not UPPER(city). With an index on the expression, yes (08-02)
c) name LIKE '%eco%' ❌ No With no fixed prefix there's no entry point in the order. It needs pg_trgm + GIN (08-03)
d) 2025 date range ✅ Yes The column appears bare and the condition is a range. It's the sargable version of EXTRACT(YEAR ...) = 2025, and it returns the same 16 orders
e) quantity * unit_price > 50 ❌ No There's a calculation between two columns: it isn't a range over either of them
f) ORDER BY price DESC LIMIT 5 ✅ Yes The last 5 entries of the index are read in reverse order and it stops, with nothing sorted

Solution 2

1. Just one: order_lines_pkey, a unique B-tree index on id, created by the PRIMARY KEY.

2. It has two foreign keys, order_id (→ orders, ON DELETE CASCADE) and product_id (→ products, ON DELETE RESTRICT), and neither of them is indexed. It's the highest-volume table in the schema and the one that appears in practically every JOIN in the course: it's the first index candidate in all of GreenStore.

3. To apply the CASCADE, the engine has to find every row with order_id = 5. With no index on order_id, the only way is a full sequential scan of order_lines: read the 47 rows, keep the 3 and delete them. With 20 million lines, that same DELETE of one row in orders would force 20 million rows to be read —and as many again in returns, which also references orders with CASCADE. The slow query wouldn't be the one you wrote, but the integrity check it triggers: that's why this case is so hard to diagnose without EXPLAIN (08-05).

Solution 3

1. Two arguments:

  • Every index makes all the table's writes more expensive. With six indexes, an INSERT into orders goes from updating one structure (the PK) to updating seven. A Christmas peak in orders turns into a write problem that didn't exist before.
  • Several of those indexes would never be used. An index is only useful if the planner chooses it, and for that the filter has to be selective. status has 5 values across 20 orders and payment_method has 4: filtering by one of them returns a huge fraction of the table, and reading it all sequentially is cheaper than going to the index and back to the table row by row.

2. How they split:

Column Candidate? Reason
customer_id Yes An unindexed FK, and "this customer's orders" is the application's most frequent query
order_date Yes Every report filters by date ranges, and ranges are the B-tree's strong point
employee_id ⚠️ Maybe An unindexed FK, but 10 of 20 orders have NULL and only 3 employees appear: not very selective. A partial index would be a better idea (08-02)
status ❌ Not on its own Low cardinality. At most, a partial index over the minority statuses: the 6 non-delivered orders
payment_method ❌ No Four values spread around; it's never a query's main filter
shipping_cost ❌ No Nobody searches for orders "by shipping amount". It's a column you display, not one you filter by

Notice the criterion peeking through: you don't index a column because it exists, but because there are real queries filtering, joining or sorting by it and returning few rows. That criterion is the thread running through the next three lessons.

Conclusion

You now have the complete mental model:

  • An index is a sorted copy of a column plus a pointer to the row, like a book's alphabetical index, except that there can be many, they have to be kept alive and the planner uses them, not you.
  • With no index there's only the Seq Scan, whose cost grows linearly. A B-tree grows logarithmically: with a fanout of around 250 entries per page, 15 million rows fit in 3 levels, and the upper levels live in cache.
  • The index stores the key and the ctid, so normally a second access to the table is needed: that separates the Index Scan from the Index Only Scan, and from there the concept of a covering index is born.
  • A B-tree is good for equalities, ranges, IN, LIKE 'abc%' prefixes, ORDER BY, MIN and MAX; it's no good for LIKE '%abc', for functions or calculations on the filtered column, or for <>. The column has to appear bare in the condition.
  • GreenStore already has eleven indexes nobody created —nine from the PKs and two from the UNIQUE constraints— and it's missing the ones for its eleven foreign keys, because PostgreSQL doesn't index them automatically (MySQL/InnoDB does). That penalises the JOINs and, very particularly, the cascading deletes.
  • An index is paid for in space and in slower writes, so it's a bet you have to win.

In lesson 08-02, Creating and Managing Indexes, you move into action: the full syntax of CREATE INDEX, the CONCURRENTLY variant that doesn't lock the table, composite indexes and the leftmost-prefix rule that decides what order to put the columns in, partial indexes that only index the rows you care about, indexes on expressions that close the LOWER(email) problem, and how to list them, measure their size and spot the ones nobody uses. Starting, of course, with the eleven foreign keys you've just discovered uncovered.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved