Skip to content

PostgreSQL Schemas & search_path — Name Resolution and the Namespace Cache

Contents:

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:

  1. 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?

  2. 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.

  3. Stability under concurrency. Name resolution reads the catalog, but the catalog is mutable: while one backend resolves orders, another may DROP or RENAME it, or create a new orders earlier 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.

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.

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.

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.

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.

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 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.”

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.c
static 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 namespacespg_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

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.

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.c
Oid
RelnameGetRelid(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 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.

Recomputing the derived path on every name lookup would be ruinous. PostgreSQL keeps two cooperating caches:

  1. A search-path cache — a simplehash table (nsphash) keyed by the pair (search_path string, roleid), mapping to the already-validated oidlist and finalPath. Because the same handful of search_path strings recur across statements and across functions with SET search_path in proconfig, this memoizes the expensive parse-and-ACL-check work.

  2. A pair of valid flags — baseSearchPathValid and searchPathCacheValid — 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)"]

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.

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.c
switch (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.

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.c
if (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" ...);
}

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.c
if (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;

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.

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.

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.c
Oid 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 */

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.c
static void
InvalidationCallback(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)”
SymbolKindFileLine
activeSearchPath / activeCreationNamespace / activeTempCreationPendingstatic statesrc/backend/catalog/namespace.c136 / 139 / 142
activePathGenerationstatic countersrc/backend/catalog/namespace.c145
baseSearchPathValidstatic flagsrc/backend/catalog/namespace.c158
searchPathCacheValid / SearchPathCacheContextstatic cache statesrc/backend/catalog/namespace.c164 / 165
SearchPathCacheKey / SearchPathCacheEntrystructsrc/backend/catalog/namespace.c167 / 173
myTempNamespace / myTempToastNamespace / myTempNamespaceSubIDstatic statesrc/backend/catalog/namespace.c201 / 203 / 205
namespace_search_pathGUC stringsrc/backend/catalog/namespace.c211
spcachekey_hash / spcachekey_equalstatic fnsrc/backend/catalog/namespace.c253 / 273
SPCACHE_RESET_THRESHOLDmacrosrc/backend/catalog/namespace.c297
spcache_init / spcache_lookup / spcache_insertstatic fnsrc/backend/catalog/namespace.c306 / 344 / 374
RangeVarGetRelidExtendedfnsrc/backend/catalog/namespace.c441
RangeVarGetCreationNamespacefnsrc/backend/catalog/namespace.c654
RelnameGetRelidfnsrc/backend/catalog/namespace.c885
RelationIsVisibleExtstatic fnsrc/backend/catalog/namespace.c925
TypenameGetTypidExtendedfnsrc/backend/catalog/namespace.c1008
FuncnameGetCandidatesfnsrc/backend/catalog/namespace.c1192
OpernameGetCandidatesfnsrc/backend/catalog/namespace.c1888
DeconstructQualifiedNamefnsrc/backend/catalog/namespace.c3304
LookupNamespaceNoErrorfnsrc/backend/catalog/namespace.c3358
LookupExplicitNamespacefnsrc/backend/catalog/namespace.c3388
LookupCreationNamespacefnsrc/backend/catalog/namespace.c3431
CheckSetNamespacefnsrc/backend/catalog/namespace.c3462
QualifiedNameGetCreationNamespacefnsrc/backend/catalog/namespace.c3490
get_namespace_oidfnsrc/backend/catalog/namespace.c3538
isTempNamespace / isAnyTempNamespace / isOtherTempNamespacefnsrc/backend/catalog/namespace.c3652 / 3690 / 3713
checkTempNamespaceStatusfnsrc/backend/catalog/namespace.c3732
GetTempNamespaceState / SetTempNamespaceStatefnsrc/backend/catalog/namespace.c3808 / 3824
GetSearchPathMatcher / SearchPathMatchesCurrentEnvironmentfnsrc/backend/catalog/namespace.c3855 / 3914
preprocessNamespacePath / finalNamespacePathstatic fnsrc/backend/catalog/namespace.c4110 / 4201
cachedNamespacePathstatic fnsrc/backend/catalog/namespace.c4247
recomputeNamespacePathstatic fnsrc/backend/catalog/namespace.c4302
AccessTempTableNamespace / InitTempTableNamespacestatic fnsrc/backend/catalog/namespace.c4365 / 4393
AtEOXact_Namespacefnsrc/backend/catalog/namespace.c4515
check_search_path / assign_search_pathGUC hooksrc/backend/catalog/namespace.c4660 / 4716
InvalidationCallbackstatic fnsrc/backend/catalog/namespace.c4799
fetch_search_path / fetch_search_path_arrayfnsrc/backend/catalog/namespace.c4822 / 4862
SearchPathMatcher / RVROptionstruct / enumsrc/include/catalog/namespace.h59 / 70

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, and activeTempCreationPending are defined as file-scope statics around lines 136–142, exactly as quoted. The header block (lines 65–131) documents the implicit-namespace ordering and the activeTempCreationPending kluge verbatim.

  • Lookup-then-lock loop. The for (;;) retry in RangeVarGetRelidExtended (from line 483) compares inval_count against SharedInvalidMessageCounter, releases a stale oldRelId lock on OID change, and invokes the user callback before locking — confirmed line by line. The RVROption enum and the RangeVarGetRelid convenience macro are in namespace.h (lines 70–82).

  • Implicit prepend order. finalNamespacePath lcons_oids PG_CATALOG_NAMESPACE first and then myTempNamespace, so the live list order is [temp, pg_catalog, …explicit] — matching the header comment’s SQL99 rationale. The temp-namespace security exclusion is enforced in FuncnameGetCandidates (the != myTempNamespace guard) and via the temp_ok parameter of TypenameGetTypidExtended.

  • Temp lifecycle. InitTempTableNamespace names the schema pg_temp_%d on MyProcNumber, creates it owned by BOOTSTRAP_SUPERUSERID, sets MyProc->tempNamespaceId, and clears both valid flags — all present. The recovery/parallel-worker refusals and the ACL_CREATE_TEMP check precede creation as quoted. AtEOXact_Namespace registers RemoveTempRelationsCallback via before_shmem_exit on commit.

  • Two-cache coherence. The nsphash simplehash is instantiated from lib/simplehash.h with SH_PREFIX nsphash; SPCACHE_RESET_THRESHOLD is 256. recomputeNamespacePath bumps activePathGeneration only on a real change. InvalidationCallback clears both flags and is registered on NAMESPACEOID, AUTHOID, AUTHMEMROLEMEM, DATABASEOID in the initialization path (the CacheRegisterSyscacheCallback block near line 4769). assign_search_path clears only baseSearchPathValid, leaving the cache intact — confirmed by its comment “This does not invalidate the search path cache.”

  • Scope boundary. Raw pg_namespace catalog DML (NamespaceCreate, schema rename/drop) lives in pg_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 in acl.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.

  • Oracle uses a single CURRENT_SCHEMA plus public synonyms rather than an ordered list; resolution consults the current schema, then private synonyms, then public synonyms, then the SYS dictionary. 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 single CURRENT 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’s SetTempNamespaceState/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.

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.

  • Primary source code (PostgreSQL REL_18_STABLE, commit 273fe94852b, 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.hSearchPathMatcher, RVROption, the RangeVarGetRelid macro, and public prototypes.
    • src/include/catalog/pg_namespace.h — the pg_namespace catalog form and the PG_CATALOG_NAMESPACE / PG_TOAST_NAMESPACE OID macros.
    • src/backend/catalog/pg_namespace.c — raw catalog DML (NamespaceCreate), referenced for the scope boundary; not analysed in depth.
    • lib/simplehash.h — the nsphash template 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.
  • Cross-references (sibling docs in this folder):

    • postgres-system-catalogs.mdpg_namespace among 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 — the RELOID/TYPENAMENSP/ PROCNAMEARGSNSP syscaches the per-class scans probe.
    • postgres-cache-invalidation.mdSharedInvalidMessageCounter and the CacheRegisterSyscacheCallback machinery driving InvalidationCallback.
    • postgres-lock-manager.mdLockRelationOid / ConditionalLockRelationOid used by the lookup-then-lock loop.
    • postgres-parser.md — produces the RangeVar/List names this module resolves.
    • postgres-guc-parameters.md — the check_hook/assign_hook GUC machinery check_search_path/assign_search_path plug into.