Module 5: Query Profiling in Production
Project: profiling the bookstore in production
What are you going to build and why?
You reached the end of module 5 with six capsules of theory and tools. You know how to enable pg_stat_statements, read the four canonical queries, capture plans with auto_explain, identify blocking with pg_stat_activity, and choose external tooling if you need it. But all of that is separate skills. In production you never run just one — you always combine them into an integrated workflow.
This project asks you to do exactly that. You're going to take the baseline bookstore (the same one you've been using since module 1, with known N+1s, heavy queries, and pre-seeded problems), generate representative synthetic load, and apply the complete profiling workflow: setup → load → top queries → plans → prioritization → action plan.
The deliverable is a structured report (PROFILING-REPORT.md) that identifies the 3 most expensive queries in the system, analyzes them with captured plans, and proposes a prioritized fix plan. Each identified query is connected with the corresponding module of the guide (index → module 3, eager loading → module 4, pool → module 6, anti-pattern → module 8).
Why this project matters:
It's the dress rehearsal for module 8's final project, where you'll do the same thing but on a "deliberately broken" version of the bookstore with additional problems you don't anticipate. Here you practice the methodology on familiar territory. If it doesn't come out fluid in this project, it won't come out in module 8's or in the workplace.
Project objective
By completing this project, you'll have:
- Applied the complete profiling workflow: reset → load → capture → prioritization → report.
- Identified the 3 most expensive bookstore queries by
total_exec_timewith a percentage over the total. - Captured the execution plans of those 3 queries with
auto_explain. - Prioritized the fixes using the
potential_improvement × frequencyformula. - Connected each problematic query with the module of the guide that covers its solution.
- Produced a
PROFILING-REPORT.mdwith tables, plans, documented decisions.
This report is portfolio-worthy: it demonstrates your ability to operate PostgreSQL profiling end to end. It's useful to show in senior interviews and as a template for your future real profilings.
How it fits into what you learned
This project integrates the concepts from the six previous capsules:
| Capsule | Concept | How it's used in the project |
|---|---|---|
| 02 | pg_stat_statements setup + reset | Initial setup + reset before measuring |
| 03 | The 4 canonical queries | Identify top queries by 4 dimensions |
| 04 | auto_explain and plan capture | Capture the plans of the problematic queries |
| 05 | Slow query log | Verify which queries passed the threshold during load |
| 06 | pg_stat_activity and blocking | Validate that there's no problematic locking during load |
| 07 | External tooling | Decide whether SaaS, self-hosted, or built-in is worth it for your team |
Think of the project as building a forensic case: each tool gives you a different piece of evidence, and your job is to integrate them into a coherent argument about where the problem is and what to do.
Technical specifications
Stack
- PostgreSQL 16 (with
pg_stat_statementsandauto_explainenabled). - Baseline bookstore (the one you've had running since module 1).
wrkorlocustfor synthetic load (module 1).psqlfor profiling queries.- A text editor for the report (
PROFILING-REPORT.md).
Prerequisites
Before you start, verify:
- The bookstore runs locally and responds to requests (
curl http://localhost:8000/health). - PostgreSQL has
pg_stat_statementsactive:SELECT count(*) FROM pg_stat_statements;returns >0. -
auto_explainis loaded:shared_preload_librariescontainsauto_explain. - Slow query log active:
SHOW log_min_duration_statement;returns a value (e.g.:100ms). -
wrkinstalled:wrk --versionworks. - The database has seeded data (at least 100k books, 500k reviews, 50k orders).
If any of these fail, go back to the corresponding capsule to resolve it.
Recommended PostgreSQL configuration for the project
Your pg-config/postgresql.conf must have at least:
listen_addresses = '*'
max_connections = 100
shared_preload_libraries = 'pg_stat_statements,auto_explain'
# pg_stat_statements
pg_stat_statements.track = 'all'
pg_stat_statements.max = 10000
pg_stat_statements.save = on
# auto_explain
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_format = 'text'
# Slow query log
log_min_duration_statement = '100ms'
log_line_prefix = '%m [%p] %q%u@%d (app=%a) '
If your config has this, restart the container and continue. If not, add what's missing and restart.
Required features
1. Documented initial setup
Create the project directory:
mkdir -p ~/projects/bookstore-baseline/profiling/module-05
cd ~/projects/bookstore-baseline/profiling/module-05
touch PROFILING-REPORT.md
The PROFILING-REPORT.md must include as an initial section:
- PostgreSQL version (
SELECT version();). - Relevant configuration (
SHOW pg_stat_statements.track;,SHOW auto_explain.log_min_duration;,SHOW log_min_duration_statement;). - Number of rows in the main tables (books, authors, reviews, orders).
- Endpoints you're going to load (a list with a description).
wrkconfiguration (RPS, duration, threads, connections).
2. Reset statistics + generate load
Before the real load, reset pg_stat_statements and capture the initial timestamp:
# Timestamp
echo "Load started: $(date -u)" >> PROFILING-REPORT.md
# Reset
docker exec bookstore-pg psql -U bookstore -d bookstore -c \
"SELECT pg_stat_statements_reset();"
Synthetic load with wrk (adjust to the bookstore's real endpoints):
# Endpoint 1: list of books filtered by author (N+1 suspicion)
wrk -t4 -c20 -d2m \
"http://localhost:8000/books-with-author?author_name=tolkien" \
>> wrk-results.txt
# Endpoint 2: deep pagination (large OFFSET suspicion)
wrk -t4 -c20 -d2m \
"http://localhost:8000/orders?page=100" \
>> wrk-results.txt
# Endpoint 3: aggregated stats (slow COUNT suspicion)
wrk -t4 -c20 -d2m \
"http://localhost:8000/stats/total-sales" \
>> wrk-results.txt
# Endpoint 4: text search (Seq Scan without GIN index suspicion)
wrk -t4 -c20 -d2m \
"http://localhost:8000/search?q=hobbit" \
>> wrk-results.txt
Total: ~8 minutes of load distributed across known baseline endpoints.
3. Identify the top 3 queries by total_exec_time
Use the canonical query from capsule 03:
SELECT
queryid,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((100.0 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct_total,
rows,
left(query, 300) AS query
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
AND query NOT LIKE '%pg_catalog%'
AND query NOT LIKE 'BEGIN%'
AND query NOT LIKE 'COMMIT%'
ORDER BY total_exec_time DESC
LIMIT 10;
Document the result in the report as a table. Identify the top 3 you're going to investigate in depth.
4. Cross-reference with the other 3 dimensions
Also run the query by mean_exec_time, by calls, and by shared_blks_read (capsule 03). Document the outputs.
For each of the top 3 queries, note:
- In which dimension does it appear (total_exec_time, mean, calls, shared_blks_read)? It can appear in several.
- What kind of problem does that dimension suggest?
5. Capture the execution plan of each top query
For each top 3, run EXPLAIN (ANALYZE, BUFFERS) manually with representative values:
EXPLAIN (ANALYZE, BUFFERS)
SELECT b.id, b.title, b.author_id
FROM books b
WHERE b.author_id = 5; -- representative value
Plus, search the log to see if auto_explain captured the plan automatically during the load:
docker logs bookstore-pg 2>&1 | grep -B 1 -A 50 "books WHERE author_id" \
> captured-plans.txt
Paste the plan into the report.
6. Prioritize fixes with the impact formula
For each top 3 query, calculate:
estimated_impact = (current_mean - estimated_mean_post_fix) × calls
If the fix is to eliminate an N+1, the calculation is different:
estimated_impact = current_mean × (current_calls - estimated_calls_post_fix)
Document in a table in the report:
| Query | current mean | estimated mean post-fix | current calls | estimated calls post-fix | estimated impact | Fix type | Module |
|---|---|---|---|---|---|---|---|
| #1 | 8 ms | 1 ms | 4,250 | 4,250 | 29,750 ms | Index | Module 3 |
| #2 | 850 ms | 80 ms | 180 | 180 | 138,600 ms | Rewrite | Module 8 |
| #3 | 1.07 ms | 1.07 ms | 8,500 | 85 | 8,995 ms | Eager loading | Module 4 |
Justify each estimate with 1-2 sentences.
7. Prioritized action plan
Document the order of attack, justified:
- Query #X first because it has the highest estimated impact and the lowest effort.
- Query #Y second because...
- Query #Z third because...
For each fix, indicate:
- The concrete action (the index SQL, code refactor with eager loading, etc.).
- How you'll validate that it worked (reset stats, repeat load, compare mean).
- Time estimate (hours).
8. Post-fix verification (optional but recommended)
Apply at least one of the proposed fixes (typically the easiest — an index). Reset pg_stat_statements, repeat the load, compare before/after. Document in the report the real measured improvement vs the estimated one.
Validations and error handling
What must be in the report (checklist)
- PostgreSQL version and relevant configuration documented.
- Number of rows in the main tables.
-
wrkconfiguration (threads, connections, duration, observed RPS). - Output of the 4 canonical queries to
pg_stat_statements. - Clear identification of the top 3 queries with
pct_total. - Complete execution plan of each top 3 query (with
Buffers). - Prioritization table with the impact formula.
- Ordered and justified action plan.
- (Optional) Post-fix verification of at least 1 query.
Errors that may appear during the project
Error 1: pg_stat_statements is empty after the load.
Cause: the extension isn't active or pg_stat_statements.track is at none.
Fix: review capsule 02 (setup) and verify:
SHOW shared_preload_libraries; -- must contain pg_stat_statements
SHOW pg_stat_statements.track; -- must be 'all' or 'top'
Error 2: auto_explain captures nothing in the log.
Cause: shared_preload_libraries doesn't include auto_explain, or log_min_duration is too high.
Fix: review capsule 04 and verify:
docker logs bookstore-pg 2>&1 | grep "auto_explain"
If there are no lines, the module isn't loaded.
Error 3: wrk reports many connection errors.
Cause: the app's pool saturated. This in itself is an important finding (module 6 covers it), but for this project lower -c20 to -c10 so wrk completes without saturating.
Error 4: the database doesn't have enough data for the queries to be slow.
Cause: the baseline bookstore is in minimal setup, not with a complete seed.
Fix: run the complete seed script from module 1 (it should seed 100k+ books, 500k+ reviews).
Minimal implementation example
Below, a skeleton of the PROFILING-REPORT.md with marked sections. You'll fill it in with your real data.
# Profiling Report — Bookstore Baseline (Module 5)
## Setup
**PostgreSQL version:** 16.2 (Docker: postgres:16)
**Date:** 2026-05-02
**Hardware:** Mac M2, 16GB RAM (Docker container with 4GB assigned)
**Relevant configuration:**
- `pg_stat_statements.track = all`
- `auto_explain.log_min_duration = 500ms`
- `log_min_duration_statement = 100ms`
**Database size:**
| Table | Rows |
|-------|-------|
| books | 120,000 |
| authors | 8,000 |
| reviews | 540,000 |
| orders | 50,000 |
| order_items | 230,000 |
**Loaded endpoints:**
1. `/books-with-author?author_name=tolkien` (N+1 suspicion)
2. `/orders?page=100` (large OFFSET suspicion)
3. `/stats/total-sales` (slow COUNT suspicion)
4. `/search?q=hobbit` (Seq Scan suspicion)
**wrk configuration:**
```bash
wrk -t4 -c20 -d2m <endpoint>
wrk results (summarized):
| Endpoint | Observed RPS | p50 | p95 | p99 |
|---|---|---|---|---|
| /books-with-author | 45 | 220ms | 850ms | 1,200ms |
| /orders?page=100 | 18 | 1.1s | 2.3s | 3.5s |
| /stats/total-sales | 12 | 1.5s | 2.8s | 4.2s |
| /search?q=hobbit | 8 | 1.8s | 3.5s | 5.0s |
Top queries identified
By total_exec_time (aggregate impact)
| # | calls | total_ms | mean_ms | pct_total | query |
|---|---|---|---|---|---|
| 1 | 4,250 | 38,500.20 | 9.06 | 42.30% | SELECT b.id, b.title, b.author_id FROM books b WHERE b.author_id = $1 |
| 2 | 180 | 24,300.50 | 135.00 | 26.70% | SELECT b.*, count(r.id) FROM books b LEFT JOIN reviews r ON ... GROUP BY ... ORDER BY count DESC LIMIT $1 |
| 3 | 12,500 | 10,625.00 | 0.85 | 11.67% | SELECT * FROM authors WHERE id = $1 |
| ... |
By mean_exec_time (unit slowness)
[similar table]
By calls (volume, possible N+1)
[similar table]
By shared_blks_read (working set out of cache)
[similar table]
Analysis of the top 3 queries
Query #1: SELECT books WHERE author_id = $1
Metrics:
- Calls: 4,250
- mean_exec_time: 9.06 ms
- total_exec_time: 38,500.20 ms
- pct_total: 42.30%
- Also appears in top calls (volume)
Captured plan (auto_explain or manual EXPLAIN):
Index Scan using idx_books_author_id on books b (cost=0.42..120.00 rows=1700 width=20) (actual time=0.025..6.500 rows=1700 loops=1)
Index Cond: (author_id = $1)
Buffers: shared hit=2000 read=300
Planning Time: 0.450 ms
Execution Time: 7.20 ms
Diagnosis:
The query is individually well optimized — it uses an Index Scan and takes 7ms for 1,700 books. The problem is NOT the query: it's that it runs 4,250 times, a clear hint of a hidden N+1 in some endpoint.
N+1 hypothesis:
The /books-with-author?author_name=tolkien endpoint probably loads the list of the author's books and for each book accesses book.author lazily, which generates another query to authors for each book. Cross-referencing with query #3 (12,500 calls to authors WHERE id = $1), it fits: 4,250 books × ~3 (the top author's books repeated across different endpoints) ≈ 12,500.
Fix type: Eager loading (joinedload or selectinload).
Module: Module 4 (N+1 with SQLAlchemy).
Query #2: SELECT books JOIN reviews COUNT GROUP BY ...
[similar analysis]
Query #3: SELECT authors WHERE id = $1
[similar analysis — confirms N+1]
Prioritization (impact formula)
| # | Query | current mean | mean post-fix | current calls | calls post-fix | Estimated impact | Fix type | Effort | Module |
|---|---|---|---|---|---|---|---|---|---|
| 1 | SELECT books WHERE author_id | 9 ms | 9 ms | 4,250 | 50 | 37,800 ms (37.8s) | Eager loading | 1-2 hours | Module 4 |
| 2 | SELECT books JOIN reviews | 135 ms | 25 ms | 180 | 180 | 19,800 ms (19.8s) | Covering index + rewrite | 3-4 hours | Modules 3 and 8 |
| 3 | SELECT authors WHERE id | 0.85 ms | 0.85 ms | 12,500 | 50 | 10,582 ms (10.6s) | Same eager loading as #1 | (included in #1) | Module 4 |
Justifications:
- Query #1 leads by aggregate impact (37.8s) and low effort. Eager loading is applied in 1-2 hours. It also resolves query #3 at the same time (it's the same N+1).
- Query #2 has lower absolute impact (19.8s) but higher effort (3-4 hours) because it requires an index + rewrite. Attack it after #1.
- Query #3 is resolved "for free" as a side effect of fixing #1.
Action plan
Sprint 1 (this week):
- Apply eager loading to the
/books-with-authorendpoint:
from sqlalchemy.orm import selectinload
stmt = (
select(Book)
.where(Book.author.has(Author.name == author_name))
.options(selectinload(Book.author))
)
- Validate the fix:
SELECT pg_stat_statements_reset();- Repeat the endpoint's wrk.
- Confirm that query #1 drops from 4,250 to ~50 calls (1 per request).
- Confirm that query #3 drops from 12,500 to ~50 calls.
Sprint 2 (next week):
- Create a covering index for query #2:
CREATE INDEX idx_reviews_book_id_includes ON reviews(book_id) INCLUDE (id);
-
Consider a materialized view if the query is popular in dashboards.
-
Validate the fix similarly to the previous one.
Post-fix verification (Sprint 1)
[If you applied the fix, document the real measured improvement]
Before:
| Query | calls | total_ms |
|---|---|---|
| books WHERE author_id | 4,250 | 38,500 |
| authors WHERE id | 12,500 | 10,625 |
| Combined total | 16,750 | 49,125 |
After (with eager loading):
| Query | calls | total_ms |
|---|---|---|
| books WHERE author_id | 50 | 450 |
| authors WHERE id | 50 | 42 |
| Combined total | 100 | 492 |
Real improvement: 99% reduction in calls, 99% reduction in total_exec_time. Exceeds the estimate.
Conclusions
- The hidden N+1 in
/books-with-authorrepresents 54% of the DB's aggregate time (queries #1 + #3 combined). Its fix with eager loading is the highest optimization ROI of the bookstore. - Query #2 (books with review count) is a secondary candidate; it benefits from a combination of an index + a possible materialized view.
- The project demonstrated that the integrated workflow (
pg_stat_statements+auto_explain+ prioritized analysis) identifies problems in minutes that without the tools would take hours or days.
Next steps
- Apply the eager loading fix in the next sprint.
- Configure a
pg_stat_statementsalert to detect regressions (if query #1 returns to the top with high calls, there's a new N+1). - Consider pgwatch2 if we reach 5+ instances.
Report generated for module 5 — Database Performance & Query Tuning Guide
---
## Evaluation rubric (self-check)
Mark each item as you complete it. Total of 100 points. **Passing: 70+.**
### Setup and load (20 points)
- [ ] (5 pts) Setup of `pg_stat_statements`, `auto_explain`, and the slow query log verified.
- [ ] (5 pts) Database size documented (rows in the main tables).
- [ ] (5 pts) Chosen endpoints relevant (at least 3 with known baseline problems).
- [ ] (5 pts) Load run with `wrk` with documented configuration (threads, connections, duration).
### Top queries identification (25 points)
- [ ] (10 pts) The 4 canonical queries to `pg_stat_statements` run and documented.
- [ ] (10 pts) Top 3 queries by `total_exec_time` clearly identified with `pct_total`.
- [ ] (5 pts) Cross-referencing with other dimensions (mean, calls, shared_blks_read) done.
### Plan analysis (20 points)
- [ ] (10 pts) Complete plan of each top 3 query included (with `Buffers`).
- [ ] (10 pts) Each plan has a written diagnosis (what the bottleneck is).
### Prioritization (15 points)
- [ ] (5 pts) Impact formula applied with justified values.
- [ ] (5 pts) Clear prioritization table with the fix type.
- [ ] (5 pts) Each query connected with the corresponding module of the guide.
### Action plan (15 points)
- [ ] (5 pts) Plan ordered by impact/effort, not by intuition.
- [ ] (5 pts) Concrete action for each fix (the index SQL, eager loading snippet, etc.).
- [ ] (5 pts) Validation method documented.
### Report (5 points)
- [ ] (5 pts) `PROFILING-REPORT.md` complete, readable, with formatted tables.
### Extra credit (up to +15 pts)
- [ ] (+5 pts) Apply at least 1 fix and document the real measured improvement vs the estimated one.
- [ ] (+5 pts) Identify at least 1 additional unexpected problematic pattern (e.g.: a query with a very high stddev, idle_in_tx during the load).
- [ ] (+5 pts) Configure postgres_exporter + a basic Grafana dashboard (capsule 07).
---
## Common mistakes in this project
### Mistake 1: forgetting to reset `pg_stat_statements` before the load
**Symptom:** the project's top queries include noise from previous loads, making analysis harder.
**Why it happens:** the statistics are cumulative. Without a reset, you mix your load with hours of previous activity.
**How to fix it:** run `SELECT pg_stat_statements_reset();` right before starting `wrk`. Document the moment of the reset in the report.
### Mistake 2: loading only 1 endpoint and reporting it as representative
**Symptom:** the report shows queries from only 1 endpoint. The conclusion "this is the bookstore's problem" is misleading.
**Why it happens:** a very specific load produces a biased diagnosis. Real production has a mix of endpoints.
**How to fix it:** load at least 3-4 different endpoints in realistic proportions (more traffic to search than to admin, for example).
### Mistake 3: capturing the plan with non-representative values
**Symptom:** you run `EXPLAIN ANALYZE SELECT ... WHERE author_id = 1` but `author_id = 1` has no books in the database, so the plan is trivial.
**Why it happens:** non-representative test values lead to plans that don't reflect the real case.
**How to fix it:** use values you know return real data (authors with many books, dates with many orders, etc.). Ideally, pull the value from the `auto_explain` log where the query was slow — that value is the real one that triggered the problem.
### Mistake 4: confusing estimated improvement with real improvement
**Symptom:** you estimate that an index will improve a query 10x. You apply the index and it doesn't improve anything.
**Why it happens:** the planner may ignore the index (due to statistics, selectivity, correlation with other conditions).
**How to fix it:** after applying a fix, **always validate with real data** (the "Post-fix verification" section). If the improvement is less than the estimate, the report must explain why (the correct fix might be another one).
### Mistake 5: prioritizing by mean_exec_time instead of total_exec_time
**Symptom:** the report recommends attacking the individually slowest query first (high mean), even though it has few calls.
**Why it happens:** the intuitive "big first" bias. But the impact on the system is a product, not individual.
**How to fix it:** always use the impact formula. The query with the lower mean but higher calls can win.
### Mistake 6: not connecting queries with the guide's modules
**Symptom:** the report identifies problems but the "solutions" are vague ("this would need to be optimized").
**Why it happens:** the last step of mapping problem → module is missing.
**How to fix it:** for each problematic query, cite the specific module that contains the fix. This makes the report actionable and demonstrates that you understand the complete guide.
---
## What to do if you get stuck
**If the database doesn't respond:**
- Verify that the container is running: `docker ps | grep bookstore-pg`.
- Verify connectivity: `docker exec bookstore-pg psql -U bookstore -d bookstore -c "SELECT 1;"`.
- If nothing works, recreate the container with the setup from capsule 02.
**If `wrk` gives connection errors:**
- Reduce `-c` (concurrent connections) from 20 to 10 or 5.
- Verify that the app responds manually with `curl`.
- Verify that the app's pool isn't saturated (capsule 06 covers this).
**If `pg_stat_statements` is empty after the load:**
- Verify that `pg_stat_statements.track = 'all'` or `'top'`.
- Verify that the extension is created: `\dx` in psql.
- If all of the above is fine, verify that your load REALLY generated queries: `SELECT count(*) FROM pg_stat_statements;` should be >100.
**If you don't understand a captured plan:**
- Go back to module 2 (EXPLAIN ANALYZE in depth) to refresh.
- Use [explain.depesz.com](https://explain.depesz.com/) to visualize the plan in colored HTML.
**If the impact estimates seem arbitrary to you:**
- It's normal — estimates require knowledge of the expected fix. For new indexes, assume a 5-20x improvement. For eliminating N+1, assume a reduction of calls to 1-3 per request of the problematic endpoint. Document your assumptions so the reviewer can critique them.
---
## Resources for the project
1. [PostgreSQL 16 — `pg_stat_statements`](https://www.postgresql.org/docs/16/pgstatstatements.html) — continued reference.
2. [PostgreSQL 16 — `auto_explain`](https://www.postgresql.org/docs/16/auto-explain.html) — continued reference.
3. [`wrk` — HTTP benchmarking tool](https://github.com/wg/wrk) — the load tool (module 1).
4. [explain.depesz.com](https://explain.depesz.com/) — a plan visualizer.
5. [Lukas Fittl — "Postgres slow query investigation guide"](https://pganalyze.com/blog/postgres-slow-query-investigation) — a professional investigation workflow.
6. [Hubert "depesz" Lubaczewski — debugging series](https://www.depesz.com/category/postgresql/) — classics on profiling and debugging.
---
## What comes next
This project closes module 5. You now have the ability to profile any PostgreSQL in production, identify the problematic queries, capture plans, prioritize fixes with measured impact, and produce reports your team can use.
**What you learned applies directly in:**
- **Module 6 (Pooling):** you'll use `pg_stat_activity` to diagnose pool saturation.
- **Module 7 (Statistics and autovacuum):** you'll use `auto_explain` and `pg_stat_statements` to detect plans degraded by stale statistics.
- **Module 8 (Anti-patterns and final project):** you'll do this same workflow on a "deliberately broken" version of the bookstore with additional problems you don't anticipate. The methodology is the same; what changes is the rigor of applying it without prior hints.
**In real work:**
This report (`PROFILING-REPORT.md`) is the template you'll use the next time your team asks you to investigate DB performance. Save it. Adapt it. The methodology is portable to any PostgreSQL at any company.
**Before moving on to module 6, make sure that:**
- You have `PROFILING-REPORT.md` complete and reviewed.
- You can explain your top 3 queries with their diagnosis and fix without consulting the report.
- If you applied fixes, you measured and documented the real improvement.
- Your workflow (reset → load → top → plans → prioritization → fix → validation) is internalized, you don't have to think about each step.
If all of that is yes, you're ready for module 6. If something wavers, repeat the project with a different endpoint — practice is what makes the methodology automatic.
---
*Module 5 — Database Performance & Query Tuning Guide*