Skip to content

Realtime: Postgres Changes

Postgres Changes streams your database’s own write-ahead log to subscribed clients as INSERT/UPDATE/DELETE events, but this is disabled by default for every table until you explicitly add it to a publication.

Postgres already keeps a durable, ordered record of every write in its write-ahead log (WAL) — it uses this internally for crash recovery and replication. Supabase Realtime reads that same WAL through Postgres’s logical replication feature and turns each committed change into a message it can push over a WebSocket. A client subscribes to a channel, tells it which table and which event types it cares about, and gets a callback fired for every matching row change, live, without polling the table on an interval.

The critical fact to internalize before writing any code: this pipeline is off by default for every table you create. Realtime does not watch your whole schema automatically — a table’s writes only reach the WAL stream that Realtime taps into once that table is added to the supabase_realtime publication. Until you do that, a client can subscribe all day and receive nothing.

Turn it on with plain SQL:

alter publication supabase_realtime add table public.messages;

The same toggle exists as a switch in the Studio’s Database > Replication settings, if you would rather not run SQL by hand — it does exactly the same thing under the hood.

Once public.messages is in the publication, a supabase-js client can subscribe to changes on it:

const channel = supabase
.channel('room1')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
(payload) => {
console.log('New message:', payload.new);
}
)
.subscribe();

event accepts INSERT, UPDATE, DELETE, or * to receive all three. schema and table scope the subscription to one table; you can register multiple .on('postgres_changes', ...) handlers on the same channel to listen for several event types or tables at once.

There is one more gap worth closing. By default, Postgres only includes the primary key columns of the old row in an UPDATE or DELETE payload — not the full previous row. If your application logic needs to compare the old and new values (for example, to know what a field changed from), set the table’s replica identity to full:

alter table public.messages replica identity full;

With replica identity full in place, payload.old on an UPDATE or DELETE event contains every column of the row as it existed before the change, not just its primary key.

flowchart LR
  insert["Postgres INSERT/UPDATE/DELETE"] --> wal["Write-ahead log (WAL)"]
  wal --> gate{"Is the table added to supabase_realtime publication?"}
  gate -->|no| nothing["No event is ever sent"]
  gate -->|yes| realtime["Realtime server reads WAL via logical replication"]
  realtime --> client["Client's postgres_changes callback fires"]
A row change only reaches a client if the table is in the publication
Do Postgres Changes work on a newly created table by default
What SQL statement enables Postgres Changes for a specific table
Why would you set replica identity full on a table used with Postgres Changes
Which fields does a channel.on('postgres_changes', ...) subscription use to scope what it listens to