Limitations
What PostgREST can't do, and what better-supabase does instead.
Most apps talk to Supabase over PostgREST. It's the right default, and the
reason some things you'd expect from an ORM aren't available there. When
they matter, use a direct connection from better-supabase/postgres; the
repository API stays the same.
No transactions over PostgREST
Every PostgREST request is its own transaction. Two calls can't commit
together, and a nested write (create with related rows) is several
requests.
Instead:
- Put multi-step writes in a database function and call it with
db.$rpc(). Declare what it changes withbetterSupabase.defineRpcso caches refresh. - On the server, use
postgres.transaction()withpostgresExecutor. The samedbcalls then run in one transaction, as the user or as admin.
Relation filters on writes
where: { notes: { some: ... } } works on reads. PostgREST can't filter an
update or delete by a related table, so those fail with
invalid_request before a request is sent. Read the keys first, or use a
database function or a direct connection, where the same where compiles
to exists (...).
every on to-many relations
every compiles to "no row that fails the condition", which is correct for
empty relations too. every: {} is always true and is dropped. Over
PostgREST it costs an embedded anti-join per condition; for large relations,
prefer a column or a view that stores the answer.
Counts
_count includes and count: 'exact' run count(*) under RLS. On large
tables use count: 'estimated' for pagination, and only count the
relations you show.
Jobs over PostgREST
The jobs block prefers a direct connection. Its PostgREST
transport uses the pgmq_public functions, which can send, read and
archive. It can't:
- deduplicate:
dedupeKeyfails withinvalid_request, - schedule or unschedule cron jobs: both fail with
invalid_request, - extend a lease:
extendreturnsfalse, so heartbeats do nothing, - record
lastErroror back off: a failed job reappears when its lease runs out, and is archived as dead aftermaxAttempts.
Workers that need these run on the server with createPostgres().
Acting as a user without Postgres
bs.forContext and bs.actingAs run work as a user who isn't calling, for
jobs and webhooks. They need a direct Postgres connection, because they set
the user's claims for the transaction themselves. The Data API can't do
that: PostgREST only trusts tokens signed by the project's signing keys,
and with asymmetric keys only Supabase Auth holds the private key, so the
app can't mint a token for the user (see
Why only over Postgres).
An Edge Function with only the Data API can therefore act as its caller, but not as anyone else. Run user-scoped workers where a pooler URL is available, or keep the work inside the user's own request. Don't switch to the service role to get around it: that bypasses RLS for the whole job.
Live queries
Live queries refetch the whole query on a
change, one request per affected query. They don't patch rows in place and
don't see tables outside realtime.tables.
Generated types
gen needs the database: a local connection, or the Management API for a
hosted project. It can't infer types from migrations alone. Commit the
snapshot and run better-supabase gen --check in CI.
Last updated on