How PostgreSQL LISTEN/NOTIFY Works
Understand the mechanism before writing a single line
PostgreSQL has a built-in publish/subscribe system. Any database connection can call pg_notify('channel_name', 'message') to broadcast a message. Any other connection that has run LISTEN channel_name receives that message instantly — the moment it's sent — over the open TCP socket.
By combining this with triggers (SQL functions that run automatically when a row changes), you get a real-time notification pipeline: a row changes → trigger fires → pg_notify broadcasts → your app receives it immediately.
Why not just poll the database?
| Approach | Latency | DB Load | Extra infra needed |
|---|---|---|---|
| Polling (SELECT every N seconds) | Up to N seconds | High — constant queries | None |
| Redis Pub/Sub | ~1ms | Low | Redis server required |
| PostgreSQL LISTEN/NOTIFY | ~1ms | Minimal | None — built in |
The complete flow
Key limitation: pg_notify does NOT store messages. If your listener is offline when a notification fires, that message is lost forever. For mission-critical data, combine this with an outbox table pattern. For real-time UIs, broadcast-only is fine.
Prerequisites
What you need before starting
| Requirement | Why it's needed |
|---|---|
| PostgreSQL (any version ≥ 10) | LISTEN/NOTIFY is a core feature — works on any self-hosted or managed instance |
| App Language (Node.js, Python, Java, Go) | We use standard unpooled drivers (pg, psycopg2, JDBC, pgx) that support LISTEN natively |
| Direct connection string | Not a pooled connection — explained fully in Step 1 |
| Driver & Environment | Install your language's Postgres driver and dotenv library (e.g. npm i pg dotenv or pip install psycopg2 python-dotenv) |
npm init -y
npm install pg dotenv
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install psycopg2-binary python-dotenv
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
go mod init listen-notify
go get github.com/jackc/pgx/v5
go get github.com/joho/godotenv
Store your database credentials in a .env file and add it to .gitignore — never commit real credentials to source control.
DATABASE_URL=postgresql://your_user:your_password@your-host:5432/your_db?sslmode=require
Connect to PostgreSQL
Open a direct, persistent connection — the foundation everything else builds on
The entire LISTEN/NOTIFY system depends on one rule: you must use a direct, persistent TCP connection — never a pooled one.
The #1 mistake — using a connection pool
Tools like PgBouncer (in transaction mode) or pg.Pool rotate the underlying TCP socket between queries. Your LISTEN subscription lives on the socket — the moment it's rotated away, your subscription disappears silently. You'll never receive notifications.
Always use pg.Client directly (not pg.Pool) for LISTEN.
The connection code — Client vs Pool
const { Client } = require('pg');
require('dotenv').config();
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
await client.connect();
.env file into process.env so credentials never appear in source code. Must be called before reading process.env.DATABASE_URL.client.connect().import os
import psycopg2
import psycopg2.extensions
from dotenv import load_dotenv
load_dotenv()
# Open direct, unpooled TCP connection
conn = psycopg2.connect(os.getenv('DATABASE_URL'))
conn.set_isolation_level(
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT
)
SimpleConnectionPool or PgBouncer in transaction mode) for listening.import java.sql.Connection;
import java.sql.DriverManager;
public class DbConnect {
public static Connection connect() throws Exception {
String url = System.getenv("DATABASE_URL");
// Open direct physical connection (No HikariCP)
return DriverManager.getConnection(url);
}
}
package main
import (
"context"
"os"
"github.com/jackc/pgx/v5"
)
func connect() (*pgx.Conn, error) {
ctx := context.Background()
// Use pgx.Connect directly, NOT pgxpool.New()
return pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
}
pgxpool.New() for listeners, because pooling returns sockets to the pool and resets channel subscriptions.Create the Trigger Function
The SQL function that runs on every row change and broadcasts the notification
A trigger function is a special PostgreSQL function marked RETURNS TRIGGER. When attached to a table, Postgres calls it automatically after every INSERT, UPDATE, or DELETE. Inside it, you have access to magic variables that describe exactly what happened.
The magic trigger variables
| Variable | Type | What it contains | Available on |
|---|---|---|---|
TG_TABLE_NAME | text | Name of the table the trigger is attached to — filled automatically by Postgres | All operations |
TG_OP | text | The operation: 'INSERT', 'UPDATE', or 'DELETE' | All operations |
NEW | RECORD | The complete row after the change — the new values | INSERT, UPDATE only |
OLD | RECORD | The complete row before the change — the original values | UPDATE, DELETE only |
The complete trigger function — explained
CREATE OR REPLACE FUNCTION notify_table_change()
RETURNS TRIGGER AS $$
DECLARE
payload JSON;
changed_columns TEXT[];
BEGIN
-- For UPDATE: find which columns actually changed
IF TG_OP = 'UPDATE' THEN
SELECT array_agg(new_kv.key) INTO changed_columns
FROM jsonb_each(to_jsonb(NEW)) AS new_kv
JOIN jsonb_each(to_jsonb(OLD)) AS old_kv
ON new_kv.key = old_kv.key
WHERE new_kv.value IS DISTINCT FROM old_kv.value;
END IF;
-- Build the JSON payload
payload = json_build_object(
'database', current_database(),
'table', TG_TABLE_NAME,
'operation', TG_OP,
'changed_columns', changed_columns,
'old_data', CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) ELSE NULL END,
'new_data', CASE WHEN TG_OP IN ('UPDATE','INSERT') THEN to_jsonb(NEW) ELSE NULL END
);
-- Broadcast on the channel
PERFORM pg_notify('table_changes', payload::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
RETURNS TRIGGER — This is not a normal function you call with SQL. Postgres calls it automatically when a trigger fires. The return type TRIGGER is a special internal type — you never call this function yourself.
changed_columns detection (UPDATE only)
We convert both NEW and OLD to JSONB, expand them into (key, value) rows with jsonb_each(), JOIN on the column name, and keep only rows where the value changed.
We use IS DISTINCT FROM instead of != because != returns NULL when either side is NULL. IS DISTINCT FROM treats NULL as a real value: NULL IS DISTINCT FROM NULL = false, so two NULLs are considered unchanged — which is the correct behavior.
PERFORM pg_notify(...) — We use PERFORM instead of SELECT because pg_notify returns void. In PL/pgSQL, SELECT on a void function causes an error. PERFORM executes the call and discards the return value.
We also cast payload::text because pg_notify() only accepts a text argument, not JSON. In your Node.js app you reverse this with JSON.parse(msg.payload).
RETURN NEW — Trigger functions must return either the row record or NULL. Returning NULL from an AFTER trigger has no effect (the change is already saved), but RETURN NEW is the safe conventional choice.
What the notification payload looks like
On INSERT
{
"database": "mydb",
"table": "orders",
"operation": "INSERT",
"changed_columns": null,
"old_data": null,
"new_data": {
"id": 42, "customer": "Alice",
"status": "pending"
}
}
On UPDATE
{
"database": "mydb",
"table": "orders",
"operation": "UPDATE",
"changed_columns": ["status"],
"old_data": { "id": 42, "status": "pending" },
"new_data": { "id": 42, "status": "shipped" }
}
Create a Table & Attach the Trigger
The function does nothing alone — attach it to a table to make it fire
A trigger is the rule that tells PostgreSQL when to call your function. You define which table, which events, and whether it fires per row or per statement.
-- 1. Create your table (IF NOT EXISTS = safe to re-run)
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
customer VARCHAR(100),
status VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT now()
);
-- 2. DROP first — PostgreSQL has no "CREATE OR REPLACE TRIGGER"
DROP TRIGGER IF EXISTS watch_orders ON orders;
-- 3. Create the trigger
CREATE TRIGGER watch_orders
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
EXECUTE FUNCTION notify_table_change();
Every clause explained
| Clause | What it means | Why we chose it |
|---|---|---|
AFTER | Fires after the row is already written | We want the final committed data. Using BEFORE could broadcast values that other BEFORE triggers later modify. |
INSERT OR UPDATE OR DELETE | All three write operations fire this trigger | We want to catch every change. You can narrow this to just UPDATE if you only care about edits. |
ON orders | Watches only this specific table | Each trigger belongs to one table. We attach to all tables in Step 4. |
FOR EACH ROW | Fires once per changed row | A bulk UPDATE of 50 rows fires 50 separate notifications. The alternative FOR EACH STATEMENT fires once per query but NEW and OLD are unavailable. |
EXECUTE FUNCTION notify_table_change() | Calls the function from Step 2 | The trigger is just a pointer. All logic lives in the function. |
Notifications go nowhere right now. The trigger is attached and will call pg_notify() on every row change. But nobody is listening yet — the notification fires and disappears. PostgreSQL does not store notifications. Step 5 fixes this.
Watch Every Table Automatically
Attach the trigger to all existing tables, and auto-wire future tables too
Instead of manually running CREATE TRIGGER for each table, you can loop through every table in your database and attach the trigger to all of them at once. Then an event trigger takes care of any new tables created in the future.
Part A — Bulk-attach to all existing tables
DO $$
DECLARE
tbl RECORD;
BEGIN
FOR tbl IN
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS
watch_changes ON %I',
tbl.table_name
);
EXECUTE format(
'CREATE TRIGGER watch_changes
AFTER INSERT OR UPDATE OR DELETE
ON %I FOR EACH ROW
EXECUTE FUNCTION
notify_table_change();',
tbl.table_name
);
END LOOP;
END $$;
table_schema = 'public' to skip system tables, and table_type = 'BASE TABLE' to skip views.tbl.table_name holds the next table name. We run two EXECUTE statements per table.EXECUTE runs dynamically built SQL. format() builds it. %I is the identifier placeholder — safely quotes the table name, preventing SQL injection and handling reserved word names.CREATE OR REPLACE TRIGGER. Running CREATE on an existing trigger fails. Always DROP first — makes the script idempotent (safe to run multiple times).Part B — Auto-wire future tables (Event Trigger)
An event trigger fires on DDL events (schema changes like CREATE TABLE) rather than row changes. This one runs the moment a new table is created and automatically attaches the notification trigger to it.
-- Function that runs every time a CREATE TABLE succeeds
CREATE OR REPLACE FUNCTION auto_attach_notify_trigger()
RETURNS event_trigger AS $$
DECLARE
obj RECORD;
BEGIN
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands()
LOOP
IF obj.object_type = 'table' AND obj.schema_name = 'public' THEN
EXECUTE format(
'DROP TRIGGER IF EXISTS watch_changes ON %I;
CREATE TRIGGER watch_changes
AFTER INSERT OR UPDATE OR DELETE ON %I
FOR EACH ROW
EXECUTE FUNCTION notify_table_change();',
obj.object_identity::regclass::text,
obj.object_identity::regclass::text
);
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- Register the event trigger (drop first = re-runnable)
DROP EVENT TRIGGER IF EXISTS auto_watch_new_tables;
CREATE EVENT TRIGGER auto_watch_new_tables
ON ddl_command_end
WHEN TAG IN ('CREATE TABLE')
EXECUTE FUNCTION auto_attach_notify_trigger();
RETURNS event_trigger — This is a different kind of trigger function. Unlike row-level triggers (which return TRIGGER), event trigger functions return event_trigger. They do not receive NEW/OLD. Instead, pg_event_trigger_ddl_commands() tells them what DDL was just executed.
ON ddl_command_end vs ddl_command_start — We use ddl_command_end because it fires after the CREATE TABLE has successfully completed. The table exists with all its columns when our function runs. Using ddl_command_start would fire before the table is created, so we couldn't attach a trigger to it yet.
The Real-Time Listener
Subscribe to the channel and receive every notification the moment it fires
The listener is a long-running application process that stays connected to PostgreSQL. It subscribes with LISTEN and registers a callback. When any trigger calls pg_notify() anywhere in the database, PostgreSQL pushes the notification over the open socket — your callback fires instantly. No timers. No loops. No polling.
The complete listener — annotated
const { Client } = require('pg');
require('dotenv').config();
async function startListener() {
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
client.on('error', (err) => {
console.error('Lost:', err.message);
setTimeout(startListener, 3000);
});
await client.connect();
await client.query('LISTEN table_changes');
client.on('notification', (msg) => {
const data = JSON.parse(msg.payload);
console.log(data.operation, data.table);
if (data.operation === 'UPDATE')
console.log('Changed:', data.changed_columns);
if (data.operation === 'INSERT')
console.log('New row:', data.new_data);
if (data.operation === 'DELETE')
console.log('Deleted:', data.old_data);
});
process.on('SIGINT', async () => {
await client.query('UNLISTEN table_changes');
await client.end();
process.exit(0);
});
}
startListener();
table_changes channel. Any pg_notify('table_changes', ...) call from any connection, any user, any tool — pushes a message to us.pg library listens on the socket and emits this event the instant data arrives. Purely event-driven — no polling or timers involved.pg_notify only accepts text, so our trigger sent JSON as a string. We reverse this with JSON.parse().import os, time, json, select
import psycopg2, psycopg2.extensions
from dotenv import load_dotenv
load_dotenv()
def run_listener():
conn = psycopg2.connect(os.getenv('DATABASE_URL'))
conn.set_isolation_level(
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT
)
cursor = conn.cursor()
cursor.execute("LISTEN table_changes;")
print("Listening for notifications...")
try:
while True:
# Wait up to 5s for socket activity
if select.select([conn], [], [], 5.0) == ([conn], [], []):
conn.poll()
while conn.notifies:
notify = conn.notifies.pop(0)
data = json.loads(notify.payload)
print(f"[{data['operation']}] on {data['table']}")
except (psycopg2.OperationalError, KeyboardInterrupt):
print("Reconnecting in 3s...")
conn.close()
time.sleep(3)
run_listener()
if __name__ == '__main__':
run_listener()
select) to wait for incoming network packets from PostgreSQL. Pure event-driven sleep with zero CPU polling loop overhead.import java.sql.*;
import org.postgresql.PGConnection;
import org.postgresql.PGNotification;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
public class PostgresListener {
public static void main(String[] args) {
while (true) {
try (Connection conn = DriverManager.getConnection(
System.getenv("DATABASE_URL"))) {
Statement stmt = conn.createStatement();
stmt.execute("LISTEN table_changes");
PGConnection pgConn = conn.unwrap(PGConnection.class);
ObjectMapper mapper = new ObjectMapper();
System.out.println("Listening...");
while (true) {
PGNotification[] notes = pgConn.getNotifications(500);
if (notes != null) {
for (PGNotification note : notes) {
Map data = mapper.readValue(
note.getParameter(), Map.class);
System.out.println("[" + data.get("operation") +
"] on " + data.get("table"));
}
}
}
} catch (Exception e) {
System.err.println("Lost. Retrying in 3s...");
try { Thread.sleep(3000); } catch (Exception ie) {}
}
}
}
}
Connection interface to access the PostgreSQL driver's native notification methods.package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/jackc/pgx/v5"
)
type Payload struct {
Operation string `json:"operation"`
Table string `json:"table"`
}
func main() {
for {
ctx := context.Background()
conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
time.Sleep(3 * time.Second)
continue
}
conn.Exec(ctx, "LISTEN table_changes")
fmt.Println("Listening...")
for {
note, err := conn.WaitForNotification(ctx)
if err != nil {
conn.Close(ctx)
break
}
var data Payload
json.Unmarshal([]byte(note.Payload), &data)
fmt.Printf("[%s] on %s\n", data.Operation, data.Table)
}
time.Sleep(3 * time.Second)
}
}
What it looks like running
Listening on multiple channels
You can subscribe to as many channels as you want on a single connection:
await client.query('LISTEN table_changes');
await client.query('LISTEN user_events');
await client.query('LISTEN payments');
// msg.channel tells you which one fired
client.on('notification', (msg) => {
if (msg.channel === 'table_changes') { /* ... */ }
if (msg.channel === 'payments') { /* ... */ }
});
cursor.execute("LISTEN table_changes;")
cursor.execute("LISTEN user_events;")
cursor.execute("LISTEN payments;")
# notify.channel tells you which channel fired
while True:
if select.select([conn], [], [], 5.0) == ([conn], [], []):
conn.poll()
while conn.notifies:
notify = conn.notifies.pop(0)
if notify.channel == 'table_changes':
pass # handle table changes
elif notify.channel == 'payments':
pass # handle payments
Statement stmt = conn.createStatement();
stmt.execute("LISTEN table_changes");
stmt.execute("LISTEN user_events");
stmt.execute("LISTEN payments");
// note.getName() tells you which channel fired
PGNotification[] notes = pgConn.getNotifications(500);
if (notes != null) {
for (PGNotification note : notes) {
if ("table_changes".equals(note.getName())) { /* ... */ }
if ("payments".equals(note.getName())) { /* ... */ }
}
}
conn.Exec(ctx, "LISTEN table_changes")
conn.Exec(ctx, "LISTEN user_events")
conn.Exec(ctx, "LISTEN payments")
// note.Channel tells you which channel fired
for {
note, _ := conn.WaitForNotification(ctx)
switch note.Channel {
case "table_changes":
// handle table changes
case "payments":
// handle payments
}
}
Production reconnect with exponential backoff
For production, backoff prevents hammering the database during an outage:
let retryDelay = 1000; // start at 1 second
async function startListener() {
const client = new Client({ connectionString: process.env.DATABASE_URL });
client.on('error', () => {
// 1s → 2s → 4s → 8s → max 30s
console.log(`Reconnecting in ${retryDelay / 1000}s...`);
setTimeout(() => {
retryDelay = Math.min(retryDelay * 2, 30000);
startListener();
}, retryDelay);
});
try {
await client.connect();
retryDelay = 1000; // reset on success
await client.query('LISTEN table_changes');
client.on('notification', handleNotification);
console.log('Listening...');
} catch (err) {
console.error('Connect failed:', err.message);
client.on('error', () => {}); // prevent double-handling
setTimeout(startListener, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
}
}
import time, os, select, psycopg2
import psycopg2.extensions
def start_listener_with_backoff():
retry_delay = 1.0 # start at 1 second
while True:
try:
conn = psycopg2.connect(os.getenv('DATABASE_URL'))
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
retry_delay = 1.0 # reset on success
cursor = conn.cursor()
cursor.execute("LISTEN table_changes;")
print("Connected and listening...")
while True:
if select.select([conn], [], [], 5.0) == ([conn], [], []):
conn.poll()
while conn.notifies:
handle_notification(conn.notifies.pop(0))
except Exception as e:
print(f"Lost ({e}). Reconnecting in {retry_delay}s...")
time.sleep(retry_delay)
retry_delay = min(retry_delay * 2, 30.0) # exponential backoff max 30s
public static void listenWithBackoff() {
long retryDelay = 1000; // start at 1 second
while (true) {
try (Connection conn = DriverManager.getConnection(System.getenv("DATABASE_URL"))) {
retryDelay = 1000; // reset on success
Statement stmt = conn.createStatement();
stmt.execute("LISTEN table_changes");
PGConnection pgConn = conn.unwrap(PGConnection.class);
System.out.println("Connected and listening...");
while (true) {
PGNotification[] notes = pgConn.getNotifications(500);
if (notes != null) {
for (PGNotification note : notes) handleNotification(note);
}
}
} catch (Exception e) {
System.err.println("Lost. Reconnecting in " + (retryDelay/1000) + "s...");
try { Thread.sleep(retryDelay); } catch (Exception ie) {}
retryDelay = Math.min(retryDelay * 2, 30000); // exponential backoff max 30s
}
}
}
func listenWithBackoff() {
retryDelay := 1 * time.Second
for {
ctx := context.Background()
conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
log.Printf("Connect failed: %v. Reconnecting in %v...", err, retryDelay)
time.Sleep(retryDelay)
if retryDelay < 30*time.Second {
retryDelay *= 2
}
continue
}
retryDelay = 1 * time.Second // reset on success
conn.Exec(ctx, "LISTEN table_changes")
log.Println("Connected and listening...")
for {
note, err := conn.WaitForNotification(ctx)
if err != nil {
log.Printf("Connection lost: %v", err)
conn.Close(ctx)
break
}
handleNotification(note)
}
time.Sleep(retryDelay)
if retryDelay < 30*time.Second {
retryDelay *= 2
}
}
}
SQL Test Queries
Run these in any SQL client with your listener running — watch notifications arrive instantly
Basic CRUD
INSERT INTO orders (customer, status) VALUES ('Alice', 'pending');
UPDATE orders SET status = 'shipped' WHERE customer = 'Alice';
DELETE FROM orders WHERE customer = 'Alice';
Bulk operations
INSERT INTO orders (customer, status) VALUES
('Bob', 'pending'),
('Charlie', 'shipped'),
('Diana', 'processing'),
('Eve', 'pending');
UPDATE orders
SET status = 'delivered', customer = 'Alice Smith'
WHERE customer = 'Alice';
Inspect your wiring
-- All triggers on all tables
SELECT trigger_name, event_object_table, event_manipulation, action_timing
FROM information_schema.triggers
WHERE trigger_schema = 'public'
ORDER BY event_object_table;
-- Confirm trigger function exists
SELECT routine_name FROM information_schema.routines
WHERE routine_name = 'notify_table_change'
AND routine_schema = 'public';
Cleanup
DROP TRIGGER IF EXISTS watch_changes ON orders;
DROP FUNCTION IF EXISTS notify_table_change();
DROP EVENT TRIGGER IF EXISTS auto_watch_new_tables;
DROP FUNCTION IF EXISTS auto_attach_notify_trigger();
Common Pitfalls & How to Fix Them
Save yourself hours of debugging with these known gotchas
Using a connection pool for LISTEN
Symptom Listener starts but never receives notifications, or disconnects after the first query.
Fix Use new pg.Client(), not new pg.Pool(). If your app uses a pool for normal queries, create a completely separate Client just for the listener.
No reconnect logic
Symptom Listener works initially, then silently stops after idle time or a server restart.
Fix Always handle client.on('error', ...) and call your connect function again. Database servers restart, networks hiccup, and idle connections are killed by firewalls.
Payload exceeds 8KB
Symptom Notifications silently drop for rows with large text fields. PostgreSQL caps pg_notify payload at ~8000 bytes.
Fix Send only the primary key in the notification, then fetch the full row separately: SELECT * FROM table WHERE id = data.id.
Missed notifications during downtime
Symptom App restarts and loses notifications that fired while it was down.
Fix Combine NOTIFY with an outbox pattern: write changes to a pending_events table. On reconnect, drain the outbox first, then switch to live LISTEN mode.
Bulk UPDATE floods the listener
Symptom A single UPDATE affecting 10,000 rows sends 10,000 notifications, overwhelming your app.
Fix (a) Remove the trigger before bulk operations and re-attach after, or (b) debounce in your app, or (c) use FOR EACH STATEMENT trigger mode with a summary payload.
Trigger function missing when creating trigger
Symptom CREATE TRIGGER fails with "function notify_table_change() does not exist".
Fix Order matters: create the trigger function (Step 2) before creating the trigger (Step 3). The trigger is just a pointer to the function — the function must already exist.