An unqualified table name is a search
Postgres does not bind an unqualified table name when you write the query. It
resolves it on every execution, against a list, in order, and the first match
wins. search_path is that list, and treating it as a namespace declaration is
where the surprises come from.
A fresh session on Postgres 18:
SHOW search_path;
search_path
-----------------
"$user", public
Two entries. "$user" names a schema matching the connected role, which usually
does not exist and is then ignored. Everything therefore lands in public, which
is why public feels like "the database" rather than one entry in a lookup list.
The path is not validated
SET search_path = no_such_schema, public;
SET
That succeeds. From the documentation: "Any name that is not an existing schema,
or is a schema for which the user does not have USAGE permission, is silently
ignored."
So a typo in a search path is not an error. It is a fallthrough to the next
entry, and the next entry is usually public — the query runs, returns rows,
and reads a table you did not mean. A schema that exists but that the role cannot
USAGE behaves identically, which makes a permissions mistake look exactly like
a working system until the day the two tables disagree.
Ask the planner, not the setting
EXPLAIN (VERBOSE) prints schema-qualified relation names, so it answers the
question you actually have:
SET search_path = no_such_schema, public;
EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM widget;
QUERY PLAN
---------------------------
Seq Scan on public.widget
Output: id
SHOW search_path tells you what you asked for. The plan tells you what you got.
pg_catalog and pg_temp are in the path whether you put them there or not
From the documentation: "The system catalog schema, pg_catalog, is always
searched, whether it is mentioned in the path or not. ... If pg_catalog is not
in the path then it will be searched before searching any of the path items." That is what keeps count and now findable, and it is why a table of
your own named pg_class is unreachable without qualifying it.
The temporary schema is stranger: "the current session's temporary-table schema
... is always searched if it exists ... If it is not listed in the path then it
is searched first (even before pg_catalog)."
First. Ahead of every schema you named:
SET search_path = sp_demo, public;
CREATE TEMP TABLE widget(id int);
EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM widget;
QUERY PLAN
----------------------------
Seq Scan on pg_temp.widget
Output: id
sp_demo.widget exists and sp_demo is first in the path. The temp table wins
anyway. A scratch table created for a one-off backfill shadows the real one for
the rest of that session, in every statement that does not qualify the name —
and the backfill will look like it worked.
One restriction limits the damage: the temporary schema "is only searched for relation ... and data type names. It is never searched for function or operator names."
Sharing one database between applications
Given all of that, an application whose tables live beside another application's in the same database has two honest options: schema-qualify every name, or set the path once per connection. The first is correct and nobody does it consistently.
The second has a trap of its own:
db.Exec("SET search_path = myapp") // wrong
SET applies to one backend session. A pool opens connections when it decides
to, not when you ask, so the next query may be served by a connection opened
after that statement ran — on the default path, resolving into public, without
complaint. The bug appears under load and disappears when you go looking for it.
Put the path in the connection string instead:
dsn := "postgres://user:pass@host/db?search_path=myapp"
lib/pq forwards any connection-string key that is not one of its own driver
settings to the server as a startup runtime parameter. The path is established as
part of establishing the connection — every connection, including the ones the
pool opens an hour from now.
The schema still has to exist, and a schema name is not a bind parameter:
if !schemaNameRE.MatchString(schema) { // ^[a-z_][a-z0-9_]*$
return fmt.Errorf("invalid schema %q", schema)
}
db.Exec(`CREATE SCHEMA IF NOT EXISTS ` + pq.QuoteIdentifier(schema))
Validate first and quote anyway. QuoteIdentifier makes the value safe to
interpolate; the pattern check is what stops a legal-but-absurd identifier from
becoming a schema.
The rule
An unqualified name is resolved against session state, and session state on a
pooled connection is not something you set — it is something you establish. Put
it in the DSN so it cannot be missed, and when a query returns rows you did not
expect, ask EXPLAIN (VERBOSE) which table it read before you assume anything
else.