Complete Setup Guide · Step by Step

Real-Time Database Notifications
with PostgreSQL LISTEN/NOTIFY

Learn how to make your database broadcast instant change notifications to your application (Node.js, Python, Java, Go) — no polling, no extra services, just PostgreSQL doing the work.

5 Steps ~45 min Any PostgreSQL host Beginner friendly

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?

ApproachLatencyDB LoadExtra infra needed
Polling (SELECT every N seconds)Up to N secondsHigh — constant queriesNone
Redis Pub/Sub~1msLowRedis server required
PostgreSQL LISTEN/NOTIFY~1msMinimalNone — built in

The complete flow

Row changes
INSERT / UPDATE / DELETE
Trigger fires
AFTER each row
pg_notify()
JSON payload
LISTEN-er
Your app
Callback runs
instantly

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

RequirementWhy 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 stringNot a pooled connection — explained fully in Step 1
Driver & EnvironmentInstall your language's Postgres driver and dotenv library (e.g. npm i pg dotenv or pip install psycopg2 python-dotenv)
bash (Node.js)
npm init -y
npm install pg dotenv
bash (Python)
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate
pip install psycopg2-binary python-dotenv
maven (pom.xml)
<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>
bash (Go)
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.

.env file
DATABASE_URL=postgresql://your_user:your_password@your-host:5432/your_db?sslmode=require

1

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

javascript
const { Client } = require('pg');
require('dotenv').config();

const client = new Client({
  connectionString: process.env.DATABASE_URL,
});

await client.connect();
Line by line (Node.js)
const { Client } = require('pg')
We destructure Client, not Pool. A Pool manages multiple connections and reuses them between queries — that breaks LISTEN. Client opens and owns exactly one persistent socket for the entire session.
require('dotenv').config()
Loads your .env file into process.env so credentials never appear in source code. Must be called before reading process.env.DATABASE_URL.
new Client({ connectionString })
Creates the client object but does NOT connect yet. No socket is opened until you explicitly call client.connect().
await client.connect()
Opens the TCP socket, authenticates, and establishes the PostgreSQL session. This socket stays open for the entire lifetime of your listener.
python
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
)
Line by line (Python)
psycopg2.connect(url)
Opens a single, dedicated TCP connection. Never use pooled connections (like SimpleConnectionPool or PgBouncer in transaction mode) for listening.
ISOLATION_LEVEL_AUTOCOMMIT
Critical for asynchronous notification delivery. In standard transaction mode, Python buffers connections inside transaction blocks where incoming notifications won't arrive until a commit. Setting autocommit lets notifications stream instantly over the socket.
java
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);
    }
}
Line by line (Java JDBC)
DriverManager.getConnection(url)
Opens a direct physical TCP connection to PostgreSQL. Never borrow a connection from a connection pool like HikariCP for listening, as connection recycling terminates underlying LISTEN subscriptions.
System.getenv("DATABASE_URL")
Reads connection string securely from environment variables so credentials are never hardcoded in source files.
go
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"))
}
Line by line (Go pgx)
pgx.Connect(...)
Opens a single, unpooled TCP connection. Do NOT use pgxpool.New() for listeners, because pooling returns sockets to the pool and resets channel subscriptions.
context.Background()
Standard Go concurrency context governing socket creation and query execution timeouts.

2

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

VariableTypeWhat it containsAvailable on
TG_TABLE_NAMEtextName of the table the trigger is attached to — filled automatically by PostgresAll operations
TG_OPtextThe operation: 'INSERT', 'UPDATE', or 'DELETE'All operations
NEWRECORDThe complete row after the change — the new valuesINSERT, UPDATE only
OLDRECORDThe complete row before the change — the original valuesUPDATE, DELETE only

The complete trigger function — explained

sql
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

json
{
  "database": "mydb",
  "table": "orders",
  "operation": "INSERT",
  "changed_columns": null,
  "old_data": null,
  "new_data": {
    "id": 42, "customer": "Alice",
    "status": "pending"
  }
}

On UPDATE

json
{
  "database": "mydb",
  "table": "orders",
  "operation": "UPDATE",
  "changed_columns": ["status"],
  "old_data": { "id": 42, "status": "pending" },
  "new_data": { "id": 42, "status": "shipped" }
}

3

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.

sql
-- 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

ClauseWhat it meansWhy we chose it
AFTERFires after the row is already writtenWe want the final committed data. Using BEFORE could broadcast values that other BEFORE triggers later modify.
INSERT OR UPDATE OR DELETEAll three write operations fire this triggerWe want to catch every change. You can narrow this to just UPDATE if you only care about edits.
ON ordersWatches only this specific tableEach trigger belongs to one table. We attach to all tables in Step 4.
FOR EACH ROWFires once per changed rowA 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 2The 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.


4

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 $$;
Explanation
DO $$ ... END $$
An anonymous PL/pgSQL block. Runs once immediately and is not saved as a named function. Perfect for one-off setup scripts.
information_schema.tables
Built-in view listing all tables. We filter to table_schema = 'public' to skip system tables, and table_type = 'BASE TABLE' to skip views.
FOR tbl IN ... LOOP
PL/pgSQL loop. Each iteration, tbl.table_name holds the next table name. We run two EXECUTE statements per table.
format(..., %I)
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.
DROP TRIGGER IF EXISTS first
PostgreSQL has no 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.

sql
-- 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.


5

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

javascript
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();
Line by line (Node.js)
new Client() — not Pool
A Client holds one persistent socket for the session. A Pool reuses sockets between queries, losing the LISTEN subscription between calls. This is the single most important rule for LISTEN/NOTIFY.
client.on('error', reconnect)
The connection can drop for many reasons: DB restart, network hiccup, idle timeout. This handler catches the error and reconnects after 3 seconds. Without this, your listener silently dies and misses all future notifications.
LISTEN table_changes
Registers this connection with PostgreSQL as a subscriber on the table_changes channel. Any pg_notify('table_changes', ...) call from any connection, any user, any tool — pushes a message to us.
client.on('notification', cb)
Fires automatically every time a notification arrives. The pg library listens on the socket and emits this event the instant data arrives. Purely event-driven — no polling or timers involved.
JSON.parse(msg.payload)
pg_notify only accepts text, so our trigger sent JSON as a string. We reverse this with JSON.parse().
python
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()
Line by line (Python)
cursor.execute("LISTEN table_changes;")
Registers this direct database session as an active subscriber on the notification channel.
select.select([conn], [], [], 5.0)
Uses OS-level socket monitoring (select) to wait for incoming network packets from PostgreSQL. Pure event-driven sleep with zero CPU polling loop overhead.
conn.poll() & conn.notifies
Reads waiting notification messages from the TCP buffer and pops them from the list for JSON decoding.
OperationalError catch & retry
If the database restarts or firewalls drop idle sockets, automatically rebuild the connection after 3 seconds.
java
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) {}
            }
        }
    }
}
Line by line (Java JDBC)
stmt.execute("LISTEN table_changes")
Tells PostgreSQL to subscribe this unpooled JDBC session to the channel.
conn.unwrap(PGConnection.class)
Unwraps standard Java Connection interface to access the PostgreSQL driver's native notification methods.
pgConn.getNotifications(500)
Checks the TCP socket buffer for incoming notifications with a 500ms block time.
try-with-resources & loop
Ensures clean socket teardown and automatic reconnection backoff if network drops.
go
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)
    }
}
Line by line (Go pgx)
conn.Exec(ctx, "LISTEN table_changes")
Registers this unpooled Go socket connection as a subscriber on the notification channel.
conn.WaitForNotification(ctx)
Blocks efficiently on the TCP socket until PostgreSQL sends a notification frame. Zero CPU usage while waiting.
json.Unmarshal(...)
Decodes the JSON text payload broadcast by our PostgreSQL database trigger into a Go struct.

What it looks like running

node listener.js
$ node listener.js
Connected — listening on table_changes
Waiting for changes...
 
─────────────────────────────────
INSERT on [orders]
New row: { id: 1, customer: "Alice", status: "pending" }
─────────────────────────────────
 
─────────────────────────────────
UPDATE on [orders]
Changed: ["status"]
Before: { id: 1, status: "pending" } | After: { id: 1, status: "shipped" }
─────────────────────────────────
 
─────────────────────────────────
DELETE on [orders]
Deleted: { id: 1, customer: "Alice", status: "shipped" }
─────────────────────────────────

Listening on multiple channels

You can subscribe to as many channels as you want on a single connection:

javascript
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')      { /* ... */ }
});
python
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
java
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()))      { /* ... */ }
    }
}
go
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:

javascript
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);
  }
}
python
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
java
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
        }
    }
}
go
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

sql — each fires one notification
INSERT INTO orders (customer, status) VALUES ('Alice', 'pending');

UPDATE orders SET status = 'shipped' WHERE customer = 'Alice';

DELETE FROM orders WHERE customer = 'Alice';

Bulk operations

sql — 4 rows = 4 separate notifications (FOR EACH ROW)
INSERT INTO orders (customer, status) VALUES
  ('Bob',     'pending'),
  ('Charlie', 'shipped'),
  ('Diana',   'processing'),
  ('Eve',     'pending');
sql — changed_columns shows ["status", "customer"]
UPDATE orders
SET status = 'delivered', customer = 'Alice Smith'
WHERE customer = 'Alice';

Inspect your wiring

sql
-- 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

sql
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.