diff --git a/README.md b/README.md index 92f5604..3ad1b1a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ go install github.com/pgrundev/pgbook@latest | 01 | Tables and data types | _in progress_ | | 02 | SELECT, INSERT, UPDATE, DELETE | _in progress_ | | 03 | Joins | _in progress_ | -| 04 | **Index basics** — why some queries are instant | ✅ `pgbook read indexes` | +| 04 | **Index basics** — why some queries are instant (hands-on tutorial, five steps) | ✅ `pgbook read indexes` | | 05 | **Transactions** — grouping statements safely | _in progress_ | | 06 | Reading EXPLAIN | _in progress_ | diff --git a/internal/render/banner.go b/internal/render/banner.go new file mode 100644 index 0000000..bcd6ba8 --- /dev/null +++ b/internal/render/banner.go @@ -0,0 +1,33 @@ +package render + +import "strings" + +// logo is the pgbook wordmark. Kept under 80 columns so it never wraps +// in a default terminal. +var logo = []string{ + ` _ _ `, + ` _ __ __ _| |__ ___ ___ | | __`, + `| '_ \ / _` + "`" + ` | '_ \ / _ \ / _ \| |/ /`, + `| |_) | (_| | |_) | (_) | (_) | < `, + `| .__/ \__, |_.__/ \___/ \___/|_|\_\`, + `|_| |___/ `, +} + +// Banner returns the greeting logo printed by a bare `pgbook`. +func Banner(color bool) string { + var b strings.Builder + b.WriteString("\n") + for _, line := range logo { + line = strings.TrimRight(line, " ") + if color { + line = ansiBold + ansiCyan + line + ansiReset + } + b.WriteString(" " + line + "\n") + } + tagline := "the Postgres Book in your terminal · pgbook.dev" + if color { + tagline = ansiDim + tagline + ansiReset + } + b.WriteString("\n " + tagline + "\n") + return b.String() +} diff --git a/internal/render/render.go b/internal/render/render.go index 2c57c0b..a204f7b 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -1,7 +1,12 @@ // Package render turns lesson markdown into terminal text. package render -import "strings" +import ( + "fmt" + "regexp" + "strconv" + "strings" +) // Options controls rendering. type Options struct { @@ -9,13 +14,23 @@ type Options struct { } const ( - ansiReset = "\x1b[0m" - ansiBold = "\x1b[1m" - ansiDim = "\x1b[2m" - ansiCyan = "\x1b[36m" - ansiYellow = "\x1b[33m" + ansiReset = "\x1b[0m" + ansiBold = "\x1b[1m" + ansiDim = "\x1b[2m" + ansiInverse = "\x1b[7m" + ansiCyan = "\x1b[36m" + ansiMagenta = "\x1b[35m" + ansiYellow = "\x1b[33m" + ansiGreen = "\x1b[32m" ) +// stepHeading matches tutorial step headings such as +// "## Step 2: Watch a query crawl". Steps are numbered in the source so +// the website and PDF read naturally; the terminal draws a tracker. +var stepHeading = regexp.MustCompile(`^## Step (\d+)\s*[:.—–-]\s*(.+?)\s*$`) + +const stepRule = "────────────────────────────────────────────────────────" + // Render converts markdown to terminal output. func Render(md string, opts Options) string { var b strings.Builder @@ -26,19 +41,34 @@ func Render(md string, opts Options) string { return code + s + ansiReset } + lines := strings.Split(md, "\n") + totalSteps := countSteps(lines) + inCode := false - for _, line := range strings.Split(md, "\n") { + codeLang := "" + for _, line := range lines { switch { case strings.HasPrefix(strings.TrimSpace(line), "```"): inCode = !inCode + codeLang = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "```"))) + case inCode && opts.Color && isSQL(codeLang): + b.WriteString(" " + highlightSQL(line) + "\n") case inCode: b.WriteString(" " + style(ansiCyan, line) + "\n") case strings.HasPrefix(line, "### "): b.WriteString(style(ansiBold, strings.ToUpper(strings.TrimPrefix(line, "### "))) + "\n") + case stepHeading.MatchString(line): + m := stepHeading.FindStringSubmatch(line) + n, _ := strconv.Atoi(m[1]) + b.WriteString(stepHeader(n, totalSteps, m[2], opts)) case strings.HasPrefix(line, "## "): b.WriteString(style(ansiBold, strings.ToUpper(strings.TrimPrefix(line, "## "))) + "\n") case strings.HasPrefix(line, "# "): b.WriteString(style(ansiBold, strings.ToUpper(strings.TrimPrefix(line, "# "))) + "\n") + case strings.HasPrefix(line, "- [ ] "): + b.WriteString(" " + style(ansiYellow, "☐") + " " + inline(line[6:], opts) + "\n") + case strings.HasPrefix(line, "- [x] ") || strings.HasPrefix(line, "- [X] "): + b.WriteString(" " + style(ansiGreen, "☑") + " " + inline(line[6:], opts) + "\n") case strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* "): b.WriteString(" • " + inline(line[2:], opts) + "\n") case strings.HasPrefix(line, "> "): @@ -52,22 +82,85 @@ func Render(md string, opts Options) string { return b.String() } -// inline strips light markdown emphasis; with color, `code` spans dim. +// isSQL reports whether a code fence language should get SQL colors. +func isSQL(lang string) bool { + return lang == "sql" || lang == "psql" || lang == "postgresql" || lang == "plpgsql" +} + +// countSteps returns the number of step headings outside code blocks. +func countSteps(lines []string) int { + n := 0 + inCode := false + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "```") { + inCode = !inCode + continue + } + if !inCode && stepHeading.MatchString(line) { + n++ + } + } + return n +} + +// stepHeader draws a boxed step title with a progress tracker, e.g. +// +// ──────────────────────────────────────── +// 1 ─ [2] ─ 3 ─ 4 ─ 5 STEP 2 OF 5 +// WATCH A QUERY CRAWL +// ──────────────────────────────────────── +func stepHeader(n, total int, title string, opts Options) string { + style := func(code, s string) string { + if !opts.Color { + return s + } + return code + s + ansiReset + } + var marks []string + for i := 1; i <= total; i++ { + switch { + case i == n: + marks = append(marks, style(ansiBold+ansiInverse, "["+strconv.Itoa(i)+"]")) + case i < n: + marks = append(marks, style(ansiDim, strconv.Itoa(i))) + default: + marks = append(marks, strconv.Itoa(i)) + } + } + tracker := strings.Join(marks, style(ansiDim, " ─ ")) + + var b strings.Builder + b.WriteString("\n" + style(ansiDim, stepRule) + "\n") + b.WriteString(" " + tracker + " " + style(ansiBold, fmt.Sprintf("STEP %d OF %d", n, total)) + "\n") + b.WriteString(" " + style(ansiBold, strings.ToUpper(inline(title, Options{}))) + "\n") + b.WriteString(style(ansiDim, stepRule) + "\n") + return b.String() +} + +// inline strips light markdown emphasis; with color, `code` spans dim +// and **bold** spans bold. func inline(s string, opts Options) string { - if strings.Count(s, "`")%2 == 0 && strings.Contains(s, "`") { - parts := strings.Split(s, "`") - var out strings.Builder - for i, p := range parts { - if i%2 == 1 && opts.Color { - out.WriteString(ansiDim + p + ansiReset) - } else { - out.WriteString(p) - } + s = spans(s, "`", ansiDim, opts.Color) + s = spans(s, "**", ansiBold, opts.Color) + return strings.ReplaceAll(s, "**", "") // stray, unpaired markers +} + +// spans removes a paired emphasis marker, wrapping the enclosed text in +// code when color is on. Unbalanced markers are left untouched. +func spans(s, marker, code string, color bool) string { + if !strings.Contains(s, marker) || strings.Count(s, marker)%2 != 0 { + return s + } + parts := strings.Split(s, marker) + var out strings.Builder + for i, p := range parts { + if i%2 == 1 && color { + out.WriteString(code + p + ansiReset) + } else { + out.WriteString(p) } - s = out.String() } - s = strings.ReplaceAll(s, "**", "") - return s + return out.String() } // ShouldColor reports whether output should use ANSI colors, given diff --git a/internal/render/render_test.go b/internal/render/render_test.go index f3c42f9..9aa54b4 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -59,3 +59,82 @@ func TestShouldColor(t *testing.T) { t.Error("TTY without NO_COLOR: ShouldColor = false, want true") } } + +const tutorial = "## Why some queries are instant\n\nIntro.\n\n## Step 1: Get a Postgres to play with\n\nText.\n\n### Your turn\n\n- [ ] Start Postgres.\n- [x] Open psql.\n\n## Step 2: Watch a query crawl\n\nMore.\n\n```sql\n## Step 9: not a real step, inside a code block\n```\n\n## Step 3: Add an index\n\n## Step 4: When the index does not help\n\n## Step 5: Indexes are not free\n\n## What you learned\n" + +func TestRenderStepHeadersShowProgress(t *testing.T) { + out := Render(tutorial, Options{Color: false}) + for _, want := range []string{ + "STEP 1 OF 5", + "GET A POSTGRES TO PLAY WITH", + "[1] ─ 2 ─ 3 ─ 4 ─ 5", + "STEP 2 OF 5", + "1 ─ [2] ─ 3 ─ 4 ─ 5", + "STEP 5 OF 5", + "1 ─ 2 ─ 3 ─ 4 ─ [5]", + "WHAT YOU LEARNED", + } { + if !strings.Contains(out, want) { + t.Errorf("step render missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "STEP 9") || strings.Contains(out, "OF 6") { + t.Errorf("step heading inside a code block was counted as a step:\n%s", out) + } + if strings.Contains(out, "## Step 1") || strings.Contains(out, "Step 1:") { + t.Errorf("raw step heading leaked into output:\n%s", out) + } +} + +func TestRenderStepHeadersColorResets(t *testing.T) { + out := Render(tutorial, Options{Color: true}) + if !strings.Contains(out, "STEP 2 OF 5") { + t.Fatalf("color step render missing header:\n%s", out) + } + if strings.Count(out, "\x1b[0m") == 0 { + t.Errorf("color step render never resets attributes") + } +} + +func TestRenderChecklist(t *testing.T) { + out := Render(tutorial, Options{Color: false}) + if !strings.Contains(out, "☐ Start Postgres.") { + t.Errorf("unchecked item not rendered as ☐:\n%s", out) + } + if !strings.Contains(out, "☑ Open psql.") { + t.Errorf("checked item not rendered as ☑:\n%s", out) + } + if strings.Contains(out, "[ ]") || strings.Contains(out, "[x]") { + t.Errorf("raw checkbox markers leaked into output:\n%s", out) + } +} + +func TestRenderBoldSpans(t *testing.T) { + plain := Render("**1. A function** around the column.\n", Options{Color: false}) + if !strings.Contains(plain, "1. A function around the column.") || strings.Contains(plain, "**") { + t.Errorf("plain bold span wrong: %q", plain) + } + color := Render("**1. A function** around the column.\n", Options{Color: true}) + if !strings.Contains(color, "\x1b[1m1. A function\x1b[0m") { + t.Errorf("color bold span wrong: %q", color) + } +} + +func TestBanner(t *testing.T) { + plain := Banner(false) + if strings.Contains(plain, "\x1b[") { + t.Errorf("plain banner contains ANSI escapes:\n%s", plain) + } + if !strings.Contains(plain, "pgbook.dev") { + t.Errorf("banner should name the site:\n%s", plain) + } + for _, line := range strings.Split(plain, "\n") { + if n := len([]rune(line)); n > 80 { + t.Errorf("banner line is %d columns, want <= 80: %q", n, line) + } + } + color := Banner(true) + if !strings.Contains(color, "\x1b[") || !strings.Contains(color, "\x1b[0m") { + t.Errorf("color banner has no ANSI escapes or never resets:\n%s", color) + } +} diff --git a/internal/render/sql.go b/internal/render/sql.go new file mode 100644 index 0000000..f553b10 --- /dev/null +++ b/internal/render/sql.go @@ -0,0 +1,98 @@ +package render + +import "strings" + +// sqlKeywords are highlighted in SQL code blocks. Types are included so +// a CREATE TABLE reads as one colored shape. +var sqlKeywords = map[string]bool{} + +func init() { + for _, w := range strings.Fields(` + SELECT FROM WHERE AND OR NOT NULL IS IN LIKE ILIKE BETWEEN EXISTS AS ON + JOIN LEFT RIGHT INNER OUTER FULL CROSS USING GROUP BY ORDER HAVING LIMIT + OFFSET DESC ASC DISTINCT UNION ALL ANY SOME CASE WHEN THEN ELSE END + INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE INDEX UNIQUE DROP ALTER + ADD COLUMN CONSTRAINT PRIMARY KEY FOREIGN REFERENCES DEFAULT CHECK + GENERATED ALWAYS IDENTITY IF EXPLAIN ANALYZE VERBOSE VACUUM FULL + BEGIN COMMIT ROLLBACK TRANSACTION ISOLATION LEVEL READ COMMITTED + REPEATABLE SERIALIZABLE FOR SHARE NOWAIT SKIP LOCKED RETURNING WITH + RECURSIVE OVER PARTITION WINDOW ROWS RANGE PRECEDING FOLLOWING CURRENT + ROW CAST TRUE FALSE GRANT REVOKE POLICY ENABLE SECURITY TO CONCURRENTLY + CASCADE ARRAY INTERVAL LATERAL NULLS FIRST LAST ONLY VIEW MATERIALIZED + FUNCTION RETURNS LANGUAGE TRIGGER BEFORE AFTER EACH EXECUTE PROCEDURE + BIGINT INT INTEGER SMALLINT SERIAL BIGSERIAL TEXT VARCHAR CHAR BOOLEAN + BOOL NUMERIC DECIMAL REAL FLOAT DOUBLE PRECISION TIMESTAMP TIMESTAMPTZ + DATE TIME JSON JSONB UUID BYTEA`) { + sqlKeywords[w] = true + } +} + +// highlightSQL colors one line of SQL: keywords bold magenta, strings +// green, numbers yellow, comments dim, function calls cyan, psql +// meta-commands yellow. Identifiers stay plain so they stand apart. +func highlightSQL(line string) string { + if strings.HasPrefix(strings.TrimLeft(line, " "), `\`) { + return ansiYellow + line + ansiReset + } + var b strings.Builder + n := len(line) + for i := 0; i < n; { + c := line[i] + switch { + case c == '-' && i+1 < n && line[i+1] == '-': + b.WriteString(ansiDim + line[i:] + ansiReset) + i = n + case c == '\'': + j := i + 1 + for j < n { + if line[j] == '\'' { + if j+1 < n && line[j+1] == '\'' { + j += 2 // escaped quote inside the string + continue + } + break + } + j++ + } + if j < n { + j++ // closing quote + } + b.WriteString(ansiGreen + line[i:j] + ansiReset) + i = j + case isIdentStart(c): + j := i + for j < n && isIdentChar(line[j]) { + j++ + } + word := line[i:j] + switch { + case sqlKeywords[strings.ToUpper(word)]: + b.WriteString(ansiBold + ansiMagenta + word + ansiReset) + case j < n && line[j] == '(': + b.WriteString(ansiCyan + word + ansiReset) + default: + b.WriteString(word) + } + i = j + case c >= '0' && c <= '9': + j := i + for j < n && (line[j] >= '0' && line[j] <= '9' || line[j] == '.') { + j++ + } + b.WriteString(ansiYellow + line[i:j] + ansiReset) + i = j + default: + b.WriteByte(c) + i++ + } + } + return b.String() +} + +func isIdentStart(c byte) bool { + return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' +} + +func isIdentChar(c byte) bool { + return isIdentStart(c) || c >= '0' && c <= '9' +} diff --git a/internal/render/sql_test.go b/internal/render/sql_test.go new file mode 100644 index 0000000..d2129ef --- /dev/null +++ b/internal/render/sql_test.go @@ -0,0 +1,49 @@ +package render + +import ( + "strings" + "testing" +) + +const sqlLesson = "```sql\n-- find one user\nSELECT count(*) FROM users WHERE email = 'a''b' AND id > 42;\n\\timing on\n```\n\n```bash\ndocker run postgres:17\n```\n" + +func TestHighlightSQLColorsTokens(t *testing.T) { + out := Render(sqlLesson, Options{Color: true}) + for _, want := range []string{ + ansiBold + ansiMagenta + "SELECT" + ansiReset, // keyword + ansiBold + ansiMagenta + "FROM" + ansiReset, + ansiCyan + "count" + ansiReset + "(", // function call + ansiGreen + "'a''b'" + ansiReset, // string with escaped quote + ansiYellow + "42" + ansiReset, // number + ansiDim + "-- find one user" + ansiReset, // comment + ansiYellow + "\\timing on" + ansiReset, // psql meta-command + " " + ansiCyan + "docker run postgres:17", // non-SQL blocks stay cyan + } { + if !strings.Contains(out, want) { + t.Errorf("sql render missing %q:\n%q", want, out) + } + } + if strings.Contains(out, ansiCyan+"users") || strings.Contains(out, ansiBold+ansiMagenta+"users") { + t.Errorf("identifier should be plain:\n%q", out) + } +} + +func TestHighlightSQLPlainIsUntouched(t *testing.T) { + out := Render(sqlLesson, Options{Color: false}) + if strings.Contains(out, "\x1b[") { + t.Errorf("plain render contains ANSI escapes:\n%s", out) + } + if !strings.Contains(out, " SELECT count(*) FROM users WHERE email = 'a''b' AND id > 42;") { + t.Errorf("plain sql line altered:\n%s", out) + } +} + +func TestHighlightSQLKeywordsAreCaseInsensitive(t *testing.T) { + out := highlightSQL("select 1") + if !strings.HasPrefix(out, ansiBold+ansiMagenta+"select"+ansiReset) { + t.Errorf("lowercase keyword not highlighted: %q", out) + } + if strings.Contains(highlightSQL("selected"), ansiMagenta) { + t.Errorf("identifier with keyword prefix was highlighted") + } +} diff --git a/main.go b/main.go index 0478c7b..5d62669 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,17 @@ Topics are fetched from pgbook.dev and cached for offline reading. pgbook never connects to a database and never executes SQL. ` +// greeting follows the logo when pgbook is run with no arguments. +const greeting = ` + Start here pgbook read indexes Chapter 1 · hands-on · 15 min + All topics pgbook list + Continue pgbook next + Search pgbook search + Download pgbook pdf + + pgbook %s · pgbook --help for every command and flag +` + func run(args []string, stdout, stderr io.Writer) int { baseURL := os.Getenv("PGBOOK_BASE_URL") if baseURL == "" { @@ -48,7 +59,8 @@ func run(args []string, stdout, stderr io.Writer) int { } if len(args) == 0 { - fmt.Fprint(stdout, usage) + fmt.Fprint(stdout, render.Banner(app.Color)) + fmt.Fprintf(stdout, greeting, version) return 0 } cmd, rest := args[0], args[1:] diff --git a/main_test.go b/main_test.go index a0a14f6..33a0c42 100644 --- a/main_test.go +++ b/main_test.go @@ -76,3 +76,23 @@ func TestAPIErrorIsReportedNotPanic(t *testing.T) { t.Error("no error message on stderr") } } + +func TestNoArgsShowsGreeting(t *testing.T) { + code, out, _ := runCLI(t) + if code != 0 { + t.Fatalf("no args: exit %d, want 0", code) + } + for _, want := range []string{"pgbook.dev", "pgbook read indexes", "pgbook list", "pgbook --help", version} { + if !strings.Contains(out, want) { + t.Errorf("greeting missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "\x1b[") { + t.Errorf("greeting to a non-terminal contains ANSI escapes:\n%s", out) + } + for _, line := range strings.Split(out, "\n") { + if n := len([]rune(line)); n > 80 { + t.Errorf("greeting line is %d columns, want <= 80: %q", n, line) + } + } +} diff --git a/site/api/topics.json b/site/api/topics.json index 6b7b9aa..c06ce52 100644 --- a/site/api/topics.json +++ b/site/api/topics.json @@ -6,7 +6,7 @@ "title": "Indexes", "description": "Why some queries are instant", "level": "beginner", - "reading_minutes": 8, + "reading_minutes": 15, "order": 1, "aliases": [ "index", @@ -16,7 +16,8 @@ "tags": [ "performance", "btree", - "explain" + "explain", + "tutorial" ] }, { diff --git a/site/api/topics/indexes b/site/api/topics/indexes index 6c112d0..6193ce1 100644 --- a/site/api/topics/indexes +++ b/site/api/topics/indexes @@ -3,7 +3,7 @@ "title": "Indexes", "description": "Why some queries are instant", "level": "beginner", - "reading_minutes": 8, + "reading_minutes": 15, "order": 1, "aliases": [ "index", @@ -13,7 +13,8 @@ "tags": [ "performance", "btree", - "explain" + "explain", + "tutorial" ], - "content": "## Why some queries are instant\n\nWithout an index, Postgres answers a `WHERE` clause by reading every row\nin the table — a sequential scan. An index is a separate structure that\nmaps column values to row locations, so Postgres can jump straight to\nthe matching rows.\n\n```sql\nCREATE TABLE users (\n id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n email text NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now()\n);\n\nINSERT INTO users (email)\nSELECT 'user' || n || '@example.com' FROM generate_series(1, 100000) n;\n```\n\n## Seeing the difference\n\n`EXPLAIN ANALYZE` shows how Postgres actually ran a query:\n\n```sql\nEXPLAIN ANALYZE SELECT * FROM users WHERE email = 'user4242@example.com';\n```\n\nYou will see `Seq Scan on users` — every row was checked. Now add an\nindex and run it again:\n\n```sql\nCREATE INDEX users_email_idx ON users (email);\n\nEXPLAIN ANALYZE SELECT * FROM users WHERE email = 'user4242@example.com';\n```\n\nThe plan switches to `Index Scan using users_email_idx`, and the\nexecution time drops from milliseconds to microseconds.\n\n## What a B-tree can do\n\nThe default index type is a B-tree. It keeps values sorted, so it\nserves more than equality:\n\n- `WHERE email = '...'` — exact matches\n- `WHERE created_at \u003e now() - interval '1 day'` — ranges\n- `ORDER BY created_at DESC LIMIT 10` — ordering, without a sort\n- `WHERE email LIKE 'user42%'` — left-anchored prefixes (with the right\n operator class or collation)\n\nIt cannot help with `LIKE '%@gmail.com'` — the sorted order is useless\nwhen the prefix is unknown.\n\n## Multi-column indexes\n\nColumn order matters. An index on `(a, b)` is sorted by `a` first, then\n`b` — like a phone book sorted by last name, then first name.\n\n```sql\nCREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at);\n```\n\nThis serves `WHERE customer_id = 7` and\n`WHERE customer_id = 7 AND created_at \u003e '2026-01-01'`,\nbut not `WHERE created_at \u003e '2026-01-01'` alone.\n\n## Indexes are not free\n\nEvery index slows down writes: each `INSERT`, `UPDATE`, and `DELETE`\nmust maintain it, and it takes disk space. Find indexes that are never\nread:\n\n```sql\nSELECT indexrelname, idx_scan\nFROM pg_stat_user_indexes\nORDER BY idx_scan ASC;\n```\n\n\u003e Note: an `idx_scan` of 0 on a busy table usually means the index can\n\u003e be dropped — but check replicas and rare reports first.\n" + "content": "## Why some queries are instant\n\nImagine this book had no index at the back. To find every mention of\n\"vacuum\" you would flip through all 1,000 pages. With the index you jump\nstraight to page 412.\n\nA database index is exactly that. Without one, Postgres answers\n`WHERE email = '...'` by reading every row in the table, top to bottom.\nThat is called a sequential scan. With an index, Postgres jumps straight\nto the matching rows.\n\nThis chapter is hands-on. You will build a table with a million rows,\nwatch a query crawl, add an index, and watch the same query become\ninstant. Then you will learn when an index does not help, and what it\ncosts.\n\n### How to use this chapter\n\n- Keep this tab open for reading.\n- Open a second terminal tab for Postgres. Every `sql` block below is\n meant to be pasted there.\n- Each step ends with **Your turn**: a short checklist. Do it before\n moving on.\n\nNothing here touches real data. You will create one throwaway table\nand drop it at the end. Plan on 20 to 30 minutes.\n\n## Step 1: Get a Postgres to play with\n\nIf you already have a Postgres you can log into with `psql`, use it and\nskip to Your turn.\n\nOtherwise, start one in Docker. In your second tab:\n\n```bash\ndocker run --rm -d --name pgbook-lab \\\n -e POSTGRES_PASSWORD=pgbook \\\n -p 5432:5432 postgres:17\n```\n\nThis starts Postgres 17 in the background. `--rm` means the container,\nand everything in it, disappears when you stop it. Give it a few\nseconds, then open a SQL prompt inside it:\n\n```bash\ndocker exec -it pgbook-lab psql -U postgres\n```\n\nYou should see:\n\n```text\npsql (17.x)\nType \"help\" for help.\n\npostgres=#\n```\n\nThat `postgres=#` prompt is where you type SQL for the rest of this\nchapter. Turn on timing, so psql prints how long every query took:\n\n```sql\n\\timing on\n```\n\n### Your turn\n\n- [ ] Start Postgres (Docker, or one you already have).\n- [ ] Open `psql` and see the `postgres=#` prompt.\n- [ ] Run `\\timing on`.\n- [ ] Run `SELECT version();` to confirm the connection works.\n\n\u003e Stuck? `docker logs pgbook-lab` shows why the container did not\n\u003e start. The usual cause is another Postgres already using port 5432.\n\n## Step 2: Watch a query crawl\n\nCreate a `users` table and fill it with one million rows.\n`generate_series` is Postgres's built-in row factory: it returns the\nnumbers 1 to 1,000,000, and each one becomes a user. Paste this in your\npsql tab:\n\n```sql\nCREATE TABLE users (\n id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n email text NOT NULL,\n country text NOT NULL,\n created_at timestamptz NOT NULL\n);\n\nINSERT INTO users (email, country, created_at)\nSELECT 'user' || n || '@example.com',\n (ARRAY['US', 'DE', 'BR', 'IN', 'JP'])[1 + n % 5],\n now() - n * interval '1 second'\nFROM generate_series(1, 1000000) AS n;\n\nANALYZE users;\n```\n\nThe insert takes a few seconds. `ANALYZE` makes Postgres look at the\ndata and record statistics about it, such as how many rows there are\nand how many distinct emails. Postgres does this on its own in the\nbackground; running it by hand just means you do not have to wait.\n\nNow find one user by email:\n\n```sql\nSELECT * FROM users WHERE email = 'user424242@example.com';\n```\n\nOne row comes back, and `\\timing` reports tens of milliseconds. Run it\nagain. The first run can be slower while the table is read from disk,\nbut after that it is the same every time.\n\nWhy so slow for one row? Ask Postgres how it ran the query:\n\n```sql\nEXPLAIN ANALYZE\nSELECT * FROM users WHERE email = 'user424242@example.com';\n```\n\n`EXPLAIN ANALYZE` runs the query and prints the plan Postgres used.\nYours will have different numbers, but look for these lines:\n\n```text\nGather (actual time=9.440..22.478 rows=1 loops=1)\n Workers Planned: 2\n Workers Launched: 2\n -\u003e Parallel Seq Scan on users (actual time=14.0..17.5 rows=0 loops=3)\n Filter: (email = 'user424242@example.com'::text)\n Rows Removed by Filter: 333333\nExecution Time: 22.494 ms\n```\n\n- `Seq Scan`: Postgres read the whole table, top to bottom. `Parallel`\n means it split the work across several processes, which is why\n `loops=3`: the main process plus two helpers each read a third.\n- `Rows Removed by Filter: 333333`: each of the three looked at 333,333\n rows and threw away all but, at most, one. A million rows examined to\n find one.\n\nThat is the \"flipping through every page\" problem. On a small machine\nyou may see a plain `Seq Scan` with `Rows Removed by Filter: 999999`\ninstead: the same work, done by one process.\n\n### Your turn\n\n- [ ] Create and fill the `users` table, then `ANALYZE` it.\n- [ ] Run the `SELECT` twice and note the time.\n- [ ] Run `EXPLAIN ANALYZE`. Find `Seq Scan` and `Rows Removed by Filter`.\n- [ ] Write the `Execution Time` down. You will compare it in Step 3.\n\n## Step 3: Add an index and run it again\n\nCreate an index on the `email` column:\n\n```sql\nCREATE INDEX users_email_idx ON users (email);\n```\n\nThis takes a second or two: Postgres reads every email once and stores\nthem, sorted, in a separate structure on disk. Now run the exact same\nquery as before:\n\n```sql\nSELECT * FROM users WHERE email = 'user424242@example.com';\n```\n\nWell under a millisecond. Dozens of times faster, and the query text\ndid not change at all. Check the plan:\n\n```sql\nEXPLAIN ANALYZE\nSELECT * FROM users WHERE email = 'user424242@example.com';\n```\n\n```text\nIndex Scan using users_email_idx on users (actual rows=1 loops=1)\n Index Cond: (email = 'user424242@example.com'::text)\nExecution Time: 0.014 ms\n```\n\n`Index Scan` replaced `Seq Scan`, the helpers are gone, and so is\n`Rows Removed by Filter`. Postgres jumped straight to the row.\n\n### What the index actually is\n\nThe default index type is a B-tree: every value, kept in sorted order,\nwith a small tree on top so any value can be found in a handful of\nhops. Finding one email among a million sorted entries takes about 20\ncomparisons instead of a million.\n\nBecause it is sorted, a B-tree helps with more than exact matches:\n\n- `WHERE email = 'x'`: equality\n- `WHERE created_at \u003e now() - interval '1 hour'`: ranges\n- `ORDER BY created_at DESC LIMIT 10`: ordering, without a sort\n- `WHERE email LIKE 'user4242%'`: prefixes (with a caveat, see Step 4)\n\n### Your turn\n\n- [ ] Create `users_email_idx`.\n- [ ] Re-run the `SELECT`. Compare the time with Step 2.\n- [ ] Re-run `EXPLAIN ANALYZE` and confirm `Index Scan using users_email_idx`.\n- [ ] Try a range query:\n\n```sql\nEXPLAIN ANALYZE\nSELECT count(*) FROM users WHERE created_at \u003e now() - interval '1 hour';\n```\n\nIt is a `Seq Scan`. Why? There is no index on `created_at` yet. Create\none, `users_created_at_idx`, and run it again. The plan should now name\nyour new index.\n\n## Step 4: When the index does not help\n\nHaving an index does not guarantee it will make a query fast. Three\ncases catch people every week. Before you run each query, guess what\nthe plan will be.\n\n**1. A function wrapped around the column.**\n\n```sql\nEXPLAIN ANALYZE\nSELECT * FROM users WHERE lower(email) = 'user424242@example.com';\n```\n\n`Seq Scan`, and slower than Step 2. The index stores `email`, not\n`lower(email)`. To know whether a row matches, Postgres has to compute\n`lower()` on every row, which is the same work as having no index,\nplus a million function calls. The fix is either to stop wrapping the\ncolumn, or to index the expression itself:\n\n```sql\nCREATE INDEX users_lower_email_idx ON users (lower(email));\n```\n\nRun the `EXPLAIN ANALYZE` again. The plan now names\n`users_lower_email_idx`, and the time is back under a millisecond.\n(You may see `Bitmap Index Scan` rather than `Index Scan`. It is a\ncousin: both jump through the index instead of reading the table.)\n\n**2. A wildcard at the start.**\n\n```sql\nEXPLAIN ANALYZE\nSELECT count(*) FROM users WHERE email LIKE '%42@example.com';\n```\n\n`Seq Scan`. A sorted list is useless when you do not know how the value\nstarts, like finding every surname ending in \"-son\" in a phone book.\n(The prefix form `LIKE 'user42%'` is only served by a B-tree when the\nindex uses `text_pattern_ops` or the database collation is `C`. When in\ndoubt, `EXPLAIN` it.)\n\n**3. The condition matches a big slice of the table.**\n\n```sql\nCREATE INDEX users_country_idx ON users (country);\n\nEXPLAIN ANALYZE\nSELECT count(*), min(created_at) FROM users WHERE country = 'DE';\n```\n\nEvery fifth user is in `DE`, so this matches 200,000 rows. Depending on\nyour machine you will see either a `Seq Scan`, or a `Bitmap Heap Scan`\nthat mentions `users_country_idx`. If it is the latter, look at\n`Heap Blocks`: Postgres used the index to make a list of matching rows,\nthen still had to visit nearly every page of the table, because `DE`\nusers are on every page.\n\nEither way, compare `Execution Time` with the seq scan in Step 2. About\nthe same. The index exists, may even be used, and buys nothing.\n\nIndexes shine when the condition is selective: it picks out a small\nfraction of the rows, so that most of the table can be skipped.\n\n### Your turn\n\n- [ ] Run all three queries. Confirm none of them is faster than Step 2.\n- [ ] Create the `lower(email)` index and re-run query 1.\n- [ ] For every index you are tempted to add in production, ask: is the\n condition selective, and does the query use the column bare?\n\n## Step 5: Indexes are not free\n\nAn index is a second copy of a column, kept sorted on disk. Look at what\nyou have built so far:\n\n```sql\nSELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size\nFROM pg_class\nWHERE relname LIKE 'users%' AND relkind IN ('r', 'i')\nORDER BY pg_relation_size(oid) DESC;\n```\n\n```text\n relname | size\n-----------------------+---------\n users | 73 MB\n users_email_idx | 39 MB\n users_lower_email_idx | 39 MB\n users_created_at_idx | 21 MB\n users_pkey | 21 MB\n users_country_idx | 6800 kB\n```\n\nThe two email indexes together are bigger than the table. Each index\ntakes disk, and each one must be updated on every `INSERT`, `UPDATE`,\nand `DELETE`. Time a write with all of them in place:\n\n```sql\nINSERT INTO users (email, country, created_at)\nSELECT 'late' || n || '@example.com', 'US', now()\nFROM generate_series(1, 200000) AS n;\n```\n\nNow drop the two indexes you do not need and run the same insert again:\n\n```sql\nDROP INDEX users_country_idx;\nDROP INDEX users_lower_email_idx;\n\nINSERT INTO users (email, country, created_at)\nSELECT 'later' || n || '@example.com', 'US', now()\nFROM generate_series(1, 200000) AS n;\n```\n\nNoticeably faster. Fewer indexes, cheaper writes. On a table that takes\nthousands of writes a second, every extra index is a tax on every one\nof them.\n\nOn a real system, find the indexes nobody uses:\n\n```sql\nSELECT indexrelname, idx_scan,\n pg_size_pretty(pg_relation_size(indexrelid)) AS size\nFROM pg_stat_user_indexes\nWHERE relname = 'users'\nORDER BY idx_scan;\n```\n\n`idx_scan` counts how many times each index has been used since the\nstatistics were last reset. An index with `0` scans on a busy table is\nusually safe to drop, with two exceptions: check replicas and rare\nmonthly reports first, and never drop a primary key or unique index\nbecause of this number. Those enforce rules about your data, not just\nspeed.\n\n### Clean up\n\n```sql\nDROP TABLE users;\n\\q\n```\n\nIf you used Docker, `docker stop pgbook-lab` removes the container too.\n\n### Your turn\n\n- [ ] Compare the size of the table with the size of each index.\n- [ ] Time the insert with all your indexes, then with two fewer.\n- [ ] Run the unused-index query and read the `idx_scan` column.\n- [ ] Clean up.\n\n## What you learned\n\n- Without an index, a `WHERE` clause means reading every row: a\n `Seq Scan`.\n- `EXPLAIN ANALYZE` shows which plan Postgres chose and how long it\n took. Read it before and after every index you add.\n- A B-tree index serves equality, ranges, ordering, and prefixes.\n- An index does not help when the column is wrapped in a function, the\n pattern starts with `%`, or the condition matches a big slice of the\n table.\n- Every index costs disk and slows every write. Add the ones your\n queries need, and check `pg_stat_user_indexes` for the ones nobody\n uses.\n\nRun `pgbook next` to continue with transactions.\n" } diff --git a/topics/01-indexes.md b/topics/01-indexes.md index c5454de..3590dc8 100644 --- a/topics/01-indexes.md +++ b/topics/01-indexes.md @@ -3,88 +3,389 @@ slug: indexes title: Indexes description: Why some queries are instant level: beginner -reading_minutes: 8 +reading_minutes: 15 order: 1 aliases: index, index-basics, btree -tags: performance, btree, explain +tags: performance, btree, explain, tutorial --- ## Why some queries are instant -Without an index, Postgres answers a `WHERE` clause by reading every row -in the table — a sequential scan. An index is a separate structure that -maps column values to row locations, so Postgres can jump straight to -the matching rows. +Imagine this book had no index at the back. To find every mention of +"vacuum" you would flip through all 1,000 pages. With the index you jump +straight to page 412. + +A database index is exactly that. Without one, Postgres answers +`WHERE email = '...'` by reading every row in the table, top to bottom. +That is called a sequential scan. With an index, Postgres jumps straight +to the matching rows. + +This chapter is hands-on. You will build a table with a million rows, +watch a query crawl, add an index, and watch the same query become +instant. Then you will learn when an index does not help, and what it +costs. + +### How to use this chapter + +- Keep this tab open for reading. +- Open a second terminal tab for Postgres. Every `sql` block below is + meant to be pasted there. +- Each step ends with **Your turn**: a short checklist. Do it before + moving on. + +Nothing here touches real data. You will create one throwaway table +and drop it at the end. Plan on 20 to 30 minutes. + +## Step 1: Get a Postgres to play with + +If you already have a Postgres you can log into with `psql`, use it and +skip to Your turn. + +Otherwise, start one in Docker. In your second tab: + +```bash +docker run --rm -d --name pgbook-lab \ + -e POSTGRES_PASSWORD=pgbook \ + -p 5432:5432 postgres:17 +``` + +This starts Postgres 17 in the background. `--rm` means the container, +and everything in it, disappears when you stop it. Give it a few +seconds, then open a SQL prompt inside it: + +```bash +docker exec -it pgbook-lab psql -U postgres +``` + +You should see: + +```text +psql (17.x) +Type "help" for help. + +postgres=# +``` + +That `postgres=#` prompt is where you type SQL for the rest of this +chapter. Turn on timing, so psql prints how long every query took: + +```sql +\timing on +``` + +### Your turn + +- [ ] Start Postgres (Docker, or one you already have). +- [ ] Open `psql` and see the `postgres=#` prompt. +- [ ] Run `\timing on`. +- [ ] Run `SELECT version();` to confirm the connection works. + +> Stuck? `docker logs pgbook-lab` shows why the container did not +> start. The usual cause is another Postgres already using port 5432. + +## Step 2: Watch a query crawl + +Create a `users` table and fill it with one million rows. +`generate_series` is Postgres's built-in row factory: it returns the +numbers 1 to 1,000,000, and each one becomes a user. Paste this in your +psql tab: ```sql CREATE TABLE users ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - email text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text NOT NULL, + country text NOT NULL, + created_at timestamptz NOT NULL ); -INSERT INTO users (email) -SELECT 'user' || n || '@example.com' FROM generate_series(1, 100000) n; +INSERT INTO users (email, country, created_at) +SELECT 'user' || n || '@example.com', + (ARRAY['US', 'DE', 'BR', 'IN', 'JP'])[1 + n % 5], + now() - n * interval '1 second' +FROM generate_series(1, 1000000) AS n; + +ANALYZE users; ``` -## Seeing the difference +The insert takes a few seconds. `ANALYZE` makes Postgres look at the +data and record statistics about it, such as how many rows there are +and how many distinct emails. Postgres does this on its own in the +background; running it by hand just means you do not have to wait. -`EXPLAIN ANALYZE` shows how Postgres actually ran a query: +Now find one user by email: ```sql -EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'user4242@example.com'; +SELECT * FROM users WHERE email = 'user424242@example.com'; ``` -You will see `Seq Scan on users` — every row was checked. Now add an -index and run it again: +One row comes back, and `\timing` reports tens of milliseconds. Run it +again. The first run can be slower while the table is read from disk, +but after that it is the same every time. + +Why so slow for one row? Ask Postgres how it ran the query: + +```sql +EXPLAIN ANALYZE +SELECT * FROM users WHERE email = 'user424242@example.com'; +``` + +`EXPLAIN ANALYZE` runs the query and prints the plan Postgres used. +Yours will have different numbers, but look for these lines: + +```text +Gather (actual time=9.440..22.478 rows=1 loops=1) + Workers Planned: 2 + Workers Launched: 2 + -> Parallel Seq Scan on users (actual time=14.0..17.5 rows=0 loops=3) + Filter: (email = 'user424242@example.com'::text) + Rows Removed by Filter: 333333 +Execution Time: 22.494 ms +``` + +- `Seq Scan`: Postgres read the whole table, top to bottom. `Parallel` + means it split the work across several processes, which is why + `loops=3`: the main process plus two helpers each read a third. +- `Rows Removed by Filter: 333333`: each of the three looked at 333,333 + rows and threw away all but, at most, one. A million rows examined to + find one. + +That is the "flipping through every page" problem. On a small machine +you may see a plain `Seq Scan` with `Rows Removed by Filter: 999999` +instead: the same work, done by one process. + +### Your turn + +- [ ] Create and fill the `users` table, then `ANALYZE` it. +- [ ] Run the `SELECT` twice and note the time. +- [ ] Run `EXPLAIN ANALYZE`. Find `Seq Scan` and `Rows Removed by Filter`. +- [ ] Write the `Execution Time` down. You will compare it in Step 3. + +## Step 3: Add an index and run it again + +Create an index on the `email` column: ```sql CREATE INDEX users_email_idx ON users (email); +``` + +This takes a second or two: Postgres reads every email once and stores +them, sorted, in a separate structure on disk. Now run the exact same +query as before: + +```sql +SELECT * FROM users WHERE email = 'user424242@example.com'; +``` -EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'user4242@example.com'; +Well under a millisecond. Dozens of times faster, and the query text +did not change at all. Check the plan: + +```sql +EXPLAIN ANALYZE +SELECT * FROM users WHERE email = 'user424242@example.com'; +``` + +```text +Index Scan using users_email_idx on users (actual rows=1 loops=1) + Index Cond: (email = 'user424242@example.com'::text) +Execution Time: 0.014 ms ``` -The plan switches to `Index Scan using users_email_idx`, and the -execution time drops from milliseconds to microseconds. +`Index Scan` replaced `Seq Scan`, the helpers are gone, and so is +`Rows Removed by Filter`. Postgres jumped straight to the row. -## What a B-tree can do +### What the index actually is -The default index type is a B-tree. It keeps values sorted, so it -serves more than equality: +The default index type is a B-tree: every value, kept in sorted order, +with a small tree on top so any value can be found in a handful of +hops. Finding one email among a million sorted entries takes about 20 +comparisons instead of a million. -- `WHERE email = '...'` — exact matches -- `WHERE created_at > now() - interval '1 day'` — ranges -- `ORDER BY created_at DESC LIMIT 10` — ordering, without a sort -- `WHERE email LIKE 'user42%'` — left-anchored prefixes (with the right - operator class or collation) +Because it is sorted, a B-tree helps with more than exact matches: -It cannot help with `LIKE '%@gmail.com'` — the sorted order is useless -when the prefix is unknown. +- `WHERE email = 'x'`: equality +- `WHERE created_at > now() - interval '1 hour'`: ranges +- `ORDER BY created_at DESC LIMIT 10`: ordering, without a sort +- `WHERE email LIKE 'user4242%'`: prefixes (with a caveat, see Step 4) -## Multi-column indexes +### Your turn -Column order matters. An index on `(a, b)` is sorted by `a` first, then -`b` — like a phone book sorted by last name, then first name. +- [ ] Create `users_email_idx`. +- [ ] Re-run the `SELECT`. Compare the time with Step 2. +- [ ] Re-run `EXPLAIN ANALYZE` and confirm `Index Scan using users_email_idx`. +- [ ] Try a range query: ```sql -CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at); +EXPLAIN ANALYZE +SELECT count(*) FROM users WHERE created_at > now() - interval '1 hour'; ``` -This serves `WHERE customer_id = 7` and -`WHERE customer_id = 7 AND created_at > '2026-01-01'`, -but not `WHERE created_at > '2026-01-01'` alone. +It is a `Seq Scan`. Why? There is no index on `created_at` yet. Create +one, `users_created_at_idx`, and run it again. The plan should now name +your new index. + +## Step 4: When the index does not help -## Indexes are not free +Having an index does not guarantee it will make a query fast. Three +cases catch people every week. Before you run each query, guess what +the plan will be. -Every index slows down writes: each `INSERT`, `UPDATE`, and `DELETE` -must maintain it, and it takes disk space. Find indexes that are never -read: +**1. A function wrapped around the column.** ```sql -SELECT indexrelname, idx_scan +EXPLAIN ANALYZE +SELECT * FROM users WHERE lower(email) = 'user424242@example.com'; +``` + +`Seq Scan`, and slower than Step 2. The index stores `email`, not +`lower(email)`. To know whether a row matches, Postgres has to compute +`lower()` on every row, which is the same work as having no index, +plus a million function calls. The fix is either to stop wrapping the +column, or to index the expression itself: + +```sql +CREATE INDEX users_lower_email_idx ON users (lower(email)); +``` + +Run the `EXPLAIN ANALYZE` again. The plan now names +`users_lower_email_idx`, and the time is back under a millisecond. +(You may see `Bitmap Index Scan` rather than `Index Scan`. It is a +cousin: both jump through the index instead of reading the table.) + +**2. A wildcard at the start.** + +```sql +EXPLAIN ANALYZE +SELECT count(*) FROM users WHERE email LIKE '%42@example.com'; +``` + +`Seq Scan`. A sorted list is useless when you do not know how the value +starts, like finding every surname ending in "-son" in a phone book. +(The prefix form `LIKE 'user42%'` is only served by a B-tree when the +index uses `text_pattern_ops` or the database collation is `C`. When in +doubt, `EXPLAIN` it.) + +**3. The condition matches a big slice of the table.** + +```sql +CREATE INDEX users_country_idx ON users (country); + +EXPLAIN ANALYZE +SELECT count(*), min(created_at) FROM users WHERE country = 'DE'; +``` + +Every fifth user is in `DE`, so this matches 200,000 rows. Depending on +your machine you will see either a `Seq Scan`, or a `Bitmap Heap Scan` +that mentions `users_country_idx`. If it is the latter, look at +`Heap Blocks`: Postgres used the index to make a list of matching rows, +then still had to visit nearly every page of the table, because `DE` +users are on every page. + +Either way, compare `Execution Time` with the seq scan in Step 2. About +the same. The index exists, may even be used, and buys nothing. + +Indexes shine when the condition is selective: it picks out a small +fraction of the rows, so that most of the table can be skipped. + +### Your turn + +- [ ] Run all three queries. Confirm none of them is faster than Step 2. +- [ ] Create the `lower(email)` index and re-run query 1. +- [ ] For every index you are tempted to add in production, ask: is the + condition selective, and does the query use the column bare? + +## Step 5: Indexes are not free + +An index is a second copy of a column, kept sorted on disk. Look at what +you have built so far: + +```sql +SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size +FROM pg_class +WHERE relname LIKE 'users%' AND relkind IN ('r', 'i') +ORDER BY pg_relation_size(oid) DESC; +``` + +```text + relname | size +-----------------------+--------- + users | 73 MB + users_email_idx | 39 MB + users_lower_email_idx | 39 MB + users_created_at_idx | 21 MB + users_pkey | 21 MB + users_country_idx | 6800 kB +``` + +The two email indexes together are bigger than the table. Each index +takes disk, and each one must be updated on every `INSERT`, `UPDATE`, +and `DELETE`. Time a write with all of them in place: + +```sql +INSERT INTO users (email, country, created_at) +SELECT 'late' || n || '@example.com', 'US', now() +FROM generate_series(1, 200000) AS n; +``` + +Now drop the two indexes you do not need and run the same insert again: + +```sql +DROP INDEX users_country_idx; +DROP INDEX users_lower_email_idx; + +INSERT INTO users (email, country, created_at) +SELECT 'later' || n || '@example.com', 'US', now() +FROM generate_series(1, 200000) AS n; +``` + +Noticeably faster. Fewer indexes, cheaper writes. On a table that takes +thousands of writes a second, every extra index is a tax on every one +of them. + +On a real system, find the indexes nobody uses: + +```sql +SELECT indexrelname, idx_scan, + pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes -ORDER BY idx_scan ASC; +WHERE relname = 'users' +ORDER BY idx_scan; +``` + +`idx_scan` counts how many times each index has been used since the +statistics were last reset. An index with `0` scans on a busy table is +usually safe to drop, with two exceptions: check replicas and rare +monthly reports first, and never drop a primary key or unique index +because of this number. Those enforce rules about your data, not just +speed. + +### Clean up + +```sql +DROP TABLE users; +\q ``` -> Note: an `idx_scan` of 0 on a busy table usually means the index can -> be dropped — but check replicas and rare reports first. +If you used Docker, `docker stop pgbook-lab` removes the container too. + +### Your turn + +- [ ] Compare the size of the table with the size of each index. +- [ ] Time the insert with all your indexes, then with two fewer. +- [ ] Run the unused-index query and read the `idx_scan` column. +- [ ] Clean up. + +## What you learned + +- Without an index, a `WHERE` clause means reading every row: a + `Seq Scan`. +- `EXPLAIN ANALYZE` shows which plan Postgres chose and how long it + took. Read it before and after every index you add. +- A B-tree index serves equality, ranges, ordering, and prefixes. +- An index does not help when the column is wrapped in a function, the + pattern starts with `%`, or the condition matches a big slice of the + table. +- Every index costs disk and slows every write. Add the ones your + queries need, and check `pg_stat_user_indexes` for the ones nobody + uses. + +Run `pgbook next` to continue with transactions.