Shlok Parida

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.