🚧 Under construction — VoTechWorks is being built. A free tech program — complimentary, as our commitment to get America working.
voTechWorksGrade 13 · a vocational technology learning center, by urbanicity
newSchoolWorks.com › VoTechWorks › Technology & Design › Data Management / Database Administration › PostgreSQL Administration › Lesson 5
TEC-DATA86-03 · Lesson 5 · 11 clock hours · demo

Indexing Strategy and Query Plan Analysis with EXPLAIN ANALYZE

Objective. Students select appropriate index types for given query patterns and use EXPLAIN ANALYZE output to measure and document query performance improvements.

⚠ 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.

Indexing Strategy and Query Plan Analysis with EXPLAIN ANALYZE

Why Indexing Decisions Matter

A PostgreSQL database serving hundreds of thousands of rows can respond in milliseconds or drag on for seconds — often the only difference is whether the right index exists. As a database administrator, choosing the correct index type for each query pattern is one of your highest-impact daily responsibilities. Poor indexing causes slow applications; over-indexing wastes disk space and slows write operations because every INSERT, UPDATE, and DELETE must maintain every index on the table. Your goal is a lean, purposeful set of indexes backed by evidence from query plans.

Reading an EXPLAIN ANALYZE Plan

The `EXPLAIN ANALYZE` command actually executes the query and returns a plan annotated with real timing and row counts alongside the planner's estimates. The output is a tree of nodes; execution flows from the innermost (leaf) node upward to the outermost (root) node.

Key fields to locate on every node:

A Seq Scan reads every page of the table. On a 500,000-row table this is expected and sometimes correct for queries returning a large fraction of rows, but it is a red flag for selective point lookups. An Index Scan or Index Only Scan navigates the index B-tree first and fetches only matching heap pages. A Bitmap Heap Scan combines results from a Bitmap Index Scan and is common for range queries.

Matching Index Types to Query Patterns

PostgreSQL ships with several index access methods. Choosing the right one is not optional — the wrong type simply will not be used by the planner.

| Query Pattern | Recommended Index Type | Notes |

|---|---|---|

| Equality on high-cardinality column | B-tree | Default; also supports range, ORDER BY |

| Equality on low-cardinality column | Partial B-tree or reconsider | Full B-tree rarely helps; planner may prefer Seq Scan |

| Full-text search (`@@` operator) | GIN on `tsvector` | GIN stores posting lists for fast keyword lookup |

| Range scan on a date/timestamp column with mostly sequential inserts | BRIN | Very small; effective when physical order correlates with column value |

| JSONB containment (`@>` operator) | GIN on the JSONB column | GIN indexes JSONB key-value pairs |

| Geometric / nearest-neighbor queries | GiST | Not covered in this lesson but good to know |

A B-tree index is the default when you write `CREATE INDEX` with no method clause. Specify others explicitly: `CREATE INDEX idx_name ON table USING gin(column);`

Before You Index: Run ANALYZE

The planner's cost estimates depend on table statistics stored in `pg_statistic`. If statistics are stale — for example, after a large bulk load — the planner may misjudge row counts and choose a bad plan regardless of what indexes exist. Always run `ANALYZE table_name;` before benchmarking or making indexing decisions. In production, `autovacuum` handles this automatically, but during a hands-on session with freshly loaded data you must run it manually.

This is a data-safety practice, not optional: decisions made on stale statistics can lead to index drops that hurt production performance.

Identifying and Removing Redundant Indexes

Every index on a table adds overhead to writes. PostgreSQL tracks index usage in the `pg_stat_user_indexes` view. The column `idx_scan` counts how many times the planner has chosen that index since statistics were last reset. An `idx_scan` value of zero after a representative workload period is a strong signal the index is unused.

```sql

SELECT schemaname, relname, indexrelname, idx_scan

FROM pg_stat_user_indexes

WHERE idx_scan = 0

ORDER BY relname;

```

Before dropping any index, document your reasoning:

1. Confirm statistics are current (`ANALYZE`).

2. Check whether the index is used during off-peak batch jobs not yet run.

3. Consider creating the index as `CONCURRENTLY` if you need to restore it on a live system.

4. Drop only after written justification is approved.

Drop syntax: `DROP INDEX CONCURRENTLY index_name;` — using `CONCURRENTLY` avoids locking the table in production.

Practical Safety Notes

Workflow Summary

1. Load data and run `ANALYZE`.

2. Run `EXPLAIN (ANALYZE, BUFFERS)` on the target query; record planning time and execution time.

3. Create the appropriate index type.

4. Run `EXPLAIN (ANALYZE, BUFFERS)` again on the same query.

5. Record the improvement in your index performance table.

6. Repeat for each scenario.

7. Use `pg_stat_user_indexes` to find zero-scan indexes, document justification, then drop with `CONCURRENTLY`.

Key terms10

EXPLAIN ANALYZEA PostgreSQL command that executes a query and returns the query execution plan annotated with actual runtime statistics, including measured time and actual row counts.
Sequential Scan (Seq Scan)An access method that reads every data page of a table from start to finish; appropriate when a large fraction of rows is returned but slow for selective lookups on large tables.
B-tree IndexThe default PostgreSQL index type; a balanced tree structure that efficiently supports equality, range, and ORDER BY operations on sortable data types.
GIN IndexGeneralized Inverted Index; stores posting lists mapping individual elements (lexemes, JSONB keys) to the rows that contain them; used for full-text search and JSONB containment queries.
BRIN IndexBlock Range Index; stores summary min/max values for ranges of physical table blocks; very small and efficient for columns whose values correlate with physical storage order, such as append-only timestamps.
pg_stat_user_indexesA PostgreSQL system view that reports index usage statistics, including idx_scan (the number of times an index has been used by the planner), useful for identifying unused or redundant indexes.
Planning TimeThe time PostgreSQL spends parsing, analyzing, rewriting, and planning a query before execution begins; reported at the bottom of an EXPLAIN ANALYZE output in milliseconds.
Buffers (shared hit / shared read)Reported in EXPLAIN (ANALYZE, BUFFERS); shared hits are pages served from PostgreSQL's shared buffer cache, while shared reads are pages fetched from disk; fewer reads indicate better cache utilization.
Index Only ScanAn access method where PostgreSQL satisfies a query entirely from the index without visiting the heap table pages, possible when all needed columns are included in the index and the visibility map is current.
ANALYZE (statement)A PostgreSQL command that collects statistics about table contents and stores them in pg_statistic so the query planner can make accurate cost estimates; should be run after bulk data loads.

Worked examples4

1. An EXPLAIN ANALYZE output shows: 'Seq Scan on orders (cost=0.00..18450.00 rows=500000 width=64) (actual time=0.042..312.5 rows=500000 loops=1)' and at the bottom 'Execution Time: 315.2 ms'. After creating a B-tree index on the order_date column and filtering for a single day, the new plan shows 'Index Scan using idx_orders_date on orders (cost=0.42..8.45 rows=1370 width=64) (actual time=0.018..1.2 rows=1380 loops=1)' and 'Execution Time: 1.8 ms'. Calculate the execution time reduction as a percentage.
  1. Step 1: Identify before-index execution time: 315.2 ms.
  2. Step 2: Identify after-index execution time: 1.8 ms.
  3. Step 3: Calculate reduction: 315.2 − 1.8 = 313.4 ms saved.
  4. Step 4: Calculate percentage reduction: (313.4 ÷ 315.2) × 100 = 99.43%.
Answer: Execution time improved by approximately 313.4 ms, a reduction of about 99.4%.
2. You run pg_stat_user_indexes and see idx_scan = 0 for an index named idx_products_sku. The table has 80,000 rows. Before deciding to drop it, you must check whether table statistics are current. The pg_stat_user_tables view shows last_analyze was 47 days ago and a bulk import added 60,000 rows last night. How many rows are now in the table, and what must you do before trusting idx_scan = 0?
  1. Step 1: Calculate current row count: 80,000 original + 60,000 imported = 140,000 rows.
  2. Step 2: Recognize that last_analyze reflects state before the bulk import; statistics are stale.
  3. Step 3: Run 'ANALYZE products;' to update statistics so the planner uses accurate row counts.
  4. Step 4: After ANALYZE, reset pg_stat_user_indexes statistics if desired (pg_stat_reset_single_table_counts), or allow a representative workload to run, then re-check idx_scan before making the drop decision.
Answer: The table now has 140,000 rows; you must run ANALYZE on the table before trusting the idx_scan = 0 value, because stale statistics may have caused the planner to avoid the index even when it would have been beneficial.
3. A BRIN index is created on an event_log table's logged_at timestamp column. The table has 1,200,000 rows and the BRIN index uses a pages_per_range of 128 (the default). How many block range summaries does the index store, assuming each table page holds exactly 100 rows?
  1. Step 1: Calculate total table pages: 1,200,000 rows ÷ 100 rows per page = 12,000 pages.
  2. Step 2: Calculate number of BRIN ranges: 12,000 pages ÷ 128 pages per range = 93.75, rounded up to 94 ranges.
  3. Step 3: The BRIN index stores 94 range summaries, each containing the min and max logged_at value for its block range.
Answer: The BRIN index stores 94 block range summaries.
4. Before indexing, a full-text search query has planning time = 0.8 ms and execution time = 4,200 ms. After adding a GIN index on the tsvector column, planning time = 1.1 ms and execution time = 22 ms. What is the total query time (planning + execution) before and after, and what is the overall time saved?
  1. Step 1: Total time before: 0.8 + 4,200 = 4,200.8 ms.
  2. Step 2: Total time after: 1.1 + 22 = 23.1 ms.
  3. Step 3: Time saved: 4,200.8 − 23.1 = 4,177.7 ms.
  4. Step 4: Note that planning time increased slightly (by 0.3 ms) because the planner now evaluates the index path, but this is vastly outweighed by the execution savings.
Answer: Total time before: 4,200.8 ms; total time after: 23.1 ms; overall time saved: approximately 4,177.7 ms.

The math in this lesson1

Learners compute milliseconds saved and percent reduction in query time after indexing.
7.RP.3
Brush up in the math halls at newSchoolWorks.com:
In class: Instructor demonstrates reading an EXPLAIN ANALYZE plan for a sequential scan vs. an index scan on a 500,000-row table; students annotate a printed plan identifying cost, actual rows, and buffers, then replicate the demo on their own VMs. · Given four query scenarios (equality search on a low-cardinality column, full-text search, range scan on a date column, JSONB containment query), students create B-tree, GIN, BRIN, and GIN indexes respectively, run EXPLAIN ANALYZE before and after each index, and record the planning and execution time reduction in an index performance table. · Students identify and drop two redundant indexes on a provided schema using pg_stat_user_indexes (idx_scan = 0 heuristic), document the decision in a written justification, and verify the table statistics are current using ANALYZE before making the decision.
Materials: PostgreSQL 16 VM, pgbench (for data generation), EXPLAIN ANALYZE output printer-friendly format, index performance table template, PostgreSQL Documentation Chapter 11 (Indexes) and Chapter 14 (Performance Tips)
Check: Instructor reviews index performance table for all four scenarios and checks that EXPLAIN plans are attached; pass = documented planning-time or execution-time improvement for each index AND written justification for dropped indexes is logically sound.

Practice check8

Answer each question, then check it. Nothing is recorded; this is your own practice.

PostgreSQL CE — Indexing and Performance
1. A query uses `WHERE document_vector @@ to_tsquery('database')` on a 600,000-row table. Which index type should you create to support this query pattern?
PostgreSQL CE — Query Plan Analysis
2. After running EXPLAIN (ANALYZE, BUFFERS) you see 'shared read=8200' on a sequential scan node. What does this indicate?
PostgreSQL CE — Database Administration
3. You must drop an unused index on a busy production table with minimal disruption. Which syntax is safest?
PostgreSQL CE — Monitoring and Maintenance
4. Which system view would you query to find indexes with zero planner usage since statistics were last reset?
PostgreSQL CE — Query Plan Analysis
5. You are benchmarking a query before and after adding a B-tree index. The EXPLAIN ANALYZE output shows actual rows = 3 but estimated rows = 45,000. What is the most likely cause of this large discrepancy?
PostgreSQL CE — Indexing and Performance
6. A table logs IoT sensor readings and new rows are always appended in timestamp order. Queries filter on a timestamp range. Which index type is best suited to minimize index size while supporting this access pattern?
PostgreSQL CE — Database Administration
7. Before making any indexing decisions on a table that received a large overnight bulk load, a DBA must:
PostgreSQL CE — Query Plan Analysis
8. When using EXPLAIN ANALYZE to measure the effect of a new index on an UPDATE statement in a lab environment without committing the data change, which approach is correct?
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.