PostgreSQL Schemas & search_path — Name Resolution and the Namespace Cache
Contents:
- Theoretical Background
- Common DBMS Design
- PostgreSQL’s Approach
- Source Walkthrough
- Source verification (as of 2026-06-05)
- Beyond PostgreSQL — Comparative Designs & Research Frontiers
- Sources
Theoretical Background
Section titled “Theoretical Background”Every relational database must answer a deceptively simple question on
nearly every statement it processes: given a name written by the user,
which object does it denote? SQL lets a programmer write SELECT * FROM orders, and the engine must decide which orders — there may be several
tables of that name living in different schemas, a temporary orders that
shadows a permanent one, a view, or nothing at all. This is the problem of
name resolution, and it sits at the boundary between the parser (which
produces names) and the catalog (which stores objects keyed by an internal
identifier, the OID in PostgreSQL).
The classical relational model as formalised by Codd treats relations as
elements of a flat universe; real systems quickly discovered that a single
global flat namespace does not scale to multi-user, multi-application
databases. Two applications both want a table called config; two
developers both want temp. The answer the SQL standard settled on is a
three-level naming hierarchy: catalog → schema → object. A schema (SQL
calls it a “schema”; PostgreSQL’s internal term is “namespace”) is a named
container that partitions the object name space, so that
sales.orders and archive.orders are distinct relations even though they
share a base name. Database System Concepts (Silberschatz et al.,
ch. “Intermediate SQL”, §“Schemas, Catalogs, and Environments”) describes
this as the mechanism by which “different applications and different users
can work independently without worrying about clashing with each other’s
relation names,” and notes that each SQL environment fixes a default
catalog and schema so that unqualified names still resolve.
Three properties define the design space of a name-resolution subsystem:
-
Qualification levels and defaulting. A fully qualified name (
db.schema.object) is unambiguous but verbose. The engine must supply defaults for the omitted leading components. The interesting question is what the default is and how dynamic it is — fixed at connection time, or a mutable session parameter the user can change mid-session? -
Search order under ambiguity. When the default is not a single schema but an ordered list of schemas, the same unqualified name may exist in several of them. The resolution rule is then “first match in the list wins” — which makes the order of the list semantically load-bearing and turns name resolution into an ordered scan, not a point lookup. Different object classes (tables vs. functions vs. operators) may need subtly different rules: a function call must additionally match by argument types, so the resolver returns a candidate set rather than a single answer and lets overload resolution finish the job.
-
Stability under concurrency. Name resolution reads the catalog, but the catalog is mutable: while one backend resolves
orders, another mayDROPorRENAMEit, or create a newordersearlier in the path. A correct resolver must lock the object it thinks it found and then re-verify that the name still resolves to that object — otherwise it races between “look up the name” and “use the OID.”
Architecture of a Database System (Hellerstein, Stonebraker & Hamilton) places this work in the query parsing and authorization stage of the relational engine: “the parser first considers each of the table references in the FROM clause … canonicalizes table names into a fully qualified name … [and] invokes the catalog manager to check that the table is registered.” It stresses that authorization (does this user have rights to see the schema?) is interleaved with resolution, because a schema the user cannot access must be invisible to search — otherwise the mere act of resolving a name would leak the existence of objects.
PostgreSQL’s answer to all three properties lives almost entirely in one
file, src/backend/catalog/namespace.c. Schemas are rows in the
pg_namespace system catalog; the default is the search_path GUC, a
comma-separated, dynamically settable, ordered list of schema names; the
resolution rule is “first schema in the path that contains a matching
object wins”; and concurrency safety is provided by a lookup-then-lock retry
loop driven by the shared-invalidation message counter.
Common DBMS Design
Section titled “Common DBMS Design”Before diving into PostgreSQL specifics, it helps to name the engineering patterns that almost every catalog-driven engine adopts, so that PostgreSQL’s choices read as selections within a shared space.
The schema as a catalog row, the object keyed by (schema, name)
Section titled “The schema as a catalog row, the object keyed by (schema, name)”Universally, a schema is itself a catalog object with its own identifier,
and every schema-scoped object carries a foreign-key-like reference to its
containing schema. The uniqueness constraint that makes names work is
UNIQUE (object_name, schema_id) — not UNIQUE (object_name). This is
why two tables named orders can coexist: the pair (orders, sales) and
(orders, archive) are distinct index keys. The resolver’s inner loop is
therefore a point lookup on that composite index, repeated once per schema
in the path until a hit.
A session-scoped, ordered default path
Section titled “A session-scoped, ordered default path”Rather than force full qualification, engines expose a session setting that
supplies the missing schema. Oracle’s CURRENT_SCHEMA, SQL Server’s default
schema per user, and PostgreSQL’s search_path are all instances. The
ordered-list variant (PostgreSQL, and DB2’s CURRENT PATH for functions)
is strictly more expressive than the single-schema variant because it lets
“my own copy shadows the shared copy” work without renaming: put your
personal schema first.
Implicit/system schemas always on the path
Section titled “Implicit/system schemas always on the path”Built-in types, functions, and operators live in a system schema
(pg_catalog in PostgreSQL, SYS/INFORMATION_SCHEMA analogues elsewhere)
that must be reachable even when the user never mentions it. Engines
therefore implicitly prepend (or append) the system schema to the
effective path. PostgreSQL prepends pg_catalog if it is not explicitly
listed, which means the built-in = operator is always found — but a user
can move pg_catalog later in the path to deliberately shadow a built-in
with their own.
Candidate sets for overloadable objects
Section titled “Candidate sets for overloadable objects”Tables resolve to exactly one OID, but functions and operators are
overloaded: many + operators, many substring functions. The name
resolver cannot finish the job alone; it returns a candidate list (every
visible object of that name, tagged with its path position), and a later
type-resolution pass picks the best match by argument types. The path
position is retained so that, among equally good type matches, the one in
the earlier schema wins.
Lookup-then-lock and the TOCTOU race
Section titled “Lookup-then-lock and the TOCTOU race”The hardest pattern is correctness under concurrent DDL. Resolving a name to
an OID and then using that OID are two steps; between them the object can
vanish or be replaced (a classic time-of-check-to-time-of-use hazard). The
robust idiom is: resolve → lock the OID → check whether any catalog change
occurred while we waited for the lock → if so, re-resolve and compare. Only
when the re-resolution yields the same OID is it safe to proceed. PostgreSQL
implements exactly this in RangeVarGetRelidExtended.
Caching the derived path
Section titled “Caching the derived path”Parsing a search_path string, looking each name up in the catalog, running
ACL checks, and de-duplicating is expensive to repeat on every statement.
Engines cache the derived form — the ordered list of schema identifiers —
and invalidate it only when something that could change the answer changes:
the path string itself, a schema’s existence or name, the user’s identity
(which affects $user and ACL visibility), or role membership. The cache
must be coherent across backends, which ties it to the engine’s catalog
invalidation machinery.
PostgreSQL’s Approach
Section titled “PostgreSQL’s Approach”PostgreSQL concentrates schema name resolution in namespace.c, layered on
top of pg_namespace.c (which does the raw catalog DML). The header comment
draws the line precisely: this module “provides routines associated with
defining a ‘namespace search path’ and implementing search-path-controlled
searches,” while the sibling file “contains the routines that directly
manipulate the pg_namespace system catalog.”
Schemas are rows in pg_namespace
Section titled “Schemas are rows in pg_namespace”A schema is a tuple in pg_namespace with an OID, a nspname, an nspowner,
and an ACL. The bootstrap catalog ships three that matter here:
pg_catalog (the system schema, OID PG_CATALOG_NAMESPACE), pg_toast
(PG_TOAST_NAMESPACE), and public. Every schema-scoped catalog —
pg_class, pg_type, pg_proc, pg_operator, … — carries an
xxxnamespace column, and each has a unique index on (name, namespace).
That composite index is the resolution primitive; get_relname_relid,
GetSysCacheOid2(TYPENAMENSP, …), and the PROCNAMEARGSNSP syscache list
are all thin wrappers over a probe of one such index for a single
(name, schema) pair.
The effective path: three derived state variables
Section titled “The effective path: three derived state variables”The user-visible knob is the search_path GUC, stored as a raw string in
the global namespace_search_path. From it, namespace.c derives three
active state variables that the rest of the backend actually consults:
// active state — namespace.cstatic List *activeSearchPath = NIL; /* ordered list of namespace OIDs */static Oid activeCreationNamespace = InvalidOid; /* default CREATE target */static bool activeTempCreationPending = false; /* pg_temp is first but not yet made */activeSearchPath is the ordered list of OIDs that every visibility scan
walks. activeCreationNamespace is the first explicitly-listed schema — the
default target for CREATE TABLE foo with no schema qualifier.
activeTempCreationPending handles the awkward case where pg_temp is
written first in the path but the temp namespace has not been physically
created yet (it is created lazily, on first temp-table creation).
The derivation has a deliberate two-phase shape. preprocessNamespacePath
turns the string into a list of OIDs that pass ACL checks (the expensive,
catalog-touching part); finalNamespacePath then de-duplicates, runs the
namespace-search object-access hook, and prepends the implicit
namespaces — pg_catalog (always, unless explicitly placed) and the temp
namespace (if it exists). The split exists so the first, costly half can be
cached independently of the second, which must re-run whenever a hook is
installed.
flowchart TD GUC["search_path GUC string<br/>namespace_search_path"] --> PRE["preprocessNamespacePath<br/>split, $user / pg_temp expansion,<br/>get_namespace_oid, ACL_USAGE check"] PRE --> OIDLIST["oidlist: explicit schema OIDs<br/>(ACL-passing, may have dups)"] OIDLIST --> FIN["finalNamespacePath<br/>de-dup, search hook,<br/>prepend pg_catalog + pg_temp"] FIN --> ACTIVE["activeSearchPath (List of OIDs)<br/>activeCreationNamespace<br/>activeTempCreationPending"] ACTIVE --> SCAN["per-class visibility scans<br/>RelnameGetRelid, FuncnameGetCandidates, ..."] CACHE["search-path cache (simplehash)<br/>keyed by (search_path string, roleid)"] -. memoizes .-> PRE CACHE -. memoizes .-> FIN
Implicit namespaces and the SQL99 quirk
Section titled “Implicit namespaces and the SQL99 quirk”The file’s header comment is explicit about why pg_catalog is prepended
rather than appended:
// namespace.c header comment// 2. The system catalog namespace is always searched. If the system// namespace is present in the explicit path then it will be searched in// the specified order; otherwise it will be searched after TEMP tables and// *before* the explicit list. (It might seem that the system namespace// should be implicitly last, but this behavior appears to be required by// SQL99. ...)So the implicit order is: temp namespace → pg_catalog → explicit list.
A user who wants their own abs function to win over the built-in must
explicitly list pg_catalog after their schema in search_path; the
implicit prepend otherwise guarantees built-ins are found first.
For security, the temp namespace is special-cased: searches ignore it for every object class except relations and types (types must be allowed because temp tables have rowtypes). This stops a user from hijacking an unqualified function or operator name by planting a same-named object in their temp schema — a privilege-escalation vector PostgreSQL closed deliberately.
Unqualified resolution: an ordered scan
Section titled “Unqualified resolution: an ordered scan”The simplest resolver, RelnameGetRelid, captures the whole pattern: ensure
the path is current, then walk it in order and return the first hit.
// RelnameGetRelid — namespace.cOidRelnameGetRelid(const char *relname){ Oid relid; ListCell *l;
recomputeNamespacePath();
foreach(l, activeSearchPath) { Oid namespaceId = lfirst_oid(l);
relid = get_relname_relid(relname, namespaceId); if (OidIsValid(relid)) return relid; /* first match in path order wins */ } return InvalidOid; /* not found in path */}TypenameGetTypidExtended is “essentially the same as RelnameGetRelid”
(its own comment) but adds the temp_ok guard that skips the temp namespace
for non-relation contexts. The function and operator resolvers
(FuncnameGetCandidates, OpernameGetCandidates) follow the same skeleton
but accumulate a candidate list and record each candidate’s pathpos so
that overload resolution can later break ties by path order.
The reverse question: visibility
Section titled “The reverse question: visibility”The inverse of “what does this name resolve to?” is “is this OID visible
under its unqualified name?” — the question pg_table_is_visible() and
psql’s \d answer. RelationIsVisibleExt shows the subtlety: being in the
path is necessary but not sufficient, because an earlier schema might hold a
different relation of the same name that shadows it. So a quick
list_member_oid test is followed, when needed, by a slow scan that stops at
the first same-named relation and reports true only if that first hit is
the OID in question.
Two caches, one generation counter
Section titled “Two caches, one generation counter”Recomputing the derived path on every name lookup would be ruinous. PostgreSQL keeps two cooperating caches:
-
A search-path cache — a
simplehashtable (nsphash) keyed by the pair(search_path string, roleid), mapping to the already-validatedoidlistandfinalPath. Because the same handful ofsearch_pathstrings recur across statements and across functions withSET search_pathinproconfig, this memoizes the expensive parse-and-ACL-check work. -
A pair of
validflags —baseSearchPathValidandsearchPathCacheValid— that gate whether the active variables (and the whole cache) must be rebuilt.
On top of these sits activePathGeneration, a monotonically increasing
counter bumped only when the effective path actually changes.
SearchPathMatcher captures a path snapshot plus its generation; a later
SearchPathMatchesCurrentEnvironment call can short-circuit to true with a
single integer compare when nothing has changed — the common case for cached
plans that must re-validate they were planned under the same path.
Coherence is maintained by syscache invalidation callbacks.
InitializeSearchPath registers InvalidationCallback on NAMESPACEOID
(schema renamed or ACL changed), AUTHOID (role rename changes $user),
AUTHMEMROLEMEM (role membership changes ACLs), and DATABASEOID (database
owner change). Any such event clears both valid flags; the next lookup
rebuilds. Assigning a new value to the GUC clears only baseSearchPathValid
(via assign_search_path), not the cache — so flipping search_path
back to a previously-used value is nearly free.
flowchart TD
LOOKUP["name lookup<br/>RelnameGetRelid / Func.../Type..."] --> RECO["recomputeNamespacePath()"]
RECO --> VALID{"baseSearchPathValid<br/>&& namespaceUser == roleid?"}
VALID -->|yes| USE["use activeSearchPath as-is"]
VALID -->|no| CACHED["cachedNamespacePath(string, roleid)"]
CACHED --> SPCACHE{"entry in spcache<br/>& searchPathCacheValid?"}
SPCACHE -->|hit| FINAL["reuse oidlist / finalPath"]
SPCACHE -->|miss| BUILD["preprocess + final, insert entry"]
FINAL --> SET["set active*, bump activePathGeneration if changed"]
BUILD --> SET
INVAL["syscache inval:<br/>pg_namespace / pg_authid /<br/>role-membership / pg_database"] -->|InvalidationCallback| CLEAR["baseSearchPathValid=false<br/>searchPathCacheValid=false"]
ASSIGN["SET search_path = ..."] -->|assign_search_path| CLEARB["baseSearchPathValid=false<br/>(cache kept)"]
Source Walkthrough
Section titled “Source Walkthrough”This section walks the call flow from a raw RangeVar to a locked relation
OID, then through the per-class resolvers, the temp-namespace lifecycle, and
the cache/invalidation core. Symbols are the durable anchors; line numbers
live only in the position-hint table at the end.
Splitting a qualified name
Section titled “Splitting a qualified name”Every resolver begins by decomposing the dotted name. DeconstructQualifiedName
accepts a List of String nodes (1–3 elements), returns the schema and
object parts, and enforces that a 3-part name’s catalog component equals the
current database — PostgreSQL has no cross-database references.
// DeconstructQualifiedName — namespace.cswitch (list_length(names)){ case 1: objname = strVal(linitial(names)); break; case 2: schemaname = strVal(linitial(names)); objname = strVal(lsecond(names)); break; case 3: catalogname = strVal(linitial(names)); schemaname = strVal(lsecond(names)); objname = strVal(lthird(names)); if (strcmp(catalogname, get_database_name(MyDatabaseId)) != 0) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("cross-database references are not implemented: %s", NameListToString(names)))); break; default: ereport(ERROR, ... "improper qualified name (too many dotted names)" ...);}*nspname_p = schemaname; /* NULL means "no explicit schema → search path" */*objname_p = objname;A NULL schema is the signal to fall back to the search path; a non-NULL
schema means an explicit, exact lookup via LookupExplicitNamespace.
The relation resolver: RangeVarGetRelidExtended
Section titled “The relation resolver: RangeVarGetRelidExtended”RangeVarGetRelidExtended is the central entry point for resolving a table
name to an OID and locking it safely. It is the most concurrency-aware
routine in the file. Three branches choose the namespace: a RELPERSISTENCE_TEMP
RangeVar forces the temp namespace; an explicit schemaname calls
LookupExplicitNamespace; otherwise it searches the path via
RelnameGetRelid.
// RangeVarGetRelidExtended — namespace.c (namespace selection)if (relation->relpersistence == RELPERSISTENCE_TEMP){ if (!OidIsValid(myTempNamespace)) relId = InvalidOid; /* this probably can't happen? */ else { if (relation->schemaname) { namespaceId = LookupExplicitNamespace(relation->schemaname, missing_ok); if (namespaceId != myTempNamespace) ereport(ERROR, ... "temporary tables cannot specify a schema name" ...); } relId = get_relname_relid(relation->relname, myTempNamespace); }}else if (relation->schemaname){ namespaceId = LookupExplicitNamespace(relation->schemaname, missing_ok); if (missing_ok && !OidIsValid(namespaceId)) relId = InvalidOid; else relId = get_relname_relid(relation->relname, namespaceId);}else relId = RelnameGetRelid(relation->relname); /* search the path */The crucial wrapper around that selection is the lookup-then-lock retry
loop. It records SharedInvalidMessageCounter before resolving, takes the
lock (which itself processes pending invalidations), then checks whether the
counter moved. If it did, the name is re-resolved; if the new OID differs
from the one already locked, the stale lock is released and the loop repeats
until two consecutive resolutions agree.
// RangeVarGetRelidExtended — namespace.c (retry core)inval_count = SharedInvalidMessageCounter;/* ... resolve relId from name ... */if (callback) callback(relation, relId, oldRelId, callback_arg); /* perms before locking */if (lockmode == NoLock) break;if (retry){ if (relId == oldRelId) break; /* answer stable → done */ if (OidIsValid(oldRelId)) UnlockRelationOid(oldRelId, lockmode); /* locked the wrong one; undo */}if (!OidIsValid(relId)) AcceptInvalidationMessages(); /* flush negative catcache entries */else if (!(flags & (RVR_NOWAIT | RVR_SKIP_LOCKED))) LockRelationOid(relId, lockmode); /* also accepts invalidations *//* ... RVR_NOWAIT / RVR_SKIP_LOCKED conditional-lock branch ... */if (inval_count == SharedInvalidMessageCounter) break; /* nothing changed → done */retry = true;oldRelId = relId;The callback runs before the lock — the comment notes “it’s really best
to check permissions before locking anything!” — and is re-invoked on each
retry with the new OID, so permission checks track the object actually being
locked. The RVROption flags (RVR_MISSING_OK, RVR_NOWAIT,
RVR_SKIP_LOCKED) tune the not-found and lock-contention behaviour; the
familiar RangeVarGetRelid(rel, lockmode, missing_ok) macro is just this
function with no callback.
Choosing a creation namespace
Section titled “Choosing a creation namespace”For DDL, RangeVarGetCreationNamespace and
QualifiedNameGetCreationNamespace pick where to create. They honour an
explicit schema, special-case the pg_temp alias (forcing the temp namespace
into existence via AccessTempTableNamespace), and otherwise fall back to
activeCreationNamespace — erroring with “no schema has been selected to
create in” if the explicit path is empty.
// QualifiedNameGetCreationNamespace — namespace.cif (schemaname){ if (strcmp(schemaname, "pg_temp") == 0) { AccessTempTableNamespace(false); /* may CommandCounterIncrement */ return myTempNamespace; } namespaceId = get_namespace_oid(schemaname, false); /* no USAGE check here */}else{ recomputeNamespacePath(); if (activeTempCreationPending) { AccessTempTableNamespace(true); /* pg_temp was first → realize it now */ return myTempNamespace; } namespaceId = activeCreationNamespace; if (!OidIsValid(namespaceId)) ereport(ERROR, ... "no schema has been selected to create in" ...);}Explicit schema lookup and ACLs
Section titled “Explicit schema lookup and ACLs”LookupExplicitNamespace resolves a named schema and enforces ACL_USAGE,
so a user cannot peek into a schema they lack rights on. It also resolves the
pg_temp alias to the session temp namespace. LookupCreationNamespace is
its sibling but checks ACL_CREATE and is willing to materialize pg_temp.
get_namespace_oid is the bottom-level pg_namespace probe that both rest
on.
// LookupExplicitNamespace — namespace.cif (strcmp(nspname, "pg_temp") == 0){ if (OidIsValid(myTempNamespace)) return myTempNamespace; /* used only for existing objects; don't init temp ns here — fall through */}namespaceId = get_namespace_oid(nspname, missing_ok);if (missing_ok && !OidIsValid(namespaceId)) return InvalidOid;aclresult = object_aclcheck(NamespaceRelationId, namespaceId, GetUserId(), ACL_USAGE);if (aclresult != ACLCHECK_OK) aclcheck_error(aclresult, OBJECT_SCHEMA, nspname);InvokeNamespaceSearchHook(namespaceId, true); /* extension hook */return namespaceId;Overloaded objects: candidate sets
Section titled “Overloaded objects: candidate sets”FuncnameGetCandidates cannot return one OID — it returns a
FuncCandidateList. With an explicit schema it filters to that namespace;
otherwise it walks activeSearchPath, recording the candidate’s position
(pathpos) and excluding the temp namespace for security.
// FuncnameGetCandidates — namespace.c (path filtering)if (OidIsValid(namespaceId)){ if (procform->pronamespace != namespaceId) /* explicit schema given */ continue;}else{ ListCell *nsp; foreach(nsp, activeSearchPath) { if (procform->pronamespace == lfirst_oid(nsp) && procform->pronamespace != myTempNamespace) /* skip temp ns */ break; pathpos++; } if (nsp == NULL) continue; /* proc is not in search path */}The retained pathpos lets the later overload-resolution pass prefer the
earliest-in-path candidate when argument types tie. OpernameGetCandidates
is structurally identical for operators (searching OPERNAMENSP), with a
bulk-allocation optimization (SPACE_PER_OP) because operator names like =
have many candidates and per-candidate palloc showed up in parse-time
profiles.
The temp namespace lifecycle
Section titled “The temp namespace lifecycle”A backend has no temp namespace until the first temp object is created.
AccessTempTableNamespace records that the session touched a temp namespace
(XACT_FLAGS_ACCESSEDTEMPNAMESPACE) and lazily calls InitTempTableNamespace,
which checks ACL_CREATE_TEMP, refuses during recovery or in a parallel
worker, names the schema pg_temp_<MyProcNumber>, creates it (owned by the
bootstrap superuser) plus a matching pg_toast_temp_<N>, and publishes the
OID into MyProc->tempNamespaceId so other backends can see it is in use.
// InitTempTableNamespace — namespace.c (creation core)snprintf(namespaceName, sizeof(namespaceName), "pg_temp_%d", MyProcNumber);namespaceId = get_namespace_oid(namespaceName, true);if (!OidIsValid(namespaceId)){ namespaceId = NamespaceCreate(namespaceName, BOOTSTRAP_SUPERUSERID, true); CommandCounterIncrement(); /* make it visible */}else RemoveTempRelations(namespaceId); /* prior owner crashed; clean it out *//* ... create pg_toast_temp_<N> similarly ... */myTempNamespace = namespaceId;myTempToastNamespace = toastspaceId;MyProc->tempNamespaceId = namespaceId; /* advertise "in use" */myTempNamespaceSubID = GetCurrentSubTransactionId();baseSearchPathValid = false; /* path now includes temp ns */searchPathCacheValid = false;The creation is transactional: AtEOXact_Namespace either registers a
before_shmem_exit cleanup callback on commit, or, on abort, forgets the
namespace entirely (resetting myTempNamespace, the valid flags, and
MyProc->tempNamespaceId) so the next attempt starts clean.
isTempNamespace, isAnyTempNamespace (name-prefix test for any backend’s
temp schema), and checkTempNamespaceStatus (live/idle/in-use via
MyProc) round out the temp predicates. SetTempNamespaceState /
GetTempNamespaceState ferry the temp OIDs to parallel workers so a worker
shares the leader’s path.
Deriving and caching the path
Section titled “Deriving and caching the path”recomputeNamespacePath is the guard every resolver calls first. It
short-circuits when the path is already valid for the current role; otherwise
it pulls a (possibly cached) entry, copies the final path into
TopMemoryContext, and bumps activePathGeneration only if the effective
values changed.
// recomputeNamespacePath — namespace.cOid roleid = GetUserId();if (baseSearchPathValid && namespaceUser == roleid) return; /* fast path: nothing to do */entry = cachedNamespacePath(namespace_search_path, roleid);if (baseCreationNamespace == entry->firstNS && baseTempCreationPending == entry->temp_missing && equal(entry->finalPath, baseSearchPath)) pathChanged = false;else { /* copy entry->finalPath into TopMemoryContext, swap into baseSearchPath */ }baseSearchPathValid = true;namespaceUser = roleid;activeSearchPath = baseSearchPath; /* publish as active */activeCreationNamespace = baseCreationNamespace;activeTempCreationPending = baseTempCreationPending;if (pathChanged) activePathGeneration++; /* invalidate SearchPathMatchers */cachedNamespacePath is the simplehash front end: spcache_init /
spcache_insert find or create the (searchPath, roleid) entry, then
preprocessNamespacePath fills oidlist (string → ACL-checked OIDs, with
$user and pg_temp expansion) and finalNamespacePath fills finalPath
(de-dup, search hook, prepend implicit namespaces). The LastSearchPathCacheEntry
pointer memoizes the immediately-previous key so repeated lookups of the same
string skip even the hash probe.
// preprocessNamespacePath — namespace.c ($user / pg_temp / normal)if (strcmp(curname, "$user") == 0){ /* substitute the schema named like the current role, if USAGE-accessible */ tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid)); /* ... get_namespace_oid(rolename) + ACL_USAGE check → maybe lappend ... */}else if (strcmp(curname, "pg_temp") == 0){ if (OidIsValid(myTempNamespace)) oidlist = lappend_oid(oidlist, myTempNamespace); else if (oidlist == NIL) *temp_missing = true; /* pg_temp first but not yet created */}else{ namespaceId = get_namespace_oid(curname, true); if (OidIsValid(namespaceId) && object_aclcheck(NamespaceRelationId, namespaceId, roleid, ACL_USAGE) == ACLCHECK_OK) oidlist = lappend_oid(oidlist, namespaceId); /* silently drop inaccessible */}Names that do not resolve, or that the user cannot access, are silently
dropped — the GUC value was already accepted, so an error here is not
allowed. finalNamespacePath then prepends the implicit schemas onto the
front of the list:
// finalNamespacePath — namespace.c (implicit prepend)if (!list_member_oid(finalPath, PG_CATALOG_NAMESPACE)) finalPath = lcons_oid(PG_CATALOG_NAMESPACE, finalPath); /* pg_catalog first */if (OidIsValid(myTempNamespace) && !list_member_oid(finalPath, myTempNamespace)) finalPath = lcons_oid(myTempNamespace, finalPath); /* temp even earlier */GUC hooks and invalidation
Section titled “GUC hooks and invalidation”check_search_path validates only the syntax of the list (not that the
schemas exist — many valid configs name not-yet-created schemas) and uses the
cache to skip repeated SplitIdentifierString. assign_search_path merely
clears baseSearchPathValid (lazy recompute on next use) and intentionally
does not clear the cache. The syscache callback is the coherence
backbone:
// InvalidationCallback — namespace.cstatic voidInvalidationCallback(Datum arg, int cacheid, uint32 hashvalue){ /* schema/role/ACL change may alter resolution → rebuild everything */ baseSearchPathValid = false; searchPathCacheValid = false;}It is registered (in InitializeSearchPath) on NAMESPACEOID, AUTHOID,
AUTHMEMROLEMEM, and DATABASEOID. GetSearchPathMatcher /
SearchPathMatchesCurrentEnvironment use activePathGeneration for an
O(1) “is this cached plan still planned under the right path?” check, falling
back to an element-wise compare only when the generation differs.
fetch_search_path and fetch_search_path_array expose the active path to
SQL functions like current_schemas().
Position hints (as of 2026-06-05, REL_18 273fe94)
Section titled “Position hints (as of 2026-06-05, REL_18 273fe94)”| Symbol | Kind | File | Line |
|---|---|---|---|
activeSearchPath / activeCreationNamespace / activeTempCreationPending | static state | src/backend/catalog/namespace.c | 136 / 139 / 142 |
activePathGeneration | static counter | src/backend/catalog/namespace.c | 145 |
baseSearchPathValid | static flag | src/backend/catalog/namespace.c | 158 |
searchPathCacheValid / SearchPathCacheContext | static cache state | src/backend/catalog/namespace.c | 164 / 165 |
SearchPathCacheKey / SearchPathCacheEntry | struct | src/backend/catalog/namespace.c | 167 / 173 |
myTempNamespace / myTempToastNamespace / myTempNamespaceSubID | static state | src/backend/catalog/namespace.c | 201 / 203 / 205 |
namespace_search_path | GUC string | src/backend/catalog/namespace.c | 211 |
spcachekey_hash / spcachekey_equal | static fn | src/backend/catalog/namespace.c | 253 / 273 |
SPCACHE_RESET_THRESHOLD | macro | src/backend/catalog/namespace.c | 297 |
spcache_init / spcache_lookup / spcache_insert | static fn | src/backend/catalog/namespace.c | 306 / 344 / 374 |
RangeVarGetRelidExtended | fn | src/backend/catalog/namespace.c | 441 |
RangeVarGetCreationNamespace | fn | src/backend/catalog/namespace.c | 654 |
RelnameGetRelid | fn | src/backend/catalog/namespace.c | 885 |
RelationIsVisibleExt | static fn | src/backend/catalog/namespace.c | 925 |
TypenameGetTypidExtended | fn | src/backend/catalog/namespace.c | 1008 |
FuncnameGetCandidates | fn | src/backend/catalog/namespace.c | 1192 |
OpernameGetCandidates | fn | src/backend/catalog/namespace.c | 1888 |
DeconstructQualifiedName | fn | src/backend/catalog/namespace.c | 3304 |
LookupNamespaceNoError | fn | src/backend/catalog/namespace.c | 3358 |
LookupExplicitNamespace | fn | src/backend/catalog/namespace.c | 3388 |
LookupCreationNamespace | fn | src/backend/catalog/namespace.c | 3431 |
CheckSetNamespace | fn | src/backend/catalog/namespace.c | 3462 |
QualifiedNameGetCreationNamespace | fn | src/backend/catalog/namespace.c | 3490 |
get_namespace_oid | fn | src/backend/catalog/namespace.c | 3538 |
isTempNamespace / isAnyTempNamespace / isOtherTempNamespace | fn | src/backend/catalog/namespace.c | 3652 / 3690 / 3713 |
checkTempNamespaceStatus | fn | src/backend/catalog/namespace.c | 3732 |
GetTempNamespaceState / SetTempNamespaceState | fn | src/backend/catalog/namespace.c | 3808 / 3824 |
GetSearchPathMatcher / SearchPathMatchesCurrentEnvironment | fn | src/backend/catalog/namespace.c | 3855 / 3914 |
preprocessNamespacePath / finalNamespacePath | static fn | src/backend/catalog/namespace.c | 4110 / 4201 |
cachedNamespacePath | static fn | src/backend/catalog/namespace.c | 4247 |
recomputeNamespacePath | static fn | src/backend/catalog/namespace.c | 4302 |
AccessTempTableNamespace / InitTempTableNamespace | static fn | src/backend/catalog/namespace.c | 4365 / 4393 |
AtEOXact_Namespace | fn | src/backend/catalog/namespace.c | 4515 |
check_search_path / assign_search_path | GUC hook | src/backend/catalog/namespace.c | 4660 / 4716 |
InvalidationCallback | static fn | src/backend/catalog/namespace.c | 4799 |
fetch_search_path / fetch_search_path_array | fn | src/backend/catalog/namespace.c | 4822 / 4862 |
SearchPathMatcher / RVROption | struct / enum | src/include/catalog/namespace.h | 59 / 70 |
Source verification (as of 2026-06-05)
Section titled “Source verification (as of 2026-06-05)”All claims above were checked against the working tree at
/data/hgryoo/references/postgres, branch REL_18_STABLE, commit
273fe94852b (dated 2026-06-05). Verification notes:
-
Active-state triple.
activeSearchPath,activeCreationNamespace, andactiveTempCreationPendingare defined as file-scope statics around lines 136–142, exactly as quoted. The header block (lines 65–131) documents the implicit-namespace ordering and theactiveTempCreationPendingkluge verbatim. -
Lookup-then-lock loop. The
for (;;)retry inRangeVarGetRelidExtended(from line 483) comparesinval_countagainstSharedInvalidMessageCounter, releases a staleoldRelIdlock on OID change, and invokes the user callback before locking — confirmed line by line. TheRVROptionenum and theRangeVarGetRelidconvenience macro are innamespace.h(lines 70–82). -
Implicit prepend order.
finalNamespacePathlcons_oidsPG_CATALOG_NAMESPACEfirst and thenmyTempNamespace, so the live list order is[temp, pg_catalog, …explicit]— matching the header comment’s SQL99 rationale. The temp-namespace security exclusion is enforced inFuncnameGetCandidates(the!= myTempNamespaceguard) and via thetemp_okparameter ofTypenameGetTypidExtended. -
Temp lifecycle.
InitTempTableNamespacenames the schemapg_temp_%donMyProcNumber, creates it owned byBOOTSTRAP_SUPERUSERID, setsMyProc->tempNamespaceId, and clears bothvalidflags — all present. The recovery/parallel-worker refusals and theACL_CREATE_TEMPcheck precede creation as quoted.AtEOXact_NamespaceregistersRemoveTempRelationsCallbackviabefore_shmem_exiton commit. -
Two-cache coherence. The
nsphashsimplehash is instantiated fromlib/simplehash.hwithSH_PREFIX nsphash;SPCACHE_RESET_THRESHOLDis 256.recomputeNamespacePathbumpsactivePathGenerationonly on a real change.InvalidationCallbackclears both flags and is registered onNAMESPACEOID,AUTHOID,AUTHMEMROLEMEM,DATABASEOIDin the initialization path (theCacheRegisterSyscacheCallbackblock near line 4769).assign_search_pathclears onlybaseSearchPathValid, leaving the cache intact — confirmed by its comment “This does not invalidate the search path cache.” -
Scope boundary. Raw
pg_namespacecatalog DML (NamespaceCreate, schema rename/drop) lives inpg_namespace.c, not this file, consistent with the header’s division of labour. Adjacent caching of relation descriptors is in the relcache; ACL evaluation detail is inacl.c. Those are cross-referenced, not duplicated, below.
Beyond PostgreSQL — Comparative Designs & Research Frontiers
Section titled “Beyond PostgreSQL — Comparative Designs & Research Frontiers”The path is the lineage of System R’s catalog manager
Section titled “The path is the lineage of System R’s catalog manager”PostgreSQL’s name-resolution shape is a direct descendant of the System R
catalog manager. The System R architecture paper (Astrahan et al., 1976;
captured locally as research/dbms-papers/systemr.md) describes the RDS
(Relational Data System) layer keeping a self-describing catalog — relations
about relations — that the SQL compiler consults to bind names to internal
identifiers during statement preparation. PostgreSQL inherits both the
self-describing catalog (pg_namespace, pg_class, pg_proc are ordinary
heap relations) and the bind-at-parse-time discipline. Where System R bound
into a static access module, PostgreSQL re-binds on each parse but caches the
derived path aggressively to recover the performance a static bind would
have given. The Selinger access-path-selection paper
(research/dbms-papers/systemr-optimizer.md) is downstream of this: the
optimizer only ever sees OIDs, never names, because resolution has already
collapsed the name space by the time planning begins — a clean separation of
concerns that PostgreSQL preserves.
Overload resolution as a candidate-set problem
Section titled “Overload resolution as a candidate-set problem”The decision to return a FuncCandidateList rather than a single OID places
PostgreSQL in the lineage of type-driven overload resolution. SQL:1999
standardized schema-qualified routine resolution with a function path
(CURRENT_PATH in DB2), and PostgreSQL’s pathpos tagging is precisely the
tie-breaker the standard’s “most specific” rule needs when two candidates are
equally good on argument types. The architectural insight — separate
name visibility (which candidates are in scope) from best-match selection
(which candidate wins) — keeps namespace.c ignorant of the type system: it
collects everything visible and hands off. Architecture of a Database
System frames this two-stage binding as the norm for extensible engines,
where the set of functions is open-ended and cannot be hard-coded into the
resolver.
search_path as both a feature and an attack surface
Section titled “search_path as both a feature and an attack surface”The dynamic, mutable search path is unusually powerful — and the source of a
well-known class of PostgreSQL security advisories. Because an unqualified
function call resolves against the caller’s path, a SECURITY DEFINER
function that does not pin its own search_path can be tricked into calling
an attacker-planted function from a writable schema earlier in the path. The
temp-namespace exclusion in FuncnameGetCandidates/OpernameGetCandidates
closes one vector (you cannot shadow a function via pg_temp), and the
proconfig SET search_path mechanism — for which the simplehash cache was
specifically built — lets a function fix its resolution environment. This is
a live design tension: maximum flexibility (per-session, per-function paths)
versus the principle that name resolution should be predictable and
non-exploitable. Modern guidance (“always schema-qualify or SET search_path in SECURITY DEFINER”) is the operational residue of a
resolver that chose dynamism.
How other engines draw the line
Section titled “How other engines draw the line”- Oracle uses a single
CURRENT_SCHEMAplus public synonyms rather than an ordered list; resolution consults the current schema, then private synonyms, then public synonyms, then theSYSdictionary. The synonym indirection layer does work PostgreSQL pushes into the path ordering. Oracle has no per-session reorderable list, trading flexibility for a simpler, harder-to-exploit resolution rule. - SQL Server binds a default schema per database user and resolves
unqualified names as
user_default_schema → dbo. The two-level fallback is a degenerate two-element path with no user control over order — closer to the SQL standard’s “default schema” than to PostgreSQL’s list. - DB2 mirrors PostgreSQL most closely for functions, with an ordered
CURRENT PATH, but keeps table resolution on a singleCURRENT SCHEMA. PostgreSQL is unusual in unifying tables, types, functions, and operators under one ordered path. - Distributed/columnar systems (the catalog discussions in BigTable and
Dremel,
research/dbms-papers/bigtable.md,dremel.md) generally flatten or externalize the namespace into a separate metadata service, because a per-session mutable path does not survive a stateless, massively-parallel query layer well. PostgreSQL’sSetTempNamespaceState/parallel-worker path-propagation is the small concession it makes toward that world: a worker must be handed the leader’s path because it cannot derive a session-local one.
Research frontiers
Section titled “Research frontiers”The interesting open problems are less about resolution semantics (settled
since System R) and more about caching and invalidation at scale.
PostgreSQL’s per-backend simplehash plus generation-counter design is optimal
for a process-per-connection server with a handful of distinct paths. Two
frontiers stretch it: (1) thousands of tenants each with a distinct
search_path (multi-tenant SaaS on shared PostgreSQL) pushes the
SPCACHE_RESET_THRESHOLD reset logic and motivates research into shared,
cross-backend path caches — which would need the invalidation discipline of a
scalable lock manager (research/dbms-papers/scalable-lock-manager.md)
applied to catalog metadata. (2) Connection pooling breaks the
per-backend assumption: a pooled connection’s path is reset between clients,
turning the “few distinct strings” assumption into “many transient strings,”
which is exactly the steady-state-growth case the threshold reset was built
to bound. Both are engineering-pressure points where PostgreSQL’s elegant
single-file resolver meets deployment realities it was not originally shaped
for.
Sources
Section titled “Sources”-
Primary source code (PostgreSQL
REL_18_STABLE, commit273fe94852b, 2026-06-05, at/data/hgryoo/references/postgres):src/backend/catalog/namespace.c— the entire resolution, visibility, temp-namespace, caching, and invalidation subsystem analysed here.src/include/catalog/namespace.h—SearchPathMatcher,RVROption, theRangeVarGetRelidmacro, and public prototypes.src/include/catalog/pg_namespace.h— thepg_namespacecatalog form and thePG_CATALOG_NAMESPACE/PG_TOAST_NAMESPACEOID macros.src/backend/catalog/pg_namespace.c— raw catalog DML (NamespaceCreate), referenced for the scope boundary; not analysed in depth.lib/simplehash.h— thensphashtemplate backing the search-path cache.
-
Textbook anchors (
knowledge/research/dbms-general/):- Database System Concepts (Silberschatz, Korth, Sudarshan) — “Schemas, Catalogs, and Environments” for the three-level naming hierarchy and default-schema semantics.
- Architecture of a Database System (Hellerstein, Stonebraker, Hamilton),
fntdb07-architecture.md— query parsing/authorization stage, catalog manager, and two-stage name binding.
-
Paper anchors (
knowledge/research/dbms-papers/):- System R (Astrahan et al., 1976),
systemr.md— self-describing catalog and bind-at-compile discipline that PostgreSQL’s resolver descends from. - Selinger et al. (1979),
systemr-optimizer.md— the optimizer operates on OIDs post-resolution; clean name/plan separation. scalable-lock-manager.md,bigtable.md,dremel.md— comparative framing for catalog-metadata caching and invalidation at scale.
- System R (Astrahan et al., 1976),
-
Cross-references (sibling docs in this folder):
postgres-system-catalogs.md—pg_namespaceamong the catalogs; bootstrap schema layout. (Deferred adjacent: raw catalog structure.)postgres-relcache.md— relation descriptor caching that consumes the OIDs this resolver produces. (Deferred adjacent: relation cache.)postgres-catcache-syscache.md— theRELOID/TYPENAMENSP/PROCNAMEARGSNSPsyscaches the per-class scans probe.postgres-cache-invalidation.md—SharedInvalidMessageCounterand theCacheRegisterSyscacheCallbackmachinery drivingInvalidationCallback.postgres-lock-manager.md—LockRelationOid/ConditionalLockRelationOidused by the lookup-then-lock loop.postgres-parser.md— produces theRangeVar/Listnames this module resolves.postgres-guc-parameters.md— thecheck_hook/assign_hookGUC machinerycheck_search_path/assign_search_pathplug into.