Skip to content

Postgres Changes

Subscribe to a table and receive a live event every time a row is inserted, updated, or deleted. Under the hood, AnvilBase listens to Postgres LISTEN/NOTIFY and fans changes out to subscribed channels.

Subscribe (SDK)

const channel = db
.channel('messages-feed')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
(payload) => {
console.log('new message:', payload.new)
}
)
.subscribe()
def on_insert(payload):
print("new message:", payload["new"])
channel = (
db.channel("messages-feed")
.on_postgres_changes(event="INSERT", schema="public",
table="messages", callback=on_insert)
.subscribe()
)
db.channel('messages-feed').onPostgresChanges(
event: PostgresChangeEvent.insert,
schema: 'public',
table: 'messages',
callback: (payload) => print('new message: ${payload.newRecord}'),
).subscribe();

Event types

eventFires on
INSERTnew rows
UPDATEchanged rows
DELETEremoved rows
*all of the above

The event payload

Each change delivers a payload with the new and/or old row and metadata:

{
"schema": "public",
"table": "messages",
"commit_timestamp": "2026-06-04T10:00:00Z",
"eventType": "UPDATE",
"new": { "id": "", "body": "edited", "user_id": "" },
"old": { "id": "" },
"columns": [ { "name": "id", "type": "unknown" }, { "name": "body", "type": "unknown" } ],
"errors": null
}
  • new is populated on INSERT and UPDATE; old on UPDATE and DELETE.
  • How old is populated is described in How old/new are populated below — it is a trigger-based feed, so a table’s REPLICA IDENTITY has no effect on it.
  • columns lists the row’s column names. AnvilBase’s trigger feed carries no column type metadata, so type is always "unknown" — treat columns as a name list, not a schema.
  • errors is a JSON array of error markers, or null for a normal event. The only marker AnvilBase emits today is ["payload_too_large"] — see below.

Oversized rows (payload_too_large)

Realtime change payloads are delivered over Postgres NOTIFY, which caps a single message at ~7900 bytes. When a changed row exceeds that cap (wide JSON/ JSONB blobs, long text, large arrays), AnvilBase cannot send the full row inline. Instead of dropping the event silently, it delivers a reference event: new/old contain only the row’s primary key and errors is ["payload_too_large"]. Refetch the row by its primary key to get the current contents.

{
"schema": "public",
"table": "documents",
"commit_timestamp": "2026-06-04T10:00:00Z",
"eventType": "INSERT",
"new": { "id": "3f7c…" },
"old": null,
"columns": [ { "name": "id", "type": "uuid" } ],
"errors": ["payload_too_large"]
}

Caveats:

  • The reference event is still RLS-checked — you receive it only if you could SELECT the row, exactly like a normal change.
  • A subscription with a filter on a non-primary-key column will not receive the reference event: the filter can’t be evaluated without the full row, so it fails safe (drop, never leak). For wide-row tables, subscribe without a non-PK filter, or reconcile on the payload_too_large marker.
  • A table with no primary key cannot deliver the reference event to anon/ authenticated subscribers (there is no key to RLS-probe); only service_role subscribers receive the flagged event for such tables.

Filtering

Restrict a subscription to rows matching a condition, using the same operator syntax as the REST API:

db.channel('my-orders')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'orders', filter: `user_id=eq.${userId}` },
handle)
.subscribe()

Supported filter operators: eq, neq, in, gt, gte, lt, lte. The filter is column=op.value (e.g. status=eq.shipped, priority=gte.3, region=in.(eu,uk)).

DELETE filters are restricted to primary-key columns

A hard DELETE can’t be RLS-checked (the row is gone), so the content filter is the only thing standing between the deleted row and the subscriber. To stop a crafted predicate on a secret column (e.g. filter=email=eq.victim@x.com or filter=balance=gte.1000000) from being used as a value oracle over deleted rows, a non-service_role subscriber’s DELETE filter is restricted to primary-key columns. A DELETE subscription whose filter references any other column (or a table whose primary key can’t be resolved) receives no event. A filter on a PK column (e.g. filter=id=eq.42) is still honored, and a filterless DELETE subscription still delivers (the old payload is PK-only — see below). service_role subscribers keep the unrestricted filter. For filtered or sensitive deletes, prefer soft-delete (an UPDATE that sets a deleted_at column), which flows through the fully RLS-checked UPDATE path.

How old/new are populated

AnvilBase’s change feed is trigger-based: provisioning installs an AFTER INSERT OR UPDATE OR DELETE trigger that emits row_to_json(NEW) / row_to_json(OLD). It is not WAL/logical-decoding, so a table’s REPLICA IDENTITY has no effect on this feed — there is nothing to set, and ALTER TABLE … REPLICA IDENTITY FULL changes nothing here.

  • INSERTnew is the full inserted row (RLS-gated); old is null.
  • UPDATEnew is the full new row and old is the full prior row, each RLS-gated independently: if the prior row is not visible to the subscriber under their own RLS, old is stripped to null (an UPDATE that moves a row across the RLS boundary can’t leak its prior contents).
  • DELETE — for non-service_role subscribers old is reduced to the table’s primary-key columns only (the row is gone and can’t be RLS-probed, so only its key is revealed). service_role subscribers receive the full old row.

Security

Realtime change events should be paired with RLS on the table so a subscriber only receives changes to rows they’re permitted to see. Subscribe with a user JWT (not the anon key alone) for per-user scoping, and reserve the service_role token for trusted server-side consumers (e.g. a worker that mirrors changes to a search index).

The change-feed RLS check evaluates against the same JWT claims your REST queries see — including the enriched claims AnvilBase mints (email, aud, app_metadata, user_metadata, session_id). So a policy written with auth.email() or auth.jwt() -> 'app_metadata' ->> 'tenant' behaves identically on postgres_changes and on REST for INSERT/UPDATE: those events are delivered only if the same policy would let the subscriber SELECT the row. (When the token’s JWT rotates mid-session via an access_token refresh, subsequent change events are checked against the new claims.)

DELETE cannot be RLS-checked — the row no longer exists, so there is nothing to probe. Non-service_role subscribers receive only the primary key of old, and a DELETE filter is restricted to PK columns (see Filtering). Prefer soft-delete (an UPDATE to a deleted_at column, which is RLS-gated) for sensitive tables.

RLS-disabled tables are not delivered to non-service_role subscribers by default. Because anon/authenticated hold a blanket SELECT grant, a table with row-level security disabled would otherwise stream its every change to any subscriber (including an anon-key holder). To expose an RLS-disabled table over realtime deliberately, mark it realtime-public:

Terminal window
# CLI
anvilbase realtime set-table reference --project <id> --rls-public true
# Management API
PUT /api/v1/projects/{id}/realtime/tables/reference
{ "realtime_public": true }

service_role subscribers are unaffected (they bypass the gate, matching REST’s BYPASSRLS behavior).

Which tables emit events

Every public base table gets the realtime CDC trigger automatically at creation — there is no publication to opt into (contrast Supabase’s ALTER PUBLICATION supabase_realtime ADD TABLE). Zero-config: create a table and its changes are live.

To take a table off the feed (to save write-path overhead or reduce exposure):

Terminal window
# CLI — disable / re-enable CDC for a table
anvilbase realtime set-table orders --project <id> --cdc false
anvilbase realtime set-table orders --project <id> --cdc true
# Management API
PUT /api/v1/projects/{id}/realtime/tables/orders
{ "cdc_enabled": false }

List every table’s realtime state (cdc_enabled, rls_enabled, realtime_public):

Terminal window
anvilbase realtime tables --project <id>
# or: GET /api/v1/projects/{id}/realtime/tables

A CDC opt-out persists, but note the trigger is re-created if the table is dropped and recreated (the auto-attach event trigger fires on CREATE TABLE); re-apply the opt-out after recreating a table.

Delivery guarantees & limitations

The change feed is at-most-once with no replay. Events can be dropped, with no redelivery, when:

  • the realtime service restarts or a per-project database listener reconnects;
  • a project is deferred at the ANVILBASE_REALTIME_MAX_LISTENERS ceiling — such a project’s realtime simply appears dead (no events) until capacity frees up;
  • the RLS-gate query pool saturates (the gate fails closed and drops rather than stalls).

Realtime is a latency optimization, not a system of record. Pair it with a periodic reconcile/refetch (and refetch on the payload_too_large marker) so a dropped event self-heals. See configuration for ANVILBASE_REALTIME_MAX_LISTENERS, ANVILBASE_REALTIME_MAX_DB_CONNECTIONS, and ANVILBASE_REALTIME_QUERY_POOL_SIZE.

Patterns

  • Live feed / chat: subscribe to INSERT on a messages table filtered by room_id.
  • Optimistic UI reconciliation: subscribe to * and reconcile your local cache against new/old.
  • Cross-device sync: subscribe to UPDATE on the user’s own rows (filter: user_id=eq.<id>).
  • Server-side CDC: a service_role subscriber that pushes changes into a downstream system.

Unsubscribe

channel.unsubscribe()
// or remove all
await db.removeAllChannels()

Always tear down channels when a component unmounts to free connections.

Next: Broadcast & Presence.