Monitor Queries with pg_stat_statements
Learn how to monitor PostgreSQL queries using pg_stat_statements — a practical lesson for identifying performance bottlenecks.
Focus: monitor queries with pg_stat_statements
You've tuned work_mem, added indexes, and rewritten queries until your fingers ache — but what if the slow query you're chasing isn't the one you think it is? Without visibility into what your database actually runs, you're guessing, and guessing wastes hours. pg_stat_statements ends the guesswork: it's the built-in PostgreSQL extension that records every query execution, its frequency, timing, and resource consumption, giving you a precise performance report straight from the source. In this lesson, you'll learn how to enable and use pg_stat_statements to monitor queries, spot bottlenecks, and confidently decide where to optimize next, without expensive external tools.
The problem this lesson solves
Every database has a handful of queries that consume 90% of its resources — but they're not always the ones in your most recent code review. Maybe it's a runaway JOIN from a background job, a missing index on a hot lookup table, or a query that was fine at 10,000 rows and now crawls at 10 million.
The core pain: Without monitoring, you can't see which queries are slow, how often they run, or how much CPU, I/O, and memory they use. You end up optimizing the wrong things while the real culprits hide in plain sight.
pg_stat_statements solves this by tracking every SQL statement executed by the server (within your configured limits) and aggregating key statistics: total execution time, average time, calls, rows returned, block reads, and temporary file usage. It's like a flight recorder for your SQL — you can rewind and ask: "What was the slowest query last night?"
This lesson matters because performance monitoring is the prerequisite for performance tuning. You wouldn't fix a broken engine without a diagnostic scanner; pg_stat_statements is your diagnostic scanner for PostgreSQL.
Core concept / mental model
Think of pg_stat_statements as a performance ledger for your database. Instead of reading individual log lines, you query a built-in view that summarizes every query's behavior.
- Query normalization: PostgreSQL replaces literal values with placeholders like
$1so thatSELECT * FROM users WHERE id = 42andSELECT * FROM users WHERE id = 99are counted as the same query. This gives you a fair aggregate view. - Rolling stats: The extension keeps cumulative counters from the moment it's loaded (or since stats reset). You can reset them to start a fresh monitoring window.
- Key metrics:
calls(execution count),total_exec_time,mean_exec_time,max_exec_time,rows(rows returned),shared_blks_read(disk blocks),shared_blks_hit(cache hits), andtemp_files/temp_bytes(spill to disk).
Mental model: Each row in
pg_stat_statementsis a unique normalized query. The columns tell you how many times it ran, how long it took, and how much disk/cache it touched. Sort bytotal_exec_timeto find your biggest time sinks.
The stats persistence
pg_stat_statements stores statistics in shared memory and writes them to a regular table (pg_stat_statements view reads that memory). This means statistics are not persisted across restarts unless you enable the pg_stat_statements.max GUC and save them periodically — but for most monitoring, that's fine; you can reset and start fresh.
How it works step by step
Enabling pg_stat_statements involves a few configuration steps, then you query the extension. Here's the logical flow:
- Add the extension to the shared preload libraries so it starts with the server.
- Create the extension in the database where you want to query the view.
- Configure limits (optional): set
pg_stat_statements.max(number of unique queries to track) andpg_stat_statements.track(whether to track top-level or nested statements). - Restart PostgreSQL (required for
shared_preload_librarieschanges). - Run your workload — existing queries will begin to be recorded.
- Query the view to analyze performance.
- Reset stats when you want a clean slate for a new test.
Key configuration parameters
| Parameter | Purpose | Default |
|---|---|---|
shared_preload_libraries |
Load extension at server start | (empty) |
pg_stat_statements.max |
Max unique queries stored | 5000 |
pg_stat_statements.track |
all / top / none |
top |
pg_stat_statements.track_utility |
Whether to track utility commands (e.g., VACUUM) |
on |
pg_stat_statements.save |
Save stats across restarts to a file | on (but only if pg_stat_statements is loaded) |
When track is top, only top-level statements are tracked — good for most cases. If you want to see inside functions or procedures, use all (with a performance cost).
Hands-on walkthrough
Step 1: Enable the extension
Edit your postgresql.conf (or use ALTER SYSTEM):
# Add to postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all
Then restart the server:
sudo systemctl restart postgresql
Step 2: Create the extension in your database
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
Step 3: Generate some workload
-- Run a few sample queries
SELECT * FROM orders WHERE customer_id = 42;
SELECT * FROM orders WHERE customer_id = 43;
SELECT * FROM orders WHERE created_at > NOW() - interval '1 day';
Step 4: Query the monitoring view
SELECT
queryid,
calls,
ROUND(total_exec_time::numeric, 2) AS total_ms,
ROUND(mean_exec_time::numeric, 2) AS avg_ms,
ROUND(max_exec_time::numeric, 2) AS max_ms,
rows,
shared_blks_hit,
shared_blks_read
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Expected output: a table showing repeated queries with high total time, helping you spot hot spots.
Step 5: Reset stats for a clean test
SELECT pg_stat_statements_reset();
Pro tip: Reset stats before deploying a new feature or running a benchmark to get a clean measurement window.
Compare options / when to choose what
pg_stat_statements is powerful, but it's not the only tool. Here's a comparison:
| Tool | Scope | Granularity | Overhead | Best for |
|---|---|---|---|---|
pg_stat_statements |
Server-wide aggregated | Query normalized | Low | Baseline monitoring, top-N discovery |
EXPLAIN ANALYZE |
Single query | Row-level detail | Manual | Deep-dive into a specific query |
pg_stat_activity |
Live sessions | Current queries | Negligible | Real-time active queries |
auto_explain |
Logs slow queries | Detailed plan | Medium | Catching slow queries in production |
| Third-party (pgBadger, Datadog) | External analytics | Varies | High | Historical trends, alerting |
When to choose what
- Choose
pg_stat_statementswhen you want to monitor queries over time, find top time-consuming queries, and track performance before/after changes. - Choose
EXPLAIN ANALYZEwhen you've identified a problematic query and need to understand its execution plan in detail. - Choose
auto_explainif you need to log every query that exceeds a threshold, e.g.,auto_explain.log_min_duration = '1s'. - Choose third-party tools when you need long-term storage, alerting, and visual dashboards — but even then,
pg_stat_statementsis the foundation.
Troubleshooting & edge cases
"Extension pg_stat_statements must be loaded via shared_preload_libraries"
This error occurs if you try to CREATE EXTENSION before setting shared_preload_libraries. Solution: add to config, restart, then create.
Stats are empty (all zeros) after restart
If you didn't set pg_stat_statements.save = on, statistics are zeroed on restart. Also, if the extension isn't in shared_preload_libraries, no data is collected.
Only one query is tracked
If pg_stat_statements.track = top, nested queries inside functions won't appear. Change to all if you need them — but expect a slight overhead.
High memory usage
Setting pg_stat_statements.max too high (e.g., 100,000) consumes shared memory. Monitor memory and adjust to a realistic value (5,000–20,000 is typical).
Query text is truncated
The default track_activity_query_size limits stored query text (usually 1024 bytes). Increase it in config if you need longer statements: track_activity_query_size = 8192.
The view returns rows even after reset
Because pg_stat_statements is in shared memory, resetting clears counters immediately, but new queries start to accumulate again. That’s normal.
What you learned & what's next
You now know how to monitor queries with pg_stat_statements: enabling the extension, configuring it, and using the view to identify performance bottlenecks. You can answer questions like "Which queries are consuming the most time?" and "How many times does this heavy query run?" You also understand how to reset stats for targeted testing and when to complement this tool with EXPLAIN ANALYZE or auto_explain.
Next step: Now that you can spot slow queries, the natural next step is to optimize them. In the upcoming lesson, you'll learn how to interpret EXPLAIN output and use that knowledge to add indexes and restructure queries for maximum performance. Monitoring is only half the battle — fixing is the next.
Keep this lesson’s example queries handy; you’ll reuse them as a baseline when you measure the impact of your optimization work.
Practice recap
Set up pg_stat_statements in your local PostgreSQL, run the sample queries from this lesson, and then use the view to find the top 3 queries by total execution time. Next, reset the stats, run the same queries in reverse order, and compare the results — notice how calls and timing change. Finally, select a query you've written recently, use EXPLAIN ANALYZE on it, and see if the plan reveals a missing index.
Common mistakes
- Trying to use pg_stat_statements without adding it to shared_preload_libraries — you'll get a runtime error when creating the extension.
- Resetting stats right before a long load test but forgetting that 'track' is set to 'top', so nested function calls are invisible.
- Relying on total_exec_time alone without checking calls — a query with a small avg time but huge call count can be the real bottleneck.
- Setting pg_stat_statements.max too high (e.g., 100k) and exhausting shared memory, causing server startup issues.
Variations
- Use pg_stat_statements with track_utility = off to exclude maintenance commands like VACUUM and ANALYZE.
- Combine pg_stat_statements with pg_stat_activity for real-time and historical monitoring in a single dashboard.
- Set a timeout (e.g., track_activity_query_size) to see only query text up to a configurable length.
Real-world use cases
- A production web app with 1M+ daily requests uses pg_stat_statements to identify the slowest API query and add a composite index, cutting p95 latency by 40%.
- A data warehouse team monitors nightly ETL jobs; they reset stats before each run and analyze total_exec_time per query to pinpoint pipeline degradation.
- A DevOps engineer sets up a cron job that runs a SELECT from pg_stat_statements every 15 minutes, pushing metrics to Prometheus for Grafana dashboards.
Key takeaways
- pg_stat_statements is a server-wide, low-overhead query performance monitoring tool.
- Enable by adding 'pg_stat_statements' to shared_preload_libraries and restarting PostgreSQL.
- Query the view by total_exec_time descending to find your biggest time sinks.
- Use pg_stat_statements_reset() to create clean measurement windows.
- Pair it with EXPLAIN ANALYZE for deep dives into specific slow queries.
- Configure max, track, and save parameters to balance detail and performance.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.