Now-Next

SaaS architecture

operator does not exist: uuid = text in PostgreSQL

Measured on PostgreSQL 17: why uuid = text fails, the current_setting cast in an RLS policy, which join side to cast for the index, and psycopg2 vs psycopg 3.

Chris van Eijk, Now-Next · · 6 min read

ERROR: operator does not exist: uuid = text (SQLSTATE 42883) means a comparison has a uuid on one side and a value PostgreSQL already knows to be text on the other, and there is no = for that pair. There is no implicit cast between them: uuid to text was allowed on assignment (an insert of a uuid into a text column worked), text to uuid only explicitly. A quoted literal works (tenant_id = 'a0ee…'), because a literal has no type yet; a text column, a ::text cast, the result of current_setting() or a text parameter does not. The fix is an explicit cast — and which side you cast decides whether the index is used.

We measured each case on PostgreSQL 17.11, with psycopg2 2.9.13 and psycopg 3.3.6 for the driver cases. In a multi-tenant database the most common source is the tenant id from current_setting() in a row-level security policy.

Why does current_setting() give uuid = text?

Because current_setting() always returns text. On a table with tenant_id uuid:

select id from invoices where tenant_id = current_setting('app.tenant_id');
ERROR:  42883: operator does not exist: uuid = text
LINE 1: select id from invoices where tenant_id = current_setting('a...
                                                ^
HINT:  No operator matches the given name and argument types. You might need to add explicit type casts.

In a policy it fails earlier: create policy p on invoices using (tenant_id = current_setting('app.tenant_id')) was refused at CREATE POLICY, with the same 42883 and without the LINE pointer. The cast is what makes it compile: tenant_id = current_setting('app.tenant_id', true)::uuid. It has four outcomes depending on the session:

Sessioncurrent_setting('app.tenant_id', true)Query under the policy
setting set to a tenant’s uuidthat uuidthat tenant’s rows
fresh session, setting never setNULL0 rows, no error
after RESET, after a committed set_config(…, true) or SET LOCAL, or after set … = ''''22P02: invalid input syntax for type uuid: ""
without , true, fresh session—42704: unrecognized configuration parameter "app.tenant_id"

The third row is the one that surprises, and the one a connection pool produces: once anything in the session has named the setting — a SET, a RESET, a set_config whose transaction has ended — it reads as an empty string, not NULL. Written as nullif(current_setting('app.tenant_id', true), '')::uuid, the policy gave 0 rows for a fresh session, 0 after a reset and the tenant’s rows once the setting was set. Why, and how to write the policy so an unset tenant gives no rows instead of an error, is in current_setting and set_config in PostgreSQL and invalid input syntax for type uuid.

Why does a literal work and a variable not?

The right-hand sideType PostgreSQL seesResult
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'unknownworks
'a0eebc99-…'::texttext42883
a text column, from a table or a CTEtext42883
'a0eebc99-' || '…'text42883
'abc'unknown22P02: invalid input syntax for type uuid: "abc" — a different error: the cast was tried
in ('…', '…') or = any('{…}')unknownworks
= any(array['…', '…'])text[]42883

The same applies to other pairs: integer = text, bigint = text, uuid = character varying, text = uuid and uuid = integer all gave 42883, and so did tenant_id like 'a0ee%' (uuid ~~ unknown): there is no LIKE for uuid.

Which side should you cast in a join?

Cast the text side to uuid, if every value in it is a valid uuid. On 100,000 rows with a btree index on invoices.tenant_id:

ComparisonPlanCatch
i.tenant_id = l.tenant_id::uuidNested Loop with an Index Scan on invoices (for a one-row legacy table)an invalid value ('n/a') that the query reaches makes it fail with 22P02 — whether it reaches it depends on the plan: with a filter that removed the bad row first it worked, without it a Merge Join failed. case when pg_input_is_valid(l.tenant_id, 'uuid') then l.tenant_id::uuid end (16 and later) worked in both
i.tenant_id::text = l.tenant_idHash Join with a Seq Scan over all 100,000 rowsalways works
where i.tenant_id::text = '…'Seq Scan, Rows Removed by Filter: 99999always works
the same, with an expression index on ((tenant_id::text))Index Scanan extra index to maintain

Casting the uuid column to text is the fix that always runs and never uses the index on that column. In a multi-tenant table where every query filters on tenant_id, that is the difference between an index lookup and a scan of every tenant’s rows. Clean up the legacy column, or give it a proper uuid type, rather than casting the indexed side.

Where the text comes from, and the cast that fixes it

What is on the text side of the comparison?

  • current_setting(…), in a query or a policyCast the settingnullif(current_setting('app.tenant_id', true), '')::uuid, which also survives a reset.
  • A text column in a joinCast that column to uuidKeeps the index on the uuid side; fails on an invalid value the query reaches.
  • A parameter from the applicationCheck how the driver sends itA typed text parameter or a text[] array fails; see the driver table.
  • A function or prepared statement parameter declared textDeclare it uuidprepare q(uuid) worked, prepare q(text) gave 42883.

Each branch measured on PostgreSQL 17.11.

Does the driver matter?

For a single value with %s, no; for lists, yes. psycopg2 interpolates parameters into the SQL text on the client, so a Python str arrives as a quoted literal of type unknown, and where tenant_id = %s worked. psycopg 3 binds on the server, and sends a str with type unknown too (OID 0), so the same line worked there as well, also with prepare=True, after automatic preparation and with prepare_threshold=0. Only the binary placeholder %b, or a parameter inside an expression such as coalesce(%s, …) (in both drivers), made it text and failed. The same mechanism on an insert gives a different message, column … is of type jsonb but expression is of type text; that one is in column is of type but expression is of type.

Parameterpsycopg2 2.9.13psycopg 3.3.6
str in tenant_id = %sworksworks
uuid.UUID in tenant_id = %sProgrammingError: can't adapt type 'UUID' until psycopg2.extras.register_uuid(); then worksworks
list[str] in tenant_id = any(%s)42883: it becomes ARRAY['…'], a text[]works
a uuid.UUID passed to set_config('app.tenant_id', %s, true)after register_uuid(): 42883, function set_config(unknown, uuid, boolean) does not exist; before: can't adapt42883; %s::text worked

So code that filters a list of tenant ids can work on psycopg 3 and fail on psycopg2, unchanged. And the tenant id for set_config has to be passed as a string: the function takes text. How a transaction-scoped tenant setting behaves behind a pool is in PgBouncer and row-level security.

Functions and prepared statements

A SQL function f(p text) whose body said where tenant_id = p failed at CREATE FUNCTION with a $$ body, or at the call with check_function_bodies = off; with begin atomic it failed at CREATE regardless. A PL/pgSQL function was created and failed when called. prepare q(text) as … where tenant_id = $1 failed; prepare q(uuid) worked, and prepare q as … = $1 without a type inferred uuid and worked.

What we did not measure

Go, Node and Java drivers. ORMs that map uuid columns themselves. Performance of the expression index under writes.

All measurements are our own, on PostgreSQL 17.11 in containers, with psycopg2 2.9.13 and psycopg 3.3.6, on 22 October 2026.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation