Performance and tuning
David Sterling  

pg_stat_statements Blind Spots: Why Slow Queries Vanish

You get paged at 2 a.m. because a settlement query on the dispatch database is taking 40 seconds instead of 200 milliseconds. You open pg_stat_statements, the tool you reach for first, and the query is not there. Nothing is broken. The view has documented blind spots, and every operator running Postgres under real traffic eventually meets them — usually at the worst possible moment. Here is where the gaps are, how PostgreSQL 18 moved them, and how to build a triage workflow that survives them.

The 5,000-entry ceiling deletes your one-off statements

pg_stat_statements holds a fixed number of rows. pg_stat_statements.max defaults to 5000, can only be set at server start, and per the documentation once more distinct statements are observed than that, “information about the least-executed statements is discarded.” A statement that runs once is by definition the least-executed thing in the database. The view also keeps only a counter, pg_stat_statements_info.dealloc, for how often this happened. It never names the casualties. You can read the full reproduction in Mikhail Shytsko’s write-up, where forty one-off DELETE statements were recorded and then evicted by a later burst of read traffic.

-- is the view already lying to you?
SELECT dealloc, stats_reset FROM pg_stat_statements_info;

-- what actually costs you time (rank by time, not by call count)
SELECT queryid, calls,
       round(total_exec_time)::bigint AS total_ms,
       round(mean_exec_time, 2)      AS mean_ms,
       rows, query
  FROM pg_stat_statements
 ORDER BY total_exec_time DESC
 LIMIT 10;

If dealloc is climbing, your dashboard is not ranking the slowest queries; it is ranking the ones that happened to survive eviction. The right sizing arithmetic is shapes × tables, not raw statement counts: a workload issuing 5,120 statements across 320 tables filled 4,805 entries without eviction, while 6,400 statements across 400 tables crossed the line. Raising max requires a restart because the entries live in shared memory sized at startup, so budget it deliberately.

One question, eight entries

Constants are stripped before the queryid is computed, so WHERE customer_id = 42 and = 313 land in one row as $1. What splits a row is any change to the parse tree. Lowercase keywords, reflowed whitespace, a leading comment, and a redundant public. prefix all merge — but a table alias, count(1) instead of count(*), two AND-ed predicates in the opposite order, a subquery wrapper, or a CTE each get their own entry. An application issuing one stable statement per endpoint barely notices. An ORM, a report builder, or any tool that regenerates SQL spreads one piece of work across rows that nothing marks as related.

PostgreSQL 18 changed the grouping rules in both directions, and both changes bite if you trust a dashboard built before the upgrade. Long IN lists were collapsed: IN (1,2,3) and IN (1,...,7) now share an entry, with a comment standing in for the dropped elements, while a single-element list still keeps a row of its own. More importantly, the 18 release notes added grouping “even if the tables in different schemas have different column names” — so tenant_a.invoices with 100 rows and tenant_b.invoices with 100,000 rows now occupy a single queryid where 17 kept them apart. The text shown is whichever statement created the entry, not a description of everything now counted in it. On a schema-per-tenant Postgres, that quietly averages two unrelated tables into one timing. In the same release, SET app.tenant = 'alpha' began displaying as a $1 placeholder, which removes the last way stats alone could tell you which tenants a pooled worker had served.

-- 17: two rows, one per schema. 18 and 19: one row, two calls, mixed timings.
SET search_path = tenant_a;  SELECT count(*) FROM invoices WHERE id > 5;
SET search_path = tenant_b;  SELECT count(*) FROM invoices WHERE id > 5;

       queryid       | calls | rows |              query
---------------------+-------+------+----------------------------------
 9114093936963880505 |     2 |    2 | SELECT count(*) FROM invoices WHERE id > $1

Errors are never recorded, and nested statements disappear by default

Statistics are written when execution finishes. A statement that raised an error never finished, so it leaves no entry at all — which means the failing query that filled your log is precisely the one the view cannot tell you about. Second gap: with track at its default top, a statement inside a PL/pgSQL function, procedure, or DO block has no entry of its own; only the client-issued CALL or SELECT is counted. For a Houston shop where the heavy lifting lives in PL/pgSQL settlement routines and job-runner functions, the default leaves the top of the list empty and the function body invisible.

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements,auto_explain'   # restart
pg_stat_statements.max   = 20000        # restart; shared memory, size deliberately
pg_stat_statements.track = 'all'        # reload; counts nested statements too
auto_explain.log_min_duration = '2s'
log_min_duration_statement    = 2000    # ms
log_line_prefix = '%m [%p] %u %a %d '   # user, application_name, database

The grouping key stops at the role

The view exposes 52 columns, but the grouping key is only userid, dbid, toplevel and queryid. There is no application_name, no client address, no session identifier anywhere in the remaining 48. Two pooled connections sharing one login — a customer-facing API and a nightly batch job — are summed into the same row, and because the numbers were added as each statement finished, nothing can separate them afterwards. A role has to exist before the traffic does. Give each workload its own database role, and take the per-session attributes from the log with log_line_prefix as above.

A triage loop that survives these gaps

  • Alert on dealloc > 0, not just on slow queries. Eviction is the difference between “no slow queries” and “no data.”
  • Size max from shapes × tables, then raise it at your next restart. Re-running the arithmetic after enabling track = 'all' usually doubles the requirement.
  • Set track = 'all' if any meaningful work happens inside functions, and remember that the resulting entries still compete for the same ceiling.
  • One role per workload. Cheap to introduce early, impossible to retrofit once a job or an agent shares the application’s login.
  • Keep the log as the fallback of record. log_min_duration_statement plus %u and %a gives you the statement, the literal values, the caller, and the timestamp.
  • Grab the plan, not just the average. auto_explain above a threshold captures execution plans for the outliers that an averaged entry can hide.
  • Expect a reset on major upgrades. queryid values are recomputed per major version, so 18 and 19 beta produce different numbers for the same query — your history starts over. PostgreSQL 19 adds generic_plan_calls and custom_plan_calls, which answers a question the view could not answer before, but it does not close the eviction, tracking, or role gaps.

The practical rule: treat pg_stat_statements as the first question you ask, not the only one. When a query is missing from the view, dealloc, track, and the role boundary will explain almost every case — and a log line with the role, the application name, and the duration will cover the rest. For related work, see our guides on multicolumn index tuning with skip scan and VACUUM tuning for bloat.

Leave A Comment