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
- The book-index analogy, and where it breaks down
- How the engine reads a table with no index
- The B-tree structure
- What an index really stores: the key and the pointer
Index Scan,Index Only Scanand covering indexes- What a B-tree speeds up and what it doesn't
- The indexes GreenStore already has
- The hole: PostgreSQL doesn't index foreign keys
- The price of an index
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- 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.
| 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 Scanisn'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.
- 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.
- What an index really stores: the key and the pointer
A leaf entry doesn't contain the row. It contains two things:
- The key's value (
12.50). - A physical pointer to the row, which in PostgreSQL is called the
ctidand is a pair(block, position within the block).
The ctid is a system column you can query:
| 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
ctidisn'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 whatidis 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.
Index Scan, Index Only Scan and covering indexes
Index Scan, Index Only Scan and covering indexesTwo 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 Scandoesn'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 asHeap Fetches: N. If that number is high, the table needs maintenance (VACUUM), and that's covered in 08-05.
- 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) = 2025is identical toorder_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 thepg_trgmextension with a GIN index for theLIKE '%organic%'that 04-01 left pending (08-03).
- 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.
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.
- 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 KEYand forUNIQUE, 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 orders —order_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.
- 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 Scanand 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 Scanagainst theIndex Only Scanand 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 onemail. 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
ctidwith an identifier. It changes with everyUPDATE. 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 tablebefore 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 5Exercise 2
Without running anything, answer:
- How many indexes does the
order_linestable have today and on which columns? - Which of its columns are foreign keys and which of those are indexed?
DELETE FROM orders WHERE id = 5cascaded and deleted three lines in lesson 01-06. Describe what the engine has to do inorder_linesto 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."
- Give two technical arguments against, using what you saw in sections 6 and 9.
- 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
INSERTintoordersgoes 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.
statushas 5 values across 20 orders andpayment_methodhas 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 theIndex Scanfrom theIndex 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,MINandMAX; it's no good forLIKE '%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
UNIQUEconstraints— 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
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
