Postgres Changes
Stream inserts, updates and deletes from your tables to clients with Supabase Realtime Postgres Changes on SupaHost. Publication setup, filters, RLS, replica identity and scaling.
Postgres Changes lets clients subscribe to inserts, updates and deletes on your tables. When a row changes, Realtime reads the change from the project's database, checks it against the table's RLS policies for each subscriber, and sends it to the clients allowed to see it.
It's the quickest way to keep a view in sync with a table: a kitchen screen that shows new orders, a status page for a booking, a shared shopping list.
Step 1: add the table to the publication
Realtime only sees tables in the supabase_realtime publication. Add the tables you want to stream, in Studio's SQL editor or a migration:
alter publication supabase_realtime add table public.orders;
To check which tables are included:
select schemaname, tablename
from pg_publication_tables
where pubname = 'supabase_realtime';
Only add tables that clients really need to watch. Every change to a published table is processed by Realtime, whether or not anyone is subscribed.
Step 2: make sure RLS allows reads
Clients receive a change only if they could select that row through the Data API. Enable RLS and add a select policy:
alter table public.orders enable row level security;
create policy "Staff see orders for their restaurant"
on public.orders for select to authenticated
using (
restaurant_id in (
select restaurant_id from public.staff where user_id = (select auth.uid())
)
);
If a subscription connects but no events arrive, a missing or too strict select policy is the usual cause. See Postgres Changes not firing.
Step 3: subscribe from the client
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
const kitchen = supabase
.channel('kitchen-42')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'orders', filter: 'restaurant_id=eq.42' },
(payload) => addOrderCard(payload.new)
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'orders', filter: 'restaurant_id=eq.42' },
(payload) => updateOrderCard(payload.new)
)
.on(
'postgres_changes',
{ event: 'DELETE', schema: 'public', table: 'orders' },
(payload) => removeOrderCard(payload.old.id)
)
.subscribe((status) => console.log('kitchen channel:', status))
The user must be signed in for the authenticated policy to apply. supabase-js passes the session's access token to Realtime and refreshes it when the session refreshes.
The event payload
Each callback receives an object like this:
{
"schema": "public",
"table": "orders",
"commit_timestamp": "2026-11-11T12:03:44.120Z",
"eventType": "UPDATE",
"new": { "id": 981, "restaurant_id": 42, "status": "ready", "updated_at": "..." },
"old": { "id": 981 },
"errors": null
}
new holds the row after the change (empty for deletes). old holds the previous values, but by default only the primary key.
Old values and replica identity
To receive the full previous row on updates and deletes, set the table's replica identity to full:
alter table public.orders replica identity full;
Two caveats from upstream Realtime:
- With RLS enabled,
oldon a delete still contains only the primary key, because Postgres can't check a policy against a row that no longer exists. - Full replica identity makes Postgres write more data to its write-ahead log for every update and delete on that table. Only turn it on where you need the old values.
Filters
Filters reduce what each client receives. They use the same operator names as the Data API:
| Filter | Example |
|---|---|
eq |
status=eq.ready |
neq |
status=neq.cancelled |
lt, lte, gt, gte |
total=gt.100 |
in |
status=in.(new,cooking) (up to 100 values) |
You can use one filter per subscription. Delete events can't be filtered, so subscribe to deletes without a filter or handle them in your callback.
Event can be 'INSERT', 'UPDATE', 'DELETE' or '*' for all three. Leave out table to listen to every published table in a schema.
How it runs on SupaHost
- Realtime polls each project's database for new changes every 100 ms, so changes usually arrive within a fraction of a second.
- The per-record limit for change payloads is 1 MB. Keep large text, JSON or binary columns out of tables you stream, or stream a small "something changed" row and fetch details separately.
- Changes count toward your project's Realtime limits (5,000 events per second) and your instance's egress. See Realtime capacity and limits.
When to use something else
For every change and every subscriber, Realtime checks RLS by running a query as that user. With a few subscribers per table this is cheap. With hundreds of clients watching the same busy table, it becomes the bottleneck, and changes start to lag.
For those cases, send changes with Broadcast from the database (see Broadcast) instead. A trigger calls realtime.send() or realtime.broadcast_changes() once per change, to a topic such as restaurant:42, and private channel policies decide who may join that topic. The authorization check then happens once per join instead of once per message.
A rough rule:
| Situation | Use |
|---|---|
| A handful of users per table, prototypes, admin screens | Postgres Changes |
| Many viewers of the same data, or high write rates | Broadcast from the database |
| Clients only need "refresh now" hints | Broadcast with a tiny payload, then refetch |
Missed changes
Postgres Changes only delivers changes that happen while the client is subscribed. After a reconnect (for example a laptop waking from sleep), refetch the data you're showing so you don't miss anything that changed in between. A simple way is to reload whenever the status callback reports SUBSCRIBED.