vanara 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/bin/vanara.js +66 -2
- package/catalog/agents/api-designer/AGENT.md +140 -0
- package/catalog/agents/api-designer/examples/openapi-snippet.yaml +130 -0
- package/catalog/agents/api-designer/examples/review-notes.md +61 -0
- package/catalog/agents/api-designer/references/contract-and-openapi.md +82 -0
- package/catalog/agents/api-designer/references/design-checklist.md +68 -0
- package/catalog/agents/api-designer/references/versioning-and-evolution.md +63 -0
- package/catalog/agents/api-designer/scripts/lint-openapi.mjs +180 -0
- package/catalog/agents/code-reviewer/AGENT.md +168 -0
- package/catalog/agents/code-reviewer/examples/pr-comment-template.md +48 -0
- package/catalog/agents/code-reviewer/examples/sample-review-output.md +38 -0
- package/catalog/agents/code-reviewer/references/review-checklist.md +46 -0
- package/catalog/agents/code-reviewer/references/security-review.md +62 -0
- package/catalog/agents/code-reviewer/references/severity-rubric.md +63 -0
- package/catalog/agents/code-reviewer/scripts/review-guard.mjs +102 -0
- package/catalog/agents/debugger/AGENT.md +112 -0
- package/catalog/agents/debugger/examples/postmortem-template.md +59 -0
- package/catalog/agents/debugger/examples/repro-template.md +50 -0
- package/catalog/agents/debugger/references/bisection.md +63 -0
- package/catalog/agents/debugger/references/debugging-method.md +57 -0
- package/catalog/agents/debugger/references/observability.md +61 -0
- package/catalog/agents/debugger/scripts/parse-stacktrace.mjs +109 -0
- package/catalog/agents/pr-summarizer/AGENT.md +156 -0
- package/catalog/agents/pr-summarizer/examples/sample-pr-summary.md +46 -0
- package/catalog/agents/pr-summarizer/examples/weak-vs-strong-summary.md +68 -0
- package/catalog/agents/pr-summarizer/references/identifying-risk.md +68 -0
- package/catalog/agents/pr-summarizer/references/reading-the-diff.md +68 -0
- package/catalog/agents/pr-summarizer/references/test-plan-and-missing-tests.md +64 -0
- package/catalog/agents/pr-summarizer/references/writing-the-summary.md +63 -0
- package/catalog/agents/pr-summarizer/scripts/diff-risk.mjs +111 -0
- package/catalog/agents/refactoring-specialist/AGENT.md +185 -0
- package/catalog/agents/refactoring-specialist/examples/before-after.md +91 -0
- package/catalog/agents/refactoring-specialist/examples/refactor-plan.md +97 -0
- package/catalog/agents/refactoring-specialist/references/large-scale-refactor.md +50 -0
- package/catalog/agents/refactoring-specialist/references/safe-workflow.md +75 -0
- package/catalog/agents/refactoring-specialist/references/smell-catalog.md +76 -0
- package/catalog/agents/refactoring-specialist/scripts/detect-smells.mjs +150 -0
- package/catalog/agents/security-auditor/AGENT.md +168 -0
- package/catalog/agents/security-auditor/examples/audit-report.md +68 -0
- package/catalog/agents/security-auditor/examples/finding-template.md +31 -0
- package/catalog/agents/security-auditor/references/audit-workflow.md +116 -0
- package/catalog/agents/security-auditor/references/severity-and-reporting.md +56 -0
- package/catalog/agents/security-auditor/references/vuln-classes.md +129 -0
- package/catalog/agents/security-auditor/scripts/scan-secrets.mjs +86 -0
- package/catalog/agents/technical-writer/AGENT.md +163 -0
- package/catalog/agents/technical-writer/examples/how-to-example.md +76 -0
- package/catalog/agents/technical-writer/examples/reference-example.md +75 -0
- package/catalog/agents/technical-writer/references/clarity-and-style.md +52 -0
- package/catalog/agents/technical-writer/references/diataxis-and-doc-types.md +61 -0
- package/catalog/agents/technical-writer/references/structure-and-maintenance.md +59 -0
- package/catalog/agents/technical-writer/scripts/readability.mjs +103 -0
- package/catalog/agents/test-author/AGENT.md +177 -0
- package/catalog/agents/test-author/examples/test-plan.md +54 -0
- package/catalog/agents/test-author/examples/unit-test-example.ts +55 -0
- package/catalog/agents/test-author/references/aaa-and-naming.md +75 -0
- package/catalog/agents/test-author/references/mocking-and-fixtures.md +94 -0
- package/catalog/agents/test-author/references/test-types-and-pyramid.md +60 -0
- package/catalog/agents/test-author/scripts/check-coverage.mjs +145 -0
- package/catalog/agents/threat-modeler/AGENT.md +163 -0
- package/catalog/agents/threat-modeler/examples/dfd-example.md +55 -0
- package/catalog/agents/threat-modeler/examples/threat-model.md +81 -0
- package/catalog/agents/threat-modeler/references/dfd-and-trust-boundaries.md +76 -0
- package/catalog/agents/threat-modeler/references/mitigation-catalog.md +77 -0
- package/catalog/agents/threat-modeler/references/stride-method.md +68 -0
- package/catalog/agents/threat-modeler/scripts/stride-checklist.mjs +183 -0
- package/catalog/agents/vuln-scanner/AGENT.md +146 -0
- package/catalog/agents/vuln-scanner/examples/finding-template.md +57 -0
- package/catalog/agents/vuln-scanner/examples/scan-report.md +49 -0
- package/catalog/agents/vuln-scanner/references/remediation-and-severity.md +68 -0
- package/catalog/agents/vuln-scanner/references/scan-types-and-tools.md +83 -0
- package/catalog/agents/vuln-scanner/references/triage-and-false-positives.md +73 -0
- package/catalog/agents/vuln-scanner/scripts/parse-scan-results.mjs +133 -0
- package/catalog/packs/code-review-pack/PACK.md +100 -0
- package/catalog/packs/code-review-pack/examples/pr-flow-example.md +50 -0
- package/catalog/packs/code-review-pack/references/rollout-guide.md +40 -0
- package/catalog/packs/security-pack/PACK.md +128 -0
- package/catalog/packs/security-pack/examples/owasp-vuln-triage-and-fix.md +66 -0
- package/catalog/packs/security-pack/examples/threat-model-then-audit.md +77 -0
- package/catalog/packs/security-pack/references/choosing-the-right-tool.md +37 -0
- package/catalog/packs/security-pack/references/threat-model-to-remediation.md +69 -0
- package/catalog/packs/security-pack/references/workflow.md +56 -0
- package/catalog/skills/api-pagination/SKILL.md +83 -0
- package/catalog/skills/api-pagination/examples/keyset-queries.sql +38 -0
- package/catalog/skills/api-pagination/examples/response-shapes.md +51 -0
- package/catalog/skills/api-pagination/references/consuming-pages.md +39 -0
- package/catalog/skills/api-pagination/references/counting-and-totals.md +41 -0
- package/catalog/skills/api-pagination/references/cursor-encoding.md +34 -0
- package/catalog/skills/api-pagination/scripts/check-cursor.mjs +51 -0
- package/catalog/skills/caching-strategies/SKILL.md +180 -0
- package/catalog/skills/caching-strategies/examples/http-cache-headers.md +82 -0
- package/catalog/skills/caching-strategies/examples/redis-cache-aside.js +110 -0
- package/catalog/skills/caching-strategies/references/cache-patterns.md +93 -0
- package/catalog/skills/caching-strategies/references/eviction-and-ttl.md +67 -0
- package/catalog/skills/caching-strategies/references/invalidation-and-stampede.md +99 -0
- package/catalog/skills/caching-strategies/scripts/ttl-jitter.mjs +99 -0
- package/catalog/skills/conventional-commits/SKILL.md +135 -0
- package/catalog/skills/conventional-commits/examples/commit-examples.md +60 -0
- package/catalog/skills/conventional-commits/examples/commitlint.config.js +31 -0
- package/catalog/skills/conventional-commits/references/breaking-changes-semver.md +50 -0
- package/catalog/skills/conventional-commits/references/scoping-commits.md +42 -0
- package/catalog/skills/conventional-commits/references/spec.md +78 -0
- package/catalog/skills/conventional-commits/scripts/lint-commit.mjs +97 -0
- package/catalog/skills/database-migrations/SKILL.md +137 -0
- package/catalog/skills/database-migrations/examples/add-column-safe.sql +34 -0
- package/catalog/skills/database-migrations/examples/migration-config.example.json +27 -0
- package/catalog/skills/database-migrations/references/expand-contract.md +74 -0
- package/catalog/skills/database-migrations/references/rollback-and-safety.md +71 -0
- package/catalog/skills/database-migrations/references/zero-downtime-changes.md +84 -0
- package/catalog/skills/database-migrations/scripts/check-migration-reversible.mjs +139 -0
- package/catalog/skills/error-handling-patterns/SKILL.md +136 -0
- package/catalog/skills/error-handling-patterns/examples/express-error-middleware.js +93 -0
- package/catalog/skills/error-handling-patterns/examples/result-pattern.ts +81 -0
- package/catalog/skills/error-handling-patterns/references/error-taxonomy.md +91 -0
- package/catalog/skills/error-handling-patterns/references/language-patterns.md +102 -0
- package/catalog/skills/error-handling-patterns/references/retry-and-backoff.md +76 -0
- package/catalog/skills/error-handling-patterns/scripts/lint-empty-catch.mjs +113 -0
- package/catalog/skills/git-collaboration-workflows/SKILL.md +104 -0
- package/catalog/skills/git-collaboration-workflows/examples/branch-protection.md +48 -0
- package/catalog/skills/git-collaboration-workflows/examples/pr-review-playbook.md +48 -0
- package/catalog/skills/git-collaboration-workflows/references/branching-models.md +52 -0
- package/catalog/skills/git-collaboration-workflows/references/history-hygiene.md +44 -0
- package/catalog/skills/git-collaboration-workflows/scripts/check-branch-hygiene.mjs +62 -0
- package/catalog/skills/owasp-top10/SKILL.md +112 -0
- package/catalog/skills/owasp-top10/examples/sql-injection-fix.md +33 -0
- package/catalog/skills/owasp-top10/examples/xss-fix.md +27 -0
- package/catalog/skills/owasp-top10/references/access-control.md +36 -0
- package/catalog/skills/owasp-top10/references/crypto-failures.md +29 -0
- package/catalog/skills/owasp-top10/references/injection.md +39 -0
- package/catalog/skills/owasp-top10/references/ssrf-and-design.md +35 -0
- package/catalog/skills/owasp-top10/scripts/scan-injection.mjs +73 -0
- package/catalog/skills/prompt-engineering/SKILL.md +100 -0
- package/catalog/skills/prompt-engineering/examples/classification-prompt.md +29 -0
- package/catalog/skills/prompt-engineering/examples/extraction-prompt.md +25 -0
- package/catalog/skills/prompt-engineering/references/prompt-injection.md +34 -0
- package/catalog/skills/prompt-engineering/references/structured-output.md +34 -0
- package/catalog/skills/prompt-engineering/references/techniques.md +33 -0
- package/catalog/skills/prompt-engineering/scripts/validate-output.mjs +71 -0
- package/catalog/skills/readme-writing/SKILL.md +176 -0
- package/catalog/skills/readme-writing/examples/README.template.md +73 -0
- package/catalog/skills/readme-writing/examples/good-readme-example.md +95 -0
- package/catalog/skills/readme-writing/references/anatomy.md +107 -0
- package/catalog/skills/readme-writing/references/maintenance.md +68 -0
- package/catalog/skills/readme-writing/references/writing-style.md +79 -0
- package/catalog/skills/readme-writing/scripts/lint-readme.mjs +117 -0
- package/catalog/skills/refactoring-patterns/SKILL.md +178 -0
- package/catalog/skills/refactoring-patterns/examples/extract-function.before-after.js +57 -0
- package/catalog/skills/refactoring-patterns/examples/replace-conditional-with-polymorphism.before-after.ts +63 -0
- package/catalog/skills/refactoring-patterns/references/code-smells.md +92 -0
- package/catalog/skills/refactoring-patterns/references/refactoring-catalog.md +78 -0
- package/catalog/skills/refactoring-patterns/references/safe-workflow.md +74 -0
- package/catalog/skills/refactoring-patterns/scripts/detect-smells.mjs +137 -0
- package/catalog/skills/rest-api-design/SKILL.md +147 -0
- package/catalog/skills/rest-api-design/examples/error-envelope.json +26 -0
- package/catalog/skills/rest-api-design/examples/orders-api.openapi.yaml +106 -0
- package/catalog/skills/rest-api-design/references/error-handling.md +43 -0
- package/catalog/skills/rest-api-design/references/pagination.md +41 -0
- package/catalog/skills/rest-api-design/references/status-codes.md +32 -0
- package/catalog/skills/rest-api-design/references/versioning.md +31 -0
- package/catalog/skills/rest-api-design/scripts/check-envelope.mjs +62 -0
- package/catalog/skills/secrets-management/SKILL.md +134 -0
- package/catalog/skills/secrets-management/examples/env-example.md +41 -0
- package/catalog/skills/secrets-management/examples/rotation-runbook.md +50 -0
- package/catalog/skills/secrets-management/references/leak-response.md +47 -0
- package/catalog/skills/secrets-management/references/rotation.md +46 -0
- package/catalog/skills/secrets-management/references/secret-managers.md +52 -0
- package/catalog/skills/secrets-management/scripts/detect-hardcoded.mjs +96 -0
- package/catalog/skills/secure-auth/SKILL.md +135 -0
- package/catalog/skills/secure-auth/examples/auth-flow.md +60 -0
- package/catalog/skills/secure-auth/examples/jwt-verify.ts +89 -0
- package/catalog/skills/secure-auth/references/auth-attacks.md +76 -0
- package/catalog/skills/secure-auth/references/oauth2-oidc.md +65 -0
- package/catalog/skills/secure-auth/references/password-hashing.md +70 -0
- package/catalog/skills/secure-auth/references/sessions-vs-jwt.md +69 -0
- package/catalog/skills/secure-auth/scripts/check-password-policy.mjs +114 -0
- package/catalog/skills/sql-index-tuning/SKILL.md +171 -0
- package/catalog/skills/sql-index-tuning/examples/explain-walkthrough.sql +91 -0
- package/catalog/skills/sql-index-tuning/examples/index-ddl.sql +67 -0
- package/catalog/skills/sql-index-tuning/references/btree-internals.md +58 -0
- package/catalog/skills/sql-index-tuning/references/composite-and-covering.md +60 -0
- package/catalog/skills/sql-index-tuning/references/reading-explain.md +54 -0
- package/catalog/skills/sql-index-tuning/references/selectivity-and-cardinality.md +62 -0
- package/catalog/skills/sql-index-tuning/scripts/suggest-index.mjs +213 -0
- package/catalog/skills/test-plan-design/SKILL.md +154 -0
- package/catalog/skills/test-plan-design/examples/test-case-template.md +45 -0
- package/catalog/skills/test-plan-design/examples/test-plan.md +57 -0
- package/catalog/skills/test-plan-design/references/case-design.md +79 -0
- package/catalog/skills/test-plan-design/references/risk-prioritization.md +58 -0
- package/catalog/skills/test-plan-design/references/test-pyramid.md +58 -0
- package/catalog/skills/test-plan-design/scripts/coverage-gaps.mjs +109 -0
- package/free-tier.json +15 -0
- package/package.json +15 -4
- package/premium-index.json +1 -0
- package/src/config.js +31 -1
- package/src/license.js +143 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sql-index-tuning
|
|
3
|
+
description: Diagnose slow SQL queries and add the right indexes without over-indexing — B-tree mechanics, composite ordering (equality-before-range), the leftmost-prefix rule, covering/index-only scans, reading EXPLAIN ANALYZE, selectivity, and write-amplification costs. Worked SQL examples and a runnable index-suggester.
|
|
4
|
+
type: skill
|
|
5
|
+
version: 2.0.0
|
|
6
|
+
updated: 2026-06-29
|
|
7
|
+
---
|
|
8
|
+
# SQL Index Tuning
|
|
9
|
+
|
|
10
|
+
Indexing is the highest-leverage performance work in most data-backed systems: the right index turns a
|
|
11
|
+
multi-second sequential scan into a sub-millisecond lookup, and the wrong one quietly taxes every write
|
|
12
|
+
forever. This skill is the deep reference for doing it deliberately — how B-tree indexes actually work,
|
|
13
|
+
how to order composite columns, how to read a query plan, and when an index is the wrong answer. Heavy
|
|
14
|
+
detail lives in `references/`; copy-paste DDL and plans in `examples/`; a runnable index-suggester in
|
|
15
|
+
`scripts/`.
|
|
16
|
+
|
|
17
|
+
## Mental model
|
|
18
|
+
|
|
19
|
+
An index is a **sorted, redundant copy** of one or more columns plus a pointer back to the row. The
|
|
20
|
+
database maintains that sort order on every write so reads can binary-search instead of scanning. Two
|
|
21
|
+
consequences follow directly and explain almost every tuning decision:
|
|
22
|
+
|
|
23
|
+
| Property | Why it matters |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Indexes are **sorted** | They serve equality, range, prefix, `ORDER BY`, and `MIN/MAX` from order alone |
|
|
26
|
+
| Indexes are **redundant** | Every `INSERT`/`UPDATE`/`DELETE` must also update them — pure write cost |
|
|
27
|
+
| Indexes are **left-anchored** | A composite `(a, b, c)` can seek on `a`, `a,b`, `a,b,c` — never `b` or `c` alone |
|
|
28
|
+
|
|
29
|
+
Keep both halves in mind at once: you are buying read speed with write speed and storage. The job is to
|
|
30
|
+
buy only the indexes that pay for themselves.
|
|
31
|
+
|
|
32
|
+
## 1. B-tree basics
|
|
33
|
+
|
|
34
|
+
The default index type everywhere (`CREATE INDEX` builds a B-tree unless you ask otherwise) is a
|
|
35
|
+
balanced tree whose leaf nodes hold sorted key values. Lookups are `O(log n)` — a few page reads even
|
|
36
|
+
for billions of rows. Because the leaves are sorted and linked, a B-tree efficiently serves:
|
|
37
|
+
|
|
38
|
+
- **Equality**: `WHERE status = 'open'`
|
|
39
|
+
- **Range**: `WHERE created_at > '2026-01-01'`, `BETWEEN`, `<`, `>`
|
|
40
|
+
- **Sorted output**: `ORDER BY created_at` with no separate sort step
|
|
41
|
+
- **Prefix matches**: `WHERE email LIKE 'jay%'` (but not `'%jay'` — a leading wildcard cannot seek)
|
|
42
|
+
|
|
43
|
+
It cannot help with operations that don't respect that ordering: `WHERE lower(email) = ...` (unless you
|
|
44
|
+
index the expression), `!=`, or `%suffix` searches. Full mechanics, page splits, and why a low-cardinality
|
|
45
|
+
boolean is a poor leading column are in [`references/btree-internals.md`](references/btree-internals.md).
|
|
46
|
+
|
|
47
|
+
## 2. Composite indexes & the leftmost-prefix rule
|
|
48
|
+
|
|
49
|
+
A composite index `(a, b, c)` is sorted by `a`, then `b` within equal `a`, then `c`. This is the single
|
|
50
|
+
most misunderstood part of indexing. The index can satisfy a predicate only as a **left-anchored prefix**:
|
|
51
|
+
|
|
52
|
+
```sql
|
|
53
|
+
-- Index: (tenant_id, status, created_at)
|
|
54
|
+
WHERE tenant_id = 9 AND status = 'open' -- uses (tenant_id, status) ✓
|
|
55
|
+
WHERE tenant_id = 9 AND status = 'open' AND created_at>… -- uses all three ✓
|
|
56
|
+
WHERE tenant_id = 9 -- uses (tenant_id) ✓
|
|
57
|
+
WHERE status = 'open' -- CANNOT seek (skips tenant_id) ✗
|
|
58
|
+
WHERE tenant_id = 9 AND created_at > … -- seeks tenant_id, filters rest ◑
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Column ordering rule: equality columns first, then one range column, then columns needed only for
|
|
62
|
+
sort.** A range predicate (`>`, `<`, `BETWEEN`) "uses up" the index — columns after the range column
|
|
63
|
+
can no longer be used for seeking, only as a filter. So `(tenant_id, status, created_at)` is right for
|
|
64
|
+
`tenant_id = ? AND status = ? AND created_at > ?`, but putting `created_at` before `status` would waste
|
|
65
|
+
the `status` equality. Full reasoning, plus covering indexes and index-only scans, in
|
|
66
|
+
[`references/composite-and-covering.md`](references/composite-and-covering.md).
|
|
67
|
+
|
|
68
|
+
## 3. Covering indexes & index-only scans
|
|
69
|
+
|
|
70
|
+
If an index contains **every column a query touches** (in `SELECT`, `WHERE`, and `ORDER BY`), the engine
|
|
71
|
+
answers from the index alone and never visits the table heap — an **index-only scan**. This eliminates the
|
|
72
|
+
random I/O of fetching rows and is often a 5–50x win on hot read paths.
|
|
73
|
+
|
|
74
|
+
```sql
|
|
75
|
+
-- Query reads only id, tenant_id, status:
|
|
76
|
+
SELECT id, status FROM orders WHERE tenant_id = 9 AND status = 'open';
|
|
77
|
+
|
|
78
|
+
-- Covering index (Postgres INCLUDE keeps non-key columns in the leaf):
|
|
79
|
+
CREATE INDEX ix_orders_cover ON orders (tenant_id, status) INCLUDE (id);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The trade-off: covering indexes are wider, so they cost more to write and store. Add them only for
|
|
83
|
+
high-frequency queries you have measured. See `examples/index-ddl.sql` for `INCLUDE` vs composite forms.
|
|
84
|
+
|
|
85
|
+
## 4. Reading EXPLAIN / EXPLAIN ANALYZE
|
|
86
|
+
|
|
87
|
+
`EXPLAIN` shows the planner's chosen plan and **estimated** cost; `EXPLAIN ANALYZE` actually runs the
|
|
88
|
+
query and reports **real** timings and row counts. Always tune against `ANALYZE` output. What to look for:
|
|
89
|
+
|
|
90
|
+
- **`Seq Scan` on a large table** under a selective `WHERE` — the prime candidate for an index.
|
|
91
|
+
- **`rows` estimate vs `actual`** wildly diverging — stale statistics; run `ANALYZE <table>`.
|
|
92
|
+
- **`Sort` / `Hash` nodes with high cost** — an ordered index may remove the sort.
|
|
93
|
+
- **`Index Scan` vs `Index Only Scan`** — the latter means your index is covering.
|
|
94
|
+
- **`Rows Removed by Filter`** — rows read then thrown away; a better index seeks past them.
|
|
95
|
+
|
|
96
|
+
```sql
|
|
97
|
+
EXPLAIN (ANALYZE, BUFFERS)
|
|
98
|
+
SELECT id FROM orders WHERE tenant_id = 9 AND created_at > now() - interval '7 days';
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A full annotated walkthrough — before/after plans for the same query — is in `examples/explain-walkthrough.sql`
|
|
102
|
+
and the node-by-node guide in [`references/reading-explain.md`](references/reading-explain.md).
|
|
103
|
+
|
|
104
|
+
## 5. Selectivity & cardinality
|
|
105
|
+
|
|
106
|
+
**Selectivity** is the fraction of rows a predicate keeps; **cardinality** is the number of distinct
|
|
107
|
+
values in a column. An index helps in proportion to its selectivity — seeking to 50 rows out of 10M is a
|
|
108
|
+
huge win; seeking to 4M out of 10M is slower than a sequential scan, and the planner will (correctly)
|
|
109
|
+
ignore the index. Rules of thumb:
|
|
110
|
+
|
|
111
|
+
- Lead a composite index with the **most selective equality column** the query actually filters on.
|
|
112
|
+
- A boolean or low-cardinality status column is a poor standalone index but fine deeper in a composite.
|
|
113
|
+
- Watch for **skew**: `status = 'archived'` (95% of rows) is unselective; `status = 'pending'` (0.1%) is
|
|
114
|
+
highly selective. Partial indexes (`WHERE status = 'pending'`) target exactly the selective slice.
|
|
115
|
+
|
|
116
|
+
Details and how the planner uses histograms: [`references/selectivity-and-cardinality.md`](references/selectivity-and-cardinality.md).
|
|
117
|
+
|
|
118
|
+
## 6. The write-amplification cost — when NOT to add an index
|
|
119
|
+
|
|
120
|
+
Every index is extra work on every write to the table. Five indexes mean an `INSERT` does roughly six
|
|
121
|
+
B-tree maintenance operations. This is **write amplification**, and it's the reason "just add an index"
|
|
122
|
+
is not free advice.
|
|
123
|
+
|
|
124
|
+
**Do NOT add an index when:**
|
|
125
|
+
|
|
126
|
+
- The table is **write-heavy and rarely read** on that column (a log/append table).
|
|
127
|
+
- The column has **very low cardinality** and the query isn't selective (a 2-value flag on its own).
|
|
128
|
+
- A **redundant** index already covers the need — `(a, b)` makes a separate `(a)` index unnecessary,
|
|
129
|
+
since the composite serves `a`-only seeks via the leftmost prefix. Drop the narrower one.
|
|
130
|
+
- The table is **small** (a few thousand rows); a sequential scan is already fast and the planner skips
|
|
131
|
+
the index anyway.
|
|
132
|
+
- You haven't measured. Speculative indexes accumulate into pure write tax and bloat.
|
|
133
|
+
|
|
134
|
+
## Common pitfalls (failure modes)
|
|
135
|
+
|
|
136
|
+
- **Wrapping the indexed column in a function**: `WHERE date(created_at) = '2026-06-29'` cannot use an
|
|
137
|
+
index on `created_at`. Rewrite as a range, or build an expression index.
|
|
138
|
+
- **Leading wildcard `LIKE '%foo'`** — no prefix to seek; needs a trigram or full-text index instead.
|
|
139
|
+
- **Wrong composite order** — `(created_at, tenant_id)` for `tenant_id = ? AND created_at > ?` forces a
|
|
140
|
+
scan; the equality column must lead. (See the leftmost-prefix rule above.)
|
|
141
|
+
- **Type/collation mismatch** — comparing a `bigint` column to a string literal, or a join across
|
|
142
|
+
mismatched collations, silently disables the index.
|
|
143
|
+
- **Redundant overlapping indexes** — `(a)`, `(a, b)`, `(a, b, c)` where the widest alone suffices: pure
|
|
144
|
+
write cost, no read benefit.
|
|
145
|
+
- **Trusting `EXPLAIN` over `EXPLAIN ANALYZE`** — estimates lie when statistics are stale; always verify
|
|
146
|
+
with real timings, and `ANALYZE` the table after big data changes.
|
|
147
|
+
- **OR across columns** — `WHERE a = 1 OR b = 2` often can't use a single composite; consider a `UNION`
|
|
148
|
+
of two indexed seeks or separate indexes.
|
|
149
|
+
|
|
150
|
+
## When NOT to use / trade-offs
|
|
151
|
+
|
|
152
|
+
Indexing is the wrong lever when the bottleneck is elsewhere: an `N+1` query pattern needs batching, not
|
|
153
|
+
an index; a query returning most of a table should be paginated and is already best served by a scan; and
|
|
154
|
+
analytical scans over whole tables belong in a column store, not a B-tree. Reach for a different index
|
|
155
|
+
type when B-tree can't help — GIN/trigram for substring and full-text search, GiST for geospatial,
|
|
156
|
+
BRIN for naturally-ordered append-only data (huge time-series tables) where a tiny block-range index
|
|
157
|
+
beats a full B-tree. And remember the meta-cost: every index slows writes and consumes storage and cache.
|
|
158
|
+
On a write-dominated workload, fewer, well-chosen indexes beat many.
|
|
159
|
+
|
|
160
|
+
Pairs with `database-scaling`, `data-modeling`, and `database-migrations` (add indexes concurrently /
|
|
161
|
+
online to avoid locking production tables during deploys).
|
|
162
|
+
|
|
163
|
+
## Files in this package
|
|
164
|
+
|
|
165
|
+
- `references/btree-internals.md` — how B-trees store keys, page splits, what they can and can't serve
|
|
166
|
+
- `references/composite-and-covering.md` — leftmost-prefix rule, equality-before-range, covering/index-only scans
|
|
167
|
+
- `references/reading-explain.md` — node-by-node guide to EXPLAIN / EXPLAIN ANALYZE output
|
|
168
|
+
- `references/selectivity-and-cardinality.md` — selectivity math, histograms, partial indexes, skew
|
|
169
|
+
- `examples/explain-walkthrough.sql` — before/after query plans for the same query, annotated
|
|
170
|
+
- `examples/index-ddl.sql` — composite, covering (`INCLUDE`), partial, and expression index DDL
|
|
171
|
+
- `scripts/suggest-index.mjs` — proposes a composite index from a query's columns (equality-before-range + leftmost-prefix); run `--selftest` to verify
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
-- explain-walkthrough.sql
|
|
2
|
+
-- A before/after EXPLAIN ANALYZE walkthrough for the same query, showing how the
|
|
3
|
+
-- right composite index changes the plan. Annotations are inline (-- ...).
|
|
4
|
+
-- Target: PostgreSQL. The same shape applies to MySQL EXPLAIN ANALYZE.
|
|
5
|
+
|
|
6
|
+
-- ---------------------------------------------------------------------------
|
|
7
|
+
-- Setup: a multi-tenant orders table, ~10M rows, no useful index yet.
|
|
8
|
+
-- ---------------------------------------------------------------------------
|
|
9
|
+
CREATE TABLE orders (
|
|
10
|
+
id bigserial PRIMARY KEY,
|
|
11
|
+
tenant_id bigint NOT NULL,
|
|
12
|
+
status text NOT NULL, -- 'open' | 'paid' | 'archived'
|
|
13
|
+
total_cents bigint NOT NULL,
|
|
14
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
15
|
+
);
|
|
16
|
+
-- (assume 10M rows loaded; one tenant owns ~5k rows; ~2% are 'open')
|
|
17
|
+
|
|
18
|
+
-- The query we want to make fast: a tenant's recent open orders.
|
|
19
|
+
-- WHERE has two equality columns and one range column, plus an ORDER BY.
|
|
20
|
+
|
|
21
|
+
-- ===========================================================================
|
|
22
|
+
-- BEFORE: only the primary-key index exists.
|
|
23
|
+
-- ===========================================================================
|
|
24
|
+
EXPLAIN (ANALYZE, BUFFERS)
|
|
25
|
+
SELECT id, total_cents
|
|
26
|
+
FROM orders
|
|
27
|
+
WHERE tenant_id = 9
|
|
28
|
+
AND status = 'open'
|
|
29
|
+
AND created_at > now() - interval '30 days'
|
|
30
|
+
ORDER BY created_at DESC
|
|
31
|
+
LIMIT 20;
|
|
32
|
+
|
|
33
|
+
-- Typical plan (abridged):
|
|
34
|
+
--
|
|
35
|
+
-- Limit (actual time=812.4..812.4 rows=20)
|
|
36
|
+
-- -> Sort (actual rows=20 loops=1) -- explicit sort step
|
|
37
|
+
-- Sort Key: created_at DESC
|
|
38
|
+
-- -> Seq Scan on orders -- reads ALL 10M rows
|
|
39
|
+
-- Filter: (tenant_id = 9 AND status = 'open' AND created_at > ...)
|
|
40
|
+
-- Rows Removed by Filter: 9994800 -- threw away ~10M rows
|
|
41
|
+
-- Buffers: shared read=98112 -- heavy disk I/O
|
|
42
|
+
-- Execution Time: 813.1 ms
|
|
43
|
+
--
|
|
44
|
+
-- Diagnosis: Seq Scan + huge "Rows Removed by Filter" + a Sort node. The query
|
|
45
|
+
-- is highly selective (keeps ~100 rows of 10M) but the engine has no index to
|
|
46
|
+
-- seek with, so it scans everything and sorts the survivors.
|
|
47
|
+
|
|
48
|
+
-- ===========================================================================
|
|
49
|
+
-- THE FIX: composite index following equality-before-range + leftmost-prefix.
|
|
50
|
+
-- Two equality columns lead (tenant_id, status); the range/sort column last.
|
|
51
|
+
-- ===========================================================================
|
|
52
|
+
CREATE INDEX CONCURRENTLY ix_orders_tenant_status_created
|
|
53
|
+
ON orders (tenant_id, status, created_at DESC);
|
|
54
|
+
-- ^equality ^equality ^range + ORDER BY (matching DESC direction)
|
|
55
|
+
|
|
56
|
+
-- Refresh stats so the planner trusts the new index.
|
|
57
|
+
ANALYZE orders;
|
|
58
|
+
|
|
59
|
+
-- ===========================================================================
|
|
60
|
+
-- AFTER: re-run the identical query.
|
|
61
|
+
-- ===========================================================================
|
|
62
|
+
EXPLAIN (ANALYZE, BUFFERS)
|
|
63
|
+
SELECT id, total_cents
|
|
64
|
+
FROM orders
|
|
65
|
+
WHERE tenant_id = 9
|
|
66
|
+
AND status = 'open'
|
|
67
|
+
AND created_at > now() - interval '30 days'
|
|
68
|
+
ORDER BY created_at DESC
|
|
69
|
+
LIMIT 20;
|
|
70
|
+
|
|
71
|
+
-- Typical plan (abridged):
|
|
72
|
+
--
|
|
73
|
+
-- Limit (actual time=0.05..0.12 rows=20)
|
|
74
|
+
-- -> Index Scan using ix_orders_tenant_status_created on orders
|
|
75
|
+
-- Index Cond: (tenant_id = 9 AND status = 'open' AND created_at > ...)
|
|
76
|
+
-- Buffers: shared hit=6 -- a handful of cached pages
|
|
77
|
+
-- Execution Time: 0.18 ms -- ~4500x faster
|
|
78
|
+
--
|
|
79
|
+
-- Note: NO Sort node — the index is already ordered by created_at DESC, so the
|
|
80
|
+
-- ORDER BY is satisfied for free. "Rows Removed by Filter" is gone because the
|
|
81
|
+
-- Index Cond seeked straight to the matching slice.
|
|
82
|
+
|
|
83
|
+
-- ===========================================================================
|
|
84
|
+
-- BONUS: make it an index-only scan by covering total_cents (and id) so the
|
|
85
|
+
-- heap is never touched. total_cents is payload-only -> use INCLUDE.
|
|
86
|
+
-- ===========================================================================
|
|
87
|
+
DROP INDEX CONCURRENTLY ix_orders_tenant_status_created;
|
|
88
|
+
CREATE INDEX CONCURRENTLY ix_orders_cover
|
|
89
|
+
ON orders (tenant_id, status, created_at DESC) INCLUDE (id, total_cents);
|
|
90
|
+
ANALYZE orders;
|
|
91
|
+
-- Re-running now yields "Index Only Scan ... Heap Fetches: 0" -- no heap I/O.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- index-ddl.sql
|
|
2
|
+
-- Copy-paste index DDL covering the common shapes. Target: PostgreSQL (notes
|
|
3
|
+
-- mark MySQL/SQL Server differences). Always create indexes CONCURRENTLY in
|
|
4
|
+
-- production so you don't hold an exclusive lock on the table during the build.
|
|
5
|
+
|
|
6
|
+
-- ---------------------------------------------------------------------------
|
|
7
|
+
-- 1. Single-column index.
|
|
8
|
+
-- Use for a selective equality or range on one column.
|
|
9
|
+
-- ---------------------------------------------------------------------------
|
|
10
|
+
CREATE INDEX CONCURRENTLY ix_users_email
|
|
11
|
+
ON users (email);
|
|
12
|
+
|
|
13
|
+
-- ---------------------------------------------------------------------------
|
|
14
|
+
-- 2. Composite index: equality columns first, then ONE range column, then any
|
|
15
|
+
-- column needed only for ORDER BY. Serves the leftmost prefixes too:
|
|
16
|
+
-- (tenant_id), (tenant_id, status), and the full triple.
|
|
17
|
+
-- Query: WHERE tenant_id = ? AND status = ? AND created_at > ? ORDER BY created_at
|
|
18
|
+
-- ---------------------------------------------------------------------------
|
|
19
|
+
CREATE INDEX CONCURRENTLY ix_orders_tenant_status_created
|
|
20
|
+
ON orders (tenant_id, status, created_at);
|
|
21
|
+
-- ^equality ^equality ^range / sort
|
|
22
|
+
|
|
23
|
+
-- A separate (tenant_id) or (tenant_id, status) index would now be REDUNDANT
|
|
24
|
+
-- because the composite already serves those prefixes. Drop such overlaps:
|
|
25
|
+
-- DROP INDEX CONCURRENTLY ix_orders_tenant; -- redundant with the composite
|
|
26
|
+
|
|
27
|
+
-- ---------------------------------------------------------------------------
|
|
28
|
+
-- 3. Covering index (index-only scan). INCLUDE keeps payload columns in the
|
|
29
|
+
-- leaf only -- they're returnable without a heap visit but don't widen the
|
|
30
|
+
-- tree or impose sort semantics. Use for hot read paths you've measured.
|
|
31
|
+
-- Query: SELECT id, total_cents FROM orders WHERE tenant_id = ? AND status = ?
|
|
32
|
+
-- ---------------------------------------------------------------------------
|
|
33
|
+
CREATE INDEX CONCURRENTLY ix_orders_cover
|
|
34
|
+
ON orders (tenant_id, status) INCLUDE (id, total_cents);
|
|
35
|
+
-- MySQL/InnoDB has no INCLUDE: add the columns as trailing key columns instead:
|
|
36
|
+
-- CREATE INDEX ix_orders_cover ON orders (tenant_id, status, id, total_cents);
|
|
37
|
+
|
|
38
|
+
-- ---------------------------------------------------------------------------
|
|
39
|
+
-- 4. Partial index: target only the selective slice of a skewed column. Tiny
|
|
40
|
+
-- and cheap to maintain. Perfect for queue/worker tables scanning a rare
|
|
41
|
+
-- state. Query: WHERE status = 'pending' ORDER BY created_at
|
|
42
|
+
-- ---------------------------------------------------------------------------
|
|
43
|
+
CREATE INDEX CONCURRENTLY ix_orders_pending
|
|
44
|
+
ON orders (tenant_id, created_at)
|
|
45
|
+
WHERE status = 'pending';
|
|
46
|
+
|
|
47
|
+
-- ---------------------------------------------------------------------------
|
|
48
|
+
-- 5. Expression (functional) index: needed when the query wraps the column in a
|
|
49
|
+
-- function, which otherwise disables a plain index.
|
|
50
|
+
-- Query: WHERE lower(email) = ?
|
|
51
|
+
-- ---------------------------------------------------------------------------
|
|
52
|
+
CREATE INDEX CONCURRENTLY ix_users_lower_email
|
|
53
|
+
ON users (lower(email));
|
|
54
|
+
|
|
55
|
+
-- ---------------------------------------------------------------------------
|
|
56
|
+
-- 6. Unique composite constraint (also builds a usable index).
|
|
57
|
+
-- ---------------------------------------------------------------------------
|
|
58
|
+
CREATE UNIQUE INDEX CONCURRENTLY ux_memberships_tenant_user
|
|
59
|
+
ON memberships (tenant_id, user_id);
|
|
60
|
+
|
|
61
|
+
-- ---------------------------------------------------------------------------
|
|
62
|
+
-- Housekeeping: find unused indexes (pure write tax) and drop them.
|
|
63
|
+
-- ---------------------------------------------------------------------------
|
|
64
|
+
-- SELECT relname AS table, indexrelname AS index, idx_scan AS scans
|
|
65
|
+
-- FROM pg_stat_user_indexes
|
|
66
|
+
-- WHERE idx_scan = 0 -- never used since stats reset
|
|
67
|
+
-- ORDER BY pg_relation_size(indexrelid) DESC;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# B-tree Internals
|
|
2
|
+
|
|
3
|
+
The B-tree (technically a B+tree in most databases) is the default index structure. Understanding its
|
|
4
|
+
shape explains exactly which queries it can accelerate.
|
|
5
|
+
|
|
6
|
+
## Structure
|
|
7
|
+
|
|
8
|
+
- **Root and internal nodes** hold separator keys and pointers to child pages. They route a search.
|
|
9
|
+
- **Leaf nodes** hold the actual sorted key values plus a pointer (tuple id / row locator) back to the
|
|
10
|
+
table row. In a B+tree, leaves are doubly-linked, so a range scan walks neighbours without re-descending
|
|
11
|
+
the tree.
|
|
12
|
+
- The tree is **balanced**: every leaf is the same depth from the root. A lookup is `O(log n)` — for a
|
|
13
|
+
billion rows that's roughly 4–5 page reads.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
[ . m . ] root (separators)
|
|
17
|
+
/ \
|
|
18
|
+
[ . f . ] [ . t . ] internal
|
|
19
|
+
/ | \ / | \
|
|
20
|
+
a→ ... f→ ... m→ ... t→ ... leaves: sorted keys → row pointers
|
|
21
|
+
└──── linked leaf chain for range scans ────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Page splits and write cost
|
|
25
|
+
|
|
26
|
+
Keys live in fixed-size pages. When an insert lands in a full leaf page, the page **splits** into two and
|
|
27
|
+
the separator propagates up — occasionally cascading to the root. This is why inserts into the *middle* of
|
|
28
|
+
the key space are costlier than appending to the end. A monotonically increasing key (an autoincrement id,
|
|
29
|
+
or `created_at` on an append table) always inserts at the right edge, minimizing splits but concentrating
|
|
30
|
+
contention on the rightmost page.
|
|
31
|
+
|
|
32
|
+
## What a B-tree can serve
|
|
33
|
+
|
|
34
|
+
| Operation | Served by B-tree? | Note |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `col = v` | Yes | descend to leaf, done |
|
|
37
|
+
| `col > v` / `BETWEEN` | Yes | seek then walk the leaf chain |
|
|
38
|
+
| `ORDER BY col` | Yes | leaves are already sorted; no sort node |
|
|
39
|
+
| `MIN(col)` / `MAX(col)` | Yes | first / last leaf entry |
|
|
40
|
+
| `col LIKE 'pre%'` | Yes | prefix is a range |
|
|
41
|
+
| `col LIKE '%suf'` | No | no prefix to anchor the descent |
|
|
42
|
+
| `f(col) = v` | No | unless an expression index on `f(col)` exists |
|
|
43
|
+
| `col != v` | No | matches almost everything; scan is cheaper |
|
|
44
|
+
|
|
45
|
+
## Cardinality and the leading column
|
|
46
|
+
|
|
47
|
+
Because the tree branches on the leading column first, a **low-cardinality** leading column (e.g. a
|
|
48
|
+
boolean) creates only two huge subtrees — a seek lands you in 50% of the rows, which the planner will
|
|
49
|
+
reject in favour of a sequential scan. Lead with selective, high-cardinality columns. Low-cardinality
|
|
50
|
+
columns are useful only deeper in a composite, or as a **partial index** predicate.
|
|
51
|
+
|
|
52
|
+
## Heap vs index-organized
|
|
53
|
+
|
|
54
|
+
In Postgres and MySQL/InnoDB the details differ: InnoDB's primary key **is** the table (clustered /
|
|
55
|
+
index-organized), so secondary indexes store the primary key as the row pointer and a lookup is two
|
|
56
|
+
B-tree descents. Postgres keeps a separate heap, so secondary and primary indexes are symmetric but the
|
|
57
|
+
heap can require a visibility check (mitigated by the visibility map for index-only scans). This is why a
|
|
58
|
+
covering index that avoids the heap visit is such a large win in Postgres.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Composite Indexes, the Leftmost-Prefix Rule, and Covering Scans
|
|
2
|
+
|
|
3
|
+
## The leftmost-prefix rule
|
|
4
|
+
|
|
5
|
+
A composite index `(a, b, c)` physically sorts rows by `a`, then `b` within each `a`, then `c` within each
|
|
6
|
+
`(a, b)`. A seek can therefore use the index only for a **left-anchored prefix** of the columns:
|
|
7
|
+
|
|
8
|
+
| Predicate | Index columns used for seek |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `a = ?` | `(a)` |
|
|
11
|
+
| `a = ? AND b = ?` | `(a, b)` |
|
|
12
|
+
| `a = ? AND b = ? AND c = ?` | `(a, b, c)` |
|
|
13
|
+
| `b = ?` | none (gap at `a`) |
|
|
14
|
+
| `a = ? AND c = ?` | `(a)` only; `c` becomes a filter (gap at `b`) |
|
|
15
|
+
|
|
16
|
+
The moment there's a gap, the columns after the gap can't be used for seeking — only for filtering rows
|
|
17
|
+
already located by the prefix.
|
|
18
|
+
|
|
19
|
+
## Equality before range
|
|
20
|
+
|
|
21
|
+
A range predicate (`>`, `<`, `>=`, `<=`, `BETWEEN`, `LIKE 'x%'`) returns a contiguous slice of the index.
|
|
22
|
+
Everything **after** a range column in the index can no longer narrow the seek — the engine has to scan the
|
|
23
|
+
whole slice and filter. Therefore:
|
|
24
|
+
|
|
25
|
+
> **Order composite columns: all equality columns first, then a single range column, then any columns
|
|
26
|
+
> needed only to satisfy `ORDER BY`.**
|
|
27
|
+
|
|
28
|
+
Example — query `WHERE tenant_id = ? AND status = ? AND created_at > ? ORDER BY created_at`:
|
|
29
|
+
|
|
30
|
+
- Good: `(tenant_id, status, created_at)` — two equalities seek precisely, the range walks the slice, and
|
|
31
|
+
the slice is already ordered by `created_at` so the sort disappears.
|
|
32
|
+
- Bad: `(tenant_id, created_at, status)` — the range on `created_at` comes before the `status` equality,
|
|
33
|
+
so `status` degrades to a filter and the seek is wider than necessary.
|
|
34
|
+
|
|
35
|
+
## ORDER BY and indexes
|
|
36
|
+
|
|
37
|
+
An index can supply sort order for free if the `ORDER BY` columns are a prefix of the remaining index
|
|
38
|
+
columns **with matching direction**. `(tenant_id, created_at)` serves
|
|
39
|
+
`WHERE tenant_id = ? ORDER BY created_at ASC` with no sort node. For `ORDER BY created_at DESC`, most
|
|
40
|
+
engines can scan the index backwards. Mixed directions (`a ASC, b DESC`) need a matching mixed-direction
|
|
41
|
+
index.
|
|
42
|
+
|
|
43
|
+
## Covering indexes and index-only scans
|
|
44
|
+
|
|
45
|
+
If the index contains every column the query references, the engine never touches the table heap — an
|
|
46
|
+
**index-only scan**. Two ways to make an index cover:
|
|
47
|
+
|
|
48
|
+
1. **Add the columns as key columns**: `(tenant_id, status, created_at)` covers a query selecting those
|
|
49
|
+
three. Downside: they participate in sorting and widen every internal node.
|
|
50
|
+
2. **`INCLUDE` non-key columns** (Postgres, SQL Server): `(tenant_id, status) INCLUDE (total, created_at)`
|
|
51
|
+
stores the extra columns only in the leaf, so they're available for the scan without bloating the tree
|
|
52
|
+
or imposing sort semantics. Prefer `INCLUDE` for payload columns you only need to return, not filter.
|
|
53
|
+
|
|
54
|
+
Covering indexes are wider, so they cost more to write and store. Reserve them for measured hot paths.
|
|
55
|
+
|
|
56
|
+
## Redundancy
|
|
57
|
+
|
|
58
|
+
`(a, b, c)` already serves seeks on `(a)` and `(a, b)`. A separate `(a)` or `(a, b)` index is redundant —
|
|
59
|
+
drop it to reclaim the write cost, unless the narrower one is also covering for a specific query or has a
|
|
60
|
+
different sort direction you depend on.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Reading EXPLAIN / EXPLAIN ANALYZE
|
|
2
|
+
|
|
3
|
+
`EXPLAIN` prints the planner's chosen execution plan and its **estimated** costs. `EXPLAIN ANALYZE`
|
|
4
|
+
executes the query and adds **actual** times and row counts. Tune against `ANALYZE` — estimates are only
|
|
5
|
+
as good as the table statistics.
|
|
6
|
+
|
|
7
|
+
```sql
|
|
8
|
+
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
|
|
9
|
+
SELECT id FROM orders
|
|
10
|
+
WHERE tenant_id = 9 AND created_at > now() - interval '7 days';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Plan nodes you'll meet
|
|
14
|
+
|
|
15
|
+
| Node | Meaning | Signal |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `Seq Scan` | read every row | bad under a selective filter on a big table — index candidate |
|
|
18
|
+
| `Index Scan` | seek the index, then fetch rows from the heap | good; random heap I/O remains |
|
|
19
|
+
| `Index Only Scan` | answer entirely from the index | best; the index is covering |
|
|
20
|
+
| `Bitmap Heap Scan` | gather many matches, then fetch heap pages in order | good for medium-selectivity ranges |
|
|
21
|
+
| `Sort` | explicit sort step | removable if an ordered index matches the `ORDER BY` |
|
|
22
|
+
| `Hash Join` / `Nested Loop` | join strategies | nested loop without an inner index can explode |
|
|
23
|
+
|
|
24
|
+
## The numbers that matter
|
|
25
|
+
|
|
26
|
+
- **`cost=`** — planner's unitless estimate (`startup..total`). Compare alternatives, don't read as ms.
|
|
27
|
+
- **`rows=` (estimate) vs `actual rows=`** — a big divergence means **stale statistics**. Run
|
|
28
|
+
`ANALYZE <table>` (or `VACUUM ANALYZE`) so the planner has fresh histograms.
|
|
29
|
+
- **`actual time=`** — real `startup..total` milliseconds per loop. Multiply by `loops` for total.
|
|
30
|
+
- **`Rows Removed by Filter: N`** — rows the node read and then discarded. A large number means the index
|
|
31
|
+
located far more rows than the query keeps — the leading column isn't selective enough, or a predicate
|
|
32
|
+
isn't part of the index.
|
|
33
|
+
- **`Buffers: shared hit/read`** — `hit` is cache, `read` is disk. High `read` on a Seq Scan is the I/O
|
|
34
|
+
you're trying to eliminate.
|
|
35
|
+
|
|
36
|
+
## A tuning loop
|
|
37
|
+
|
|
38
|
+
1. Run `EXPLAIN ANALYZE` on the slow query; identify the dominant node (usually a `Seq Scan` or a `Sort`).
|
|
39
|
+
2. Check `actual rows` returned vs scanned — confirm the query is actually selective (an index won't help
|
|
40
|
+
a query that keeps most of the table).
|
|
41
|
+
3. Propose an index following equality-before-range and leftmost-prefix (see
|
|
42
|
+
`composite-and-covering.md`, or run `scripts/suggest-index.mjs`).
|
|
43
|
+
4. Create it (use `CREATE INDEX CONCURRENTLY` in production to avoid locking).
|
|
44
|
+
5. Re-run `EXPLAIN ANALYZE`. Confirm the plan switched to `Index Scan`/`Index Only Scan`, `Rows Removed by
|
|
45
|
+
Filter` dropped, and `actual time` fell. If the planner still chose a `Seq Scan`, your predicate
|
|
46
|
+
probably isn't selective enough — the index won't pay off.
|
|
47
|
+
|
|
48
|
+
## Gotchas
|
|
49
|
+
|
|
50
|
+
- **`EXPLAIN` without `ANALYZE` never runs the query** — safe on writes, but shows estimates only.
|
|
51
|
+
- **`EXPLAIN ANALYZE` on an `INSERT`/`UPDATE`/`DELETE` executes it.** Wrap in a transaction and `ROLLBACK`.
|
|
52
|
+
- The first run pays cold-cache I/O; run twice to see warm timings.
|
|
53
|
+
- A loop count in a `Nested Loop` multiplies per-row cost — a cheap-looking inner node run 10,000 times is
|
|
54
|
+
your bottleneck.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Selectivity & Cardinality
|
|
2
|
+
|
|
3
|
+
These two concepts decide whether an index is worth building and which column should lead a composite.
|
|
4
|
+
|
|
5
|
+
## Definitions
|
|
6
|
+
|
|
7
|
+
- **Cardinality** — the number of distinct values in a column. `user_id` has high cardinality; a boolean
|
|
8
|
+
`is_active` has cardinality 2.
|
|
9
|
+
- **Selectivity** — the fraction of rows a predicate keeps. `WHERE id = 42` on a unique column is maximally
|
|
10
|
+
selective (1 row). `WHERE is_active = true` on a table that's 90% active is poorly selective (0.9 of
|
|
11
|
+
rows).
|
|
12
|
+
|
|
13
|
+
An index is worthwhile in proportion to selectivity. Seeking to retrieve 0.001 of a table is a massive
|
|
14
|
+
win; seeking to retrieve 0.5 of it is slower than a sequential scan because of random heap fetches — and
|
|
15
|
+
the planner will ignore the index, correctly.
|
|
16
|
+
|
|
17
|
+
## How the planner decides
|
|
18
|
+
|
|
19
|
+
Databases keep **statistics** per column: number of distinct values, most-common-values (MCV) list, and a
|
|
20
|
+
**histogram** of the value distribution. From these it estimates how many rows a predicate returns and
|
|
21
|
+
compares the cost of an index scan (seek + random heap I/O) against a sequential scan (streaming reads).
|
|
22
|
+
If the estimate is wrong because stats are stale, it makes the wrong choice — run `ANALYZE <table>` after
|
|
23
|
+
bulk loads or large deletes to refresh them.
|
|
24
|
+
|
|
25
|
+
```sql
|
|
26
|
+
ANALYZE orders; -- refresh statistics
|
|
27
|
+
SELECT n_distinct, most_common_vals
|
|
28
|
+
FROM pg_stats WHERE tablename = 'orders' AND attname = 'status';
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Choosing the leading composite column
|
|
32
|
+
|
|
33
|
+
Lead with the **most selective equality column the query filters on**. For
|
|
34
|
+
`WHERE tenant_id = ? AND status = ?`:
|
|
35
|
+
|
|
36
|
+
- If a tenant has thousands of orders and `status` has 5 values, `tenant_id` is more selective → lead with
|
|
37
|
+
it: `(tenant_id, status)`.
|
|
38
|
+
- The leading column must be one the query constrains with equality (leftmost-prefix rule), so selectivity
|
|
39
|
+
is the tiebreaker among eligible equality columns, not a free choice.
|
|
40
|
+
|
|
41
|
+
## Skew and partial indexes
|
|
42
|
+
|
|
43
|
+
Selectivity can differ wildly **per value**. If `status = 'archived'` is 95% of rows but `status =
|
|
44
|
+
'pending'` is 0.1%, an index on `status` is useless for the former and gold for the latter. A **partial
|
|
45
|
+
index** targets exactly the selective slice and stays tiny:
|
|
46
|
+
|
|
47
|
+
```sql
|
|
48
|
+
CREATE INDEX ix_orders_pending
|
|
49
|
+
ON orders (tenant_id, created_at)
|
|
50
|
+
WHERE status = 'pending';
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This index only contains pending rows, so it's small, cheap to maintain, and the planner uses it whenever
|
|
54
|
+
the query includes `status = 'pending'`. Partial indexes are the standard fix for queue/worker tables
|
|
55
|
+
where you repeatedly scan for a rare state.
|
|
56
|
+
|
|
57
|
+
## Rules of thumb
|
|
58
|
+
|
|
59
|
+
- Unique or near-unique column in the predicate → index almost always wins.
|
|
60
|
+
- Predicate keeps more than ~10–20% of the table → index probably loses to a scan.
|
|
61
|
+
- Low-cardinality column → only useful inside a composite, or as a partial-index predicate.
|
|
62
|
+
- Re-`ANALYZE` after big data shifts; most "the index isn't being used" reports are stale statistics.
|