TEC-DATA85-04 · Lesson 7 · 11 clock hours · practice
Query Optimization and Indexing
Objective. Students interpret EXPLAIN ANALYZE output to identify sequential scans on large tables and add B-tree and partial indexes that measurably reduce query execution time.
⚠ Pending professional review
This lesson, its worked examples and its practice questions has not yet been verified by an industry-certified professional or by a certification or approval body. It is a study draft and may contain errors. Do not use it as instructions for work on a job site or on live equipment. Always follow your instructor, your supervising professional, and current official references and regulations.
VoTechWorks program approval and accreditation are still in progress. Credentials are issued only by the certifying bodies named in each program, after their own exams.
Why Query Optimization Matters
When a table holds millions of rows, a poorly written or poorly supported query can take minutes instead of milliseconds. In production data pipelines, slow queries waste cloud compute dollars, delay dashboards, and frustrate stakeholders. Query optimization is the skill of reading what PostgreSQL actually does with a query and changing the table structure or the query itself so the database does less work.
The two main tools in this lesson are EXPLAIN ANALYZE and indexes.
Reading EXPLAIN ANALYZE Output
`EXPLAIN ANALYZE` runs the query and returns the execution plan PostgreSQL chose, along with actual timing measured during that run. The key syntax is:
```sql
EXPLAIN ANALYZE SELECT * FROM health_records WHERE county = 'Alameda';
```
The output is a tree of nodes. Each node represents one operation — a scan, a join, a sort, an aggregate. Read the tree from the innermost (most-indented) node outward; PostgreSQL executes inner nodes first.
For each node you will see:
- cost=start..total — the planner's estimate of work units (not seconds).
- actual time=start..end — real elapsed milliseconds for that node.
- rows — how many rows the node returned.
- loops — how many times the node executed.
The costliest node is usually the one with the highest `actual time` total or the one labeled Seq Scan on a large table. A sequential scan reads every page of the table regardless of how many rows match — fine for small tables, expensive for millions of rows.
Recognizing a Problem
Look for these warning signs in the plan:
- `Seq Scan` on a table with many rows and a low `rows` return count — the database read everything to find a few rows.
- A large gap between planner `rows` estimate and actual `rows` — stale statistics, solvable with `ANALYZE`.
- High `actual time` on a node that feeds a join — that bottleneck multiplies cost.
Indexes: What They Are and How They Help
An index is a separate data structure that maps column values to the physical locations of matching rows. Instead of reading every page, PostgreSQL jumps directly to the rows it needs.
B-tree Indexes
The default index type in PostgreSQL is the B-tree (balanced tree). It supports equality (`=`) and range comparisons (`<`, `>`, `BETWEEN`). Create one with:
```sql
CREATE INDEX idx_county ON health_records (county);
```
After creation, run `EXPLAIN ANALYZE` again. You should see the `Seq Scan` replaced by an Index Scan or Bitmap Index Scan, and the actual time should drop sharply.
Partial Indexes
A partial index covers only rows that satisfy a `WHERE` condition. If 95 % of queries filter on active records only, indexing just those rows keeps the index small and fast:
```sql
CREATE INDEX idx_active_county
ON health_records (county)
WHERE status = 'active';
```
Partial indexes are useful when a column has a skewed distribution — most values are one thing, but you always query for the rare value.
When Indexes Do Not Help
An index can be ignored by the planner when:
- Selectivity is low — if a query returns more than roughly 5–10 % of all rows, PostgreSQL may decide a sequential scan is faster because random page reads from an index can exceed the cost of a single sequential pass.
- The table is small — the overhead of an index lookup outweighs reading the whole table.
- The column is cast or wrapped in a function — `WHERE UPPER(county) = 'ALAMEDA'` cannot use an index on `county` unless a functional index exists.
- Statistics are stale — run `ANALYZE health_records;` to refresh the planner's row estimates.
Adding too many indexes also slows `INSERT`, `UPDATE`, and `DELETE` operations because each index must be maintained. Index only columns that appear in frequent, selective `WHERE` clauses or join conditions.
Reading the Comparison Table
Your lab notebook table records four columns for each query: query ID, plan node before, actual time before (ms), and actual time after (ms). Calculate the speedup ratio as before ÷ after. A ratio of 10× or greater is common when replacing a sequential scan on a million-row table with a B-tree index scan.
Data Accuracy and Integrity
Record the numbers you actually observe. Do not round aggressively or copy a classmate's timings — execution times vary by machine load, and your instructor will check that results are plausible for your hardware. Fabricating benchmark results is academic dishonesty and also a serious professional ethics violation; in industry, false performance claims can lead to bad infrastructure decisions and cost overruns.
Workplace Safety Note
This lesson runs entirely in software on a local or lab PostgreSQL instance loaded with open, de-identified public data. No personal health information (PHI) is used. When you work with real health data in your career, you must follow your organization's data governance policy, HIPAA requirements where applicable, and any data-use agreements. Never run untested bulk operations — including index builds — on production databases without a maintenance window and a verified backup. Large index builds can lock tables and interrupt live applications.
Key terms9
EXPLAIN ANALYZEA PostgreSQL command that both executes a query and returns the query execution plan annotated with actual timing and row counts, allowing you to see exactly where time is spent.
Sequential Scan (Seq Scan)A plan node in which PostgreSQL reads every page of a table from start to finish, regardless of how many rows match the query condition; efficient for small tables or low-selectivity queries, but slow on large tables.
B-tree IndexPostgreSQL's default index type, based on a balanced tree structure, that supports equality and range comparisons and allows the planner to jump directly to matching rows instead of scanning the whole table.
Partial IndexAn index built only on the subset of rows that satisfy a specified WHERE condition, keeping the index small and fast when queries consistently filter on a predictable value.
Index ScanA plan node in which PostgreSQL uses an index to locate matching rows, typically much faster than a sequential scan when the query is highly selective.
SelectivityThe fraction of table rows a query condition matches; high selectivity means few rows match (good for index use), while low selectivity means many rows match (index may be ignored).
Query Execution PlanThe internal step-by-step strategy PostgreSQL's planner chooses to execute a query, represented as a tree of nodes each describing one operation such as a scan, join, or sort.
Bitmap Index ScanA two-step plan node that first reads the index to build a bitmap of matching page locations, then fetches those pages; useful when multiple rows per page match, bridging index and sequential scan strategies.
ANALYZEA PostgreSQL command that collects statistics about table contents and updates the planner's estimates, helping the optimizer choose better execution plans.
Worked examples4
1. A query takes 4,200 ms before adding an index and 38 ms after. Calculate the speedup ratio and express it as a multiplier rounded to one decimal place.
- Identify before time: 4,200 ms.
- Identify after time: 38 ms.
- Divide before by after: 4,200 ÷ 38 = 110.526…
- Round to one decimal place: 110.5.
Answer: The speedup ratio is approximately 110.5×, meaning the indexed query runs about 110 times faster.
2. You have a table with 1,000,000 rows. A query using WHERE status = 'closed' returns 920,000 rows. Should you create a B-tree index on status? Explain using selectivity.
- Calculate selectivity: rows returned ÷ total rows = 920,000 ÷ 1,000,000 = 0.92, or 92%.
- Recall the guideline: indexes generally help when selectivity is high — meaning the query returns a small fraction of rows, roughly under 5–10%.
- At 92% selectivity, the query matches almost every row. PostgreSQL would need to visit nearly every index entry and then fetch nearly every table page anyway.
- A sequential scan reads table pages in order, which is cheaper than the random I/O pattern of an index scan when most pages must be visited.
- Conclusion: an index on status is unlikely to help here and may be ignored by the planner.
Answer: No. Because 92% of rows match, selectivity is too low. The planner will likely choose a sequential scan, and the index would not reduce execution time.
3. Before optimization, Query 3 has an actual time of 3,850 ms. After adding a partial index, it drops to 210 ms. What is the time saved in seconds, and what percentage reduction does that represent? Round percentage to one decimal place.
- Calculate time saved in milliseconds: 3,850 − 210 = 3,640 ms.
- Convert to seconds: 3,640 ÷ 1,000 = 3.64 seconds saved.
- Calculate percentage reduction: (3,640 ÷ 3,850) × 100 = 94.545…%.
- Round to one decimal place: 94.5%.
Answer: The query is 3.64 seconds faster, a 94.5% reduction in execution time.
4. Your comparison table shows five queries. Execution times before and after (in ms) are: Q1 800→45, Q2 5,200→4,980, Q3 3,100→120, Q4 900→30, Q5 2,400→88. Which queries show a speedup ratio greater than 10×, and do you meet the pass requirement of four of five showing measurable improvement?
- Calculate each ratio (before ÷ after): Q1: 800÷45 ≈ 17.8×; Q2: 5,200÷4,980 ≈ 1.04×; Q3: 3,100÷120 ≈ 25.8×; Q4: 900÷30 = 30.0×; Q5: 2,400÷88 ≈ 27.3×.
- Identify which exceed 10×: Q1 (17.8×), Q3 (25.8×), Q4 (30.0×), Q5 (27.3×) all exceed 10×. Q2 (1.04×) shows almost no improvement.
- Count queries with measurable improvement: Q1, Q3, Q4, Q5 — that is four queries.
- Compare to pass requirement: four of five correct is exactly the minimum needed to pass.
Answer: Q1, Q3, Q4, and Q5 show speedup ratios greater than 10×. Q2 shows almost no improvement, likely because the index was not used (low selectivity or wrong column). Four of five meet the threshold, so the pass requirement is exactly met.
The math in this lesson1
Learners compute a query speedup ratio from before and after execution times.
6.RP.3
In class: Slow-query diagnosis: students run five pre-written slow queries against a 1-million-row public health dataset, capture EXPLAIN ANALYZE output, and annotate each plan identifying the costliest node. · Index creation lab: students add appropriate indexes for each slow query, re-run EXPLAIN ANALYZE, and record before-and-after execution times in a comparison table in their lab notebook. · Class discussion: pairs share one case where an index did not help (or caused a table-scan anyway due to low selectivity) and explain the reason; instructor synthesizes findings into a short whiteboard summary of index selection guidelines.
Materials: 1-million-row public health CSV loaded into PostgreSQL (instructor-prepared from CDC open data), EXPLAIN ANALYZE cheat-sheet (instructor-prepared), lab notebook comparison table template
Check: Comparison table must show measurable execution-time improvement for at least four of five queries, with correct identification of the plan node that changed; instructor verifies numbers are from actual query runs, not fabricated; pass requires four of five correct.
Practice check8
Answer each question, then check it. Nothing is recorded; this is your own practice.
Record this lesson on my transcript. Sign in with your key and enrollment email to record lessons (enroll free if you have no key). This page stays open to read and practise.