relation does not exist for a Table That Is Definitely There

The query fails with relation does not exist while the table sits in the schema browser a click away. The table never moved.

Share
relation does not exist for a Table That Is Definitely There. Abstract error autopsy illustration in orange and dark grey on debugly.dev

The migration ran, the application restarted, and a query that had worked for years began failing with ERROR: relation "orders" does not exist, SQLSTATE 42P01. The table was visible in every client, in the schema browser, in the psql tab right next to the error. It existed. The query simply could not see it, which is a different claim, and the difference is search_path.

An unqualified table name is not an address. It is a question, "find me orders", and search_path is the list of places the question is asked, in order. When the list changes, the same name resolves differently or not at all, while the table itself never moves. This is the whole bug.

This was Postgres 16.3 behind PgBouncer 1.23, and the change that broke it was a connection string tweak that altered which role settings applied.

The quick version

Postgres resolves an unqualified relation by walking search_path, defaulting to the user's schema then public. If the table lives in a schema not on the path, or the path no longer includes the schema it used to, the name does not resolve and 42P01 fires, even though the object exists.

So the error is a naming failure, not an existence failure, and the fix is about names and paths, not about the table.

Why it breaks after a change that looks unrelated

search_path is not only a server default. It can be set at the database level, the role level, the session level, and by the connection pooler, and these layer with the most specific winning. That layering is exactly where the incident lives.

A role change. If the app connects as a new role, or the pooler now uses a different role for some connections, the role level search_path applies, and it may differ from the old role's. The same SQL, a different resolver list.

A pooler reset. PgBouncer in transaction mode resets session state between transactions, and depending on configuration it may discard a search_path the application set at connect, returning the session to the default. The app believed it had set the path at startup, and the pooler politely forgot, which is the session state trap from PgBouncer transaction mode.

A connection string option. Adding an options parameter or changing how the driver applies currentSchema style settings can replace a path that included the app schema with one that does not.

In each case the table is fine and the name is fine, and the resolver list between them changed.

Seeing the path

Ask the session what it is resolving against:

SHOW search_path;
SELECT current_schema();

And for the table, ask where it actually lives:

SELECT schemaname, tablename FROM pg_tables WHERE tablename = 'orders';

When the schema from the second query is not in the list from the first, the diagnosis is complete. Run both through the same connection path the application uses, including the pooler, because running them in your direct psql session shows your path, not the app's, and the two are precisely what differ.

The fix, in order

Qualify the names. The durable fix is to stop asking the question and state the address: app_schema.orders. Qualified names do not consult search_path, so they are immune to every layer of the bug. The migration to qualification is mechanical and removes the whole class, which is the same instinct as pinning the algorithm at the call site in the JWT library that accepted alg none, where the lesson is to make the security relevant choice explicit rather than ambient.

Set the path where it survives. If qualification is not feasible now, set search_path at the layer that the pooler preserves, or on the role the app uses, so every session inherits it. Setting it once in application code at connect is the fragile variant, because poolers and reconnects discard it, and the failure returns at the worst moment.

Audit the layering. List the database level, role level and pooler contributions, and make them agree, so that no combination of connection paths yields a different resolver list. The disagreement between layers is the defect, and the audit is the fix.

The trap inside the trap

A subtler variant is worth naming because it produces wrong data instead of an error. If two schemas on the path both contain a table of the same name, the unqualified query resolves to the first one and silently reads the wrong table. That is 42P01's dangerous sibling: the name resolves, to a table that exists, that is not the one you meant. Shadowing is why qualification is the fix and not merely a style preference, and why a search_path that lists several app schemas is a loaded gun.

If it happens again

An unqualified relation name is resolved through a layered, session specific search_path, and any change to the role, the pooler or the connection string can change the resolution while the table stands still. Diagnose by comparing the session's path, through the real connection path, against the table's actual schema, and fix by qualifying names or by making the path agree at every layer.

The error says the relation does not exist. The truth is that the question was asked in the wrong rooms. The generic plan cousin, where the same SQL means different things in different execution modes, is the slow query that ran in four milliseconds under EXPLAIN.

The migration habit that prevents it

New schemas and new services are where this class is born, so the habit belongs at creation time. Give every service schema a name that is not public, qualify it in the service's own SQL from the first commit, and set the role level path only as a convenience for interactive sessions, never as the thing the application relies on. A codebase that starts qualified stays immune to every future pooler, role or connection string change, because none of those can move a name that was never ambient. The cost is a schema prefix on table names, and the payment is that 42P01 stops being a failure mode your on call has to know about.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud