Capturing Plans for Long-Running Queries #
A report has been running for forty minutes. You cannot run EXPLAIN ANALYZE on it — that would start a second forty-minute execution — and by the time it finishes, whoever ran it will have cancelled it. Meanwhile last night’s batch job was slow once and has been fine since; no one captured a plan, and nobody can reproduce the conditions.
Capturing plans deliberately is covered in EXPLAIN options and output formats. This page is about the cases where you cannot simply run the query.
The Condition #
Four sources cover the cases where EXPLAIN ANALYZE is not an option:
auto_explainlogs plans for statements exceeding a duration threshold, after they finish. Withauto_explain.log_analyzeit includes actual rows and times; withlog_min_duration = 0it logs everything, which is only for short investigations. It is the only way to capture what really ran in production, including plans for statements you never thought to instrument.EXPLAINwithoutANALYZEon the same statement text gives the plan the planner would choose now, without executing anything. It costs planning time only and is safe for writes. It does not tell you what the running query is doing, but for a stable schema and statistics it is usually the same plan.pg_stat_activityshows the running statement, how long it has been running, and what it is waiting on. Combined withpg_stat_progress_*views it shows how far along some operations are.- Progress views —
pg_stat_progress_vacuum,pg_stat_progress_create_index,pg_stat_progress_cluster,pg_stat_progress_basebackup,pg_stat_progress_copyandpg_stat_progress_analyze— report phase and completion for those specific operations.
What none of them provides is the plan of a query currently executing in another backend. Some extensions add that capability; in core PostgreSQL, the practical substitutes are auto_explain for after the fact and EXPLAIN for the equivalent plan now.
Annotated Evidence #
What is running, and what it is doing:
SELECT pid, now() - query_start AS runtime, state, wait_event_type, wait_event,
left(query, 80) AS query
FROM pg_stat_activity
WHERE state = 'active' AND now() - query_start > interval '1 minute'
ORDER BY query_start;
pid | runtime | state | wait_event_type | wait_event | query
-------+-------------+--------+-----------------+------------+-------------------------------
41204 | 00:41:12 | active | IO | DataFileRead | SELECT customer_id, sum(total) …
-- running 41 minutes, currently waiting on storage reads, not on a lock
The plan it would get now:
EXPLAIN (VERBOSE, SETTINGS)
SELECT customer_id, sum(total) FROM orders WHERE placed_at >= '2026-01-01' GROUP BY customer_id;
Finalize GroupAggregate (cost=18402104.42..19204112.10 rows=3812044 width=40)
-> Gather Merge (cost=…)
Workers Planned: 4
-> Sort (cost=…)
Sort Key: customer_id
-> Partial HashAggregate (cost=…)
-> Parallel Seq Scan on orders (cost=…)
Settings: work_mem = '64MB', effective_cache_size = '48GB'
-- costs only; no execution. Enough to see the shape and spot the sort
And what actually ran last night, from auto_explain:
LOG: duration: 2481204.118 ms plan:
Query Text: SELECT customer_id, sum(total) FROM orders WHERE placed_at >= $1 GROUP BY customer_id
Finalize GroupAggregate (cost=… rows=3812044) (actual rows=18402110 loops=1)
-> Gather Merge (actual rows=41204 loops=1)
Workers Planned: 4 Workers Launched: 0
-> Sort (actual rows=8240 loops=1)
Sort Method: external merge Disk: 4102208kB
-- Workers Launched: 0 explains the outlier: the pool was exhausted that night
Step-by-Step Resolution #
-
Set up
auto_explainbefore you need it. A duration threshold of a few seconds captures the outliers without flooding the log:ALTER SYSTEM SET session_preload_libraries = 'auto_explain'; ALTER SYSTEM SET auto_explain.log_min_duration = '3s'; ALTER SYSTEM SET auto_explain.log_analyze = on; ALTER SYSTEM SET auto_explain.log_buffers = on; ALTER SYSTEM SET auto_explain.log_settings = on; ALTER SYSTEM SET auto_explain.log_format = 'json'; SELECT pg_reload_conf();log_analyzeadds instrumentation overhead to every statement that qualifies; on a workload with slow clock reads, addauto_explain.log_timing = offto keep row counts without per-node timing. -
For a query running now, read
pg_stat_activityfor its runtime and wait event, which tells you whether it is working, waiting on I/O, or blocked on a lock — the distinction in lock waits that look like slow plans. -
Run
EXPLAINwithoutANALYZEon the same text to see the plan shape it would get now. Bind the same parameter types, and useGENERIC_PLANwhen the application uses prepared statements. -
Check the progress views when the statement is a maintenance command; they give completion percentages no plan can.
-
Decide whether to cancel. A query waiting on a lock will not finish until the blocker does; one reading steadily may be close to done — the progress views and
Buffersgrowth over samples ofpg_stat_activityhelp estimate. -
Keep the captured plans. An
auto_explainentry for a rare outlier is often the only evidence that will ever exist.
Before and After #
-- BEFORE: no capture configured; the outlier is unexplained
pg_stat_activity: 41 minutes, wait_event DataFileRead — and nothing after it finishes
-- AFTER: auto_explain with log_analyze
LOG: duration: 2481204 ms plan: … Workers Launched: 0 … Sort Method: external merge Disk: 4102208kB
Estimating progress without a progress view #
Ordinary queries have no progress view, but two rough signals help decide whether to wait or cancel. Sampling pg_stat_activity does not show buffer counts, but pg_stat_statements accumulates per-statement totals as executions complete, so it cannot help for one in flight either. What does work is sampling the backend’s I/O from the operating system, or — more practically — reasoning from the plan: an EXPLAIN of the same statement gives the estimated row counts, and comparing them with what the query is expected to return tells you the order of magnitude of work remaining.
For maintenance commands the progress views are exact, and they are worth knowing precisely because those are the operations most likely to be waited on: pg_stat_progress_create_index reports the phase and blocks done, pg_stat_progress_vacuum the phase and heap blocks scanned, and pg_stat_progress_copy the tuples processed during a bulk load.
Where investigating in-flight queries is routine, the most valuable investment is not a tool but a habit: auto_explain configured in every environment before anything goes wrong, with a threshold low enough to catch the queries you would want to see and high enough not to flood the log.
Common Pitfalls #
Configuring auto_explain after the incident. The evidence is gone. Diagnostic signal: “we will add logging next time” repeated. Fix: enable it permanently with a sensible threshold.
log_min_duration = 0 in production. Every statement’s plan is logged. Diagnostic signal: log volume explosion. Fix: a threshold of seconds.
Assuming EXPLAIN shows the running plan. It shows what the planner would choose now. Diagnostic signal: a mismatch with auto_explain output. Fix: treat it as an approximation, and check GENERIC_PLAN for prepared statements.
Cancelling without checking the wait event. A blocked query will not progress; a working one might be nearly done. Diagnostic signal: repeated cancellations of the same report. Fix: read wait_event_type first.
Frequently Asked Questions #
Can I see the execution plan of a query that is currently running? #
Not from core PostgreSQL. You can see what it is doing through pg_stat_activity and progress views, and you can run EXPLAIN without ANALYZE on the same statement to see the plan the planner would choose now.
How do I capture plans for slow queries in production? #
Configure auto_explain with a duration threshold and log_analyze enabled. It logs the plan, with actual rows and timings, for every statement exceeding the threshold — including ones you did not anticipate.
Is EXPLAIN without ANALYZE safe on an UPDATE? #
Yes. Without ANALYZE the statement is planned but not executed, so no rows change. Only ANALYZE executes the statement.
Related #
- EXPLAIN Options and Output Formats — parent guide: option semantics
- Capturing ORM Plans with auto_explain — the same tool for application traffic
- Lock Waits That Look Like Slow Plans — interpreting wait events