search_path Drift and Object Resolution #
A query returns different row counts depending on which connection runs it. Both connections run identical SQL against the same database, and both are correct — they are reading different tables, because search_path differs and the unqualified name orders resolves differently in each.
Settings drift generally is covered in session parameter drift. search_path deserves its own treatment because, unlike other settings, it changes which objects a query refers to rather than how they are accessed.
The Condition #
An unqualified name is resolved by walking search_path in order and taking the first schema containing a matching object. The default is "$user", public: a schema named after the current user if one exists, then public.
That makes the following all true at once:
- A query written against
public.ordersreadstenant_42.ordersinstead, ifsearch_pathbegins withtenant_42and that schema has anorderstable. - A function resolves to a different implementation depending on which schema’s version comes first, which matters for operators and default-argument functions as much as for plain functions.
- Extension objects —
pg_trgm’s operators, PostGIS functions — are unreachable if their schema is not on the path, and a query that worked in one session fails in another withfunction … does not exist. - Plans are not shared across resolutions. A prepared statement resolves names when it is prepared, so a cached plan refers to specific object OIDs. That is correct behaviour, and it means the same statement text in two sessions can have two entirely different plans over different tables.
search_path also carries a well-known security consideration: a SECURITY DEFINER function that does not pin its own path can be made to call a different function than it intended by a caller who controls the path. Pinning it is standard practice for that reason alone.
Annotated Evidence #
The same statement, two sessions:
-- session A
SHOW search_path; -- "$user", public
EXPLAIN SELECT count(*) FROM orders;
Aggregate (cost=41204.10..41204.11 rows=1 width=8)
-> Seq Scan on orders (cost=0.00..38412.08 rows=1116808 width=0)
-- session B, a tenant worker
SHOW search_path; -- tenant_42, public
EXPLAIN SELECT count(*) FROM orders;
Aggregate (cost=412.10..412.11 rows=1 width=8)
-> Seq Scan on orders (cost=0.00..384.08 rows=11208 width=0)
-- a hundredfold smaller: this is tenant_42.orders, a different table
Which object was actually used:
EXPLAIN (VERBOSE) SELECT count(*) FROM orders;
Aggregate (cost=412.10..412.11 rows=1 width=8)
Output: count(*)
-> Seq Scan on tenant_42.orders (cost=…)
-- VERBOSE qualifies every relation: the fastest way to see what resolved
And where the path is coming from:
SELECT setdatabase::regclass, setrole::regrole, setconfig FROM pg_db_role_setting;
setdatabase | setrole | setconfig
-------------+----------+--------------------------------
- | worker | {"search_path=tenant_42, public"}
-- a role default, invisible to anyone reading the application's code
Step-by-Step Resolution #
-
Qualify names in anything that must be unambiguous — migrations,
SECURITY DEFINERfunctions, monitoring queries, anything in a shared library.public.orderscannot drift. -
Pin the path for functions that rely on it:
ALTER FUNCTION app.process_order(bigint) SET search_path = app, public, pg_temp;Including
pg_templast (or omitting it deliberately) prevents a caller’s temporary objects shadowing real ones. -
Set the application’s path once, where it is visible — a role default or the connection string’s
options— rather than with aSETinside request code, which leaks through pooled connections as described in application-side pools and server-side state. -
Audit what is already set:
pg_db_role_settingfor role and database defaults, the connection string, and anySETin application startup code. -
Use
EXPLAIN (VERBOSE)whenever a query’s cost estimate looks wrong by an order of magnitude in one environment and not another. It qualifies every relation, which settles the question immediately. -
Keep extension schemas on the path deliberately. Installing extensions into a dedicated schema and adding it to the role default is more predictable than installing into
public. -
Test with the application’s real role. A superuser session in
psqlfrequently has a different path than the application, which is how this class of bug reaches production.
Before and After #
-- BEFORE: unqualified, path set per request
SET search_path = tenant_42, public; -- leaks to later requests on this connection
SELECT count(*) FROM orders; -- which orders? depends on who ran before
-- AFTER: role default plus qualification where it matters
ALTER ROLE worker_42 SET search_path = tenant_42, public;
SELECT count(*) FROM public.orders; -- unambiguous when it must be
Why plans differ, not just results #
The plan difference is not incidental — it is the clearest symptom. Two tables with the same name in different schemas have different sizes, different statistics and different indexes, so the planner makes different choices for each. A query that uses an index scan against a tenant’s small table can use a sequential scan against the shared one, and the estimated row counts differ by whatever the size ratio is.
That gives a useful diagnostic: when the same SQL produces plans whose estimates differ by orders of magnitude across environments, resolution is a stronger hypothesis than statistics. Statistics drift changes estimates by a factor of two or ten; resolving to a different table changes them by whatever the tables differ by, which is usually much more.
It also explains a caching subtlety. A prepared statement binds its object references when it is prepared, so changing search_path afterwards does not re-resolve it — the cached plan keeps pointing at the objects it originally found. In a pooled application where the path is set per request, that means a connection can end up executing a cached plan against the previous tenant’s tables, which is a correctness problem rather than a performance one.
Schema-per-tenant and the plan cache #
Multi-tenant designs that give each tenant a schema use search_path as the tenant selector, which makes all of the above operational rather than theoretical.
The arrangement works well when each tenant has its own role and its own connection pool: the path is a role default, sessions never change it, and cached plans always refer to the right tenant’s tables. It works badly when one pool serves every tenant and the path is set per request, because that is exactly the configuration where a cached plan can outlive the path that produced it, and where a returned connection carries the previous tenant’s path to the next request.
If a shared pool is unavoidable, two mitigations help. Reset the path explicitly when checking a connection out, rather than relying on the previous user to have cleaned up. And avoid prepared statements for the tenant-resolved queries, or clear them alongside the path — DISCARD ALL does both, at the cost of the plan cache.
The alternative design — one table with a tenant column and row-level security — trades this problem for a different one: plans are shared, which is good, but selectivity on the tenant column becomes a statistics question, and a tenant holding most of the rows will mislead the planner for everyone else.
Common Pitfalls #
Setting search_path per request. It leaks through pooling and invalidates assumptions about cached plans. Diagnostic signal: results depending on request order. Fix: role defaults.
Unpinned SECURITY DEFINER functions. The caller controls resolution. Diagnostic signal: a definer function with no SET search_path. Fix: pin it, ending with pg_temp.
Testing as a superuser. The path differs from the application’s. Diagnostic signal: works in psql, fails in the app. Fix: test with the application role.
Assuming a cached plan re-resolves. It does not. Diagnostic signal: a prepared statement reading the wrong schema’s table. Fix: do not change the path on live connections.
Frequently Asked Questions #
How do I see which schema a query actually used? #
Run EXPLAIN (VERBOSE): it qualifies every relation with its schema, so the resolution is explicit in the plan. SHOW search_path gives the order that produced it.
Why does the same query give different row counts in different sessions? #
Because search_path differs and the unqualified names resolve to tables in different schemas. Both sessions are behaving correctly; they are reading different objects.
Where should search_path be set for an application? #
As a role default with ALTER ROLE, or in the connection string, so it applies to every session that role opens. Setting it per request leaks through connection pools and interacts badly with cached plans.
Related #
- Session Parameter Drift — parent guide
- Role and Database Level Setting Overrides — sibling: where defaults come from
- Application-Side Pools and Server-Side State — why per-request SET leaks