Session Parameter Drift: When GUCs Differ Between Connections #
Two application connections run the identical statement with the identical parameters, and one is fast while the other spills to disk or picks a different index. Nothing in the SQL explains it. The cause is session parameter drift: the runtime configuration (GUCs) in effect on the two backends has diverged, and because a plan is a function of its session GUCs, the divergence produces different plans and different performance.
This page shows the sources of drift, how to audit the effective value and its origin, and how to pin settings so every connection plans the same way. It is the configuration-focused branch of the runtime environment story.
Which GUCs Change the Plan #
Not every setting affects planning, but the ones that do are exactly the ones that drift. The high-impact set:
work_mem— the memory budget per sort, hash, or hash-aggregate operation. Below it, operations spill to disk. This is the single most common source of “fast here, slow there.”effective_cache_size— the planner’s belief about how much OS cache is available, which shifts the balance toward index scans when large.random_page_cost— the cost of a random page fetch relative to a sequential one; lowering it favors index scans.search_path— determines which schema an unqualified table or index name resolves to, so drift here can hit an entirely different physical table.enable_*toggles —enable_seqscan,enable_hashjoin, and friends; a diagnosticSET enable_seqscan = offthat leaked into a pooled connection changes every plan on it.statement_timeout— does not change plan shape but aborts the query mid-execution, converting a slow plan into an error on one connection only.jit— enables JIT compilation, adding fixed latency that only pays off on long CPU-bound queries.
Where Drift Comes From #
Drift is not random; it enters through a fixed set of layers, each of which can override the one below it:
ALTER ROLE app_user SET ...— a per-role default applied at connection time. Easy to forget, and it silently overrides the config file for that role only.ALTER DATABASE appdb SET ...— a per-database default, layered under the role default.- Connection-string options —
options='-c work_mem=8MB'in the DSN, applied per connection, easy to differ between services sharing a database. - Application-issued
SET— a runtime change from code; behind a pooler it can leak into the next client that reuses the backend. - Pooler
server_reset_query— aDISCARD ALLresets session GUCs to their startup values on backend release, which either fixes or masks drift depending on what you expected to persist. SETvsSET LOCALleakage —SETpersists for the session (dangerous behind a pooler);SET LOCALreverts at transaction end (safe). Mixing them is a classic source of “it drifts sometimes.”
Auditing the Effective Value and Its Source #
The authoritative view is pg_settings, whose source and context columns tell you not just the value but where it came from.
-- What is in effect on THIS backend, and where did each value originate?
SELECT name, setting, unit, source, context
FROM pg_settings
WHERE name IN ('work_mem','effective_cache_size','random_page_cost',
'search_path','statement_timeout','jit');
name | setting | unit | source | context
---------------------+---------+------+-------------------+-----------
work_mem | 4096 | kB | role | user
search_path | app,... | | session | user
random_page_cost | 1.1 | | configuration file| sighup
-- source=role → an ALTER ROLE ... SET work_mem is overriding the config default.
-- source=session → a runtime SET changed search_path on THIS backend only.
-- source=configuration file → the postgresql.conf value is still in force.
To compare across live connections and tie a value to an application, join pg_stat_activity:
-- Spot connections whose effective work_mem differs from the intended 64MB.
SELECT a.pid, a.application_name, a.usename,
s.setting AS work_mem_kb, s.source
FROM pg_stat_activity a
CROSS JOIN LATERAL (
SELECT setting, source FROM pg_settings WHERE name = 'work_mem'
) s
WHERE a.datname = 'appdb';
-- Note: pg_settings reflects the querying backend; use current_setting()
-- inside each app connection, or auto_explain, to capture per-backend drift.
Annotated: The Same Query, Two Outcomes #
The clearest evidence of drift is a sort that spills on one connection and stays in memory on another. Same query, same data, different work_mem.
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, SUM(total_amount) AS lifetime
FROM orders
GROUP BY customer_id
ORDER BY lifetime DESC;
-- Connection with work_mem = 4MB:
Sort (cost=48210.0..48960.0 rows=300000 width=16)
(actual time=980.4..1120.7 rows=300000 loops=1)
Sort Key: (sum(total_amount)) DESC
Sort Method: external merge Disk: 8776kB
-- external merge + Disk: → the sort SPILLED; work_mem too small here
-- Connection with work_mem = 64MB (identical SQL):
Sort (cost=41200.0..41950.0 rows=300000 width=16)
(actual time=305.2..362.9 rows=300000 loops=1)
Sort Key: (sum(total_amount)) DESC
Sort Method: quicksort Memory: 41800kB
-- quicksort + Memory: → fully in RAM, ~3x faster on identical data
The only difference is the effective work_mem. Reading these spill signals in depth is covered in sort and hash node analysis; the same memory budget also governs whether a hash join stays single-batch. The detecting work_mem drift across sessions page turns this into a repeatable canary check.
Numbered Diagnostic Workflow #
Step 1 — Enumerate the sources of each GUC.
-- Find every role- and database-level override that could cause drift.
SELECT rolname, rolconfig FROM pg_roles WHERE rolconfig IS NOT NULL;
SELECT datname, datconfig FROM pg_db_role_setting s
JOIN pg_database d ON d.oid = s.setdatabase; -- per-db/role overrides
Combined with the pg_settings source column, this shows every layer that can set the value.
Step 2 — Pin the intended value at role or database level.
-- Make the value deterministic for every connection of this role/db.
ALTER ROLE app_user SET work_mem = '64MB';
ALTER DATABASE appdb SET search_path = 'app, public';
-- Applied at connection time; no reliance on the app issuing SET.
Step 3 — Verify with a canary query.
Run the spill-sensitive query above on a freshly checked-out connection and confirm Sort Method: quicksort (in memory), not external merge (spilled). A canary that changes plan under drift is your regression detector.
Step 4 — Monitor for regressions.
-- Periodically snapshot effective settings per active connection.
SELECT pid, application_name,
current_setting('work_mem') AS work_mem,
current_setting('search_path') AS search_path
FROM pg_stat_activity
WHERE datname = 'appdb' AND state <> 'idle';
Common Pitfalls #
search_path changes which schema’s table is hit.
Diagnostic: two connections use different indexes, or one hits a table the other does not, for an unqualified name. Fix: schema-qualify names in the query, or pin search_path at the role level so multi-tenant connections cannot resolve to the wrong schema.
SET leaking into pooled connections.
Diagnostic: a GUC intermittently has a value no one set on this connection. Fix: replace runtime SET with SET LOCAL (transaction-scoped) or with a role/database default; a plain SET persists on the backend and the pooler may hand that backend to another client.
jit = on adds latency to short queries.
Diagnostic: EXPLAIN ANALYZE shows a JIT block with Generation and Inlining time on a query returning few rows. Fix: raise jit_above_cost, or set jit = off for OLTP roles; JIT only amortizes on long CPU-bound analytical queries.
Per-operation work_mem multiplication.
Diagnostic: raising work_mem to fix one spill causes memory pressure because a query has several sorts/hashes. Fix: work_mem is per operation, so a plan with three hash nodes can use 3 × work_mem; pin a moderate value and raise it with SET LOCAL only for the specific heavy query.
geqo_threshold changing plans on many-way joins.
Diagnostic: a query with many joined tables produces a different plan on two connections with different geqo_threshold or geqo settings. Fix: the genetic query optimizer is non-deterministic above the threshold; align geqo_threshold across connections or raise it so the exhaustive planner runs for that join count.
Frequently Asked Questions #
Why does the same query spill to disk on one connection but not another? #
The two connections have different effective work_mem. A sort or hash that fits in memory on a connection with a large work_mem spills to a temporary file on a connection with a small one, shown as Sort Method: external merge with a Disk: figure. The difference usually comes from an ALTER ROLE SET, a connection-string option, or a SET that leaked into one pooled connection but not the other. Pinning work_mem at the role level removes the divergence.
How do I find where a session setting came from? #
Query pg_settings and read the source and context columns. source tells you whether the value came from the configuration file, a per-database default, a per-role default, the client connection, or a runtime SET. Compare current_setting() across connections to detect drift, and use pg_stat_activity to tie a setting to a specific backend and application. That trail almost always leads straight to the layer that introduced the drift.
What is the difference between SET and SET LOCAL for GUC drift? #
SET changes a GUC for the rest of the session, so behind a pooler it can leak into the next client that reuses the backend. SET LOCAL scopes the change to the current transaction and reverts on commit or rollback, which makes it safe for pooled connections. Persistent, deterministic settings should be pinned with ALTER ROLE or ALTER DATABASE instead of either runtime form, so no connection depends on the app issuing the right SET.
Related #
- Detecting work_mem Drift Across Sessions — a canary-query method to catch work_mem divergence before users do
- Connection Pooling & Plan Cache — how poolers reset and leak session GUCs across backends
- Prepared Statement Plan Pinning — the other runtime input that changes plans without changing SQL
- Sort and Hash Node Analysis — reading the spill signals that work_mem drift produces
- Hash Join Mechanics — how the same work_mem budget governs single-batch versus spilled hash joins