Skip to content
PostgreSQL

PostgreSQL LISTEN/NOTIFY Is Not Durable — Here's the Fix with pg_durable

Why LISTEN/NOTIFY drops messages when nobody is listening, and how pg_durable's durable signals and workflow engine keep long-running work alive across restarts.

D
8 min read

What if PostgreSQL could run a multi-step workflow, pause it for days, survive a restart, and continue from the exact point where it stopped — with no external queue or worker service? That is what pg_durable does.

The Microsoft Azure team announced pg_durable in June 2026 (still in preview). It is not a replacement for scheduled-job extensions like pg_cron. Its purpose is different: it runs long-running workflows durably inside the database. It is built on duroxide, a Rust framework for durable workflows.

Why orchestration inside the database?

We can already chain steps inside a single PL/pgSQL function, so why do we need more?

Because a PL/pgSQL function runs as one transaction. It is all-or-nothing, and it must finish quickly — you cannot hold a transaction open for hours while you wait for a payment, an approval, or an external API.

pg_durable is a full orchestration system. Each step is saved (checkpointed) as it completes, so a workflow can wait, survive a crash, and resume without repeating work that is already done.

Get it running in under a minute

pg_durable ships as a ready-to-run Docker image (PostgreSQL 17 with the extension built in).

# 1. Start PostgreSQL with pg_durable
docker run -d --name pg_durable \
  -e POSTGRES_PASSWORD=secret -p 5434:5432 \
  ghcr.io/microsoft/pg_durable:0.2.2-pg17

# 2. Connect
psql "postgresql://postgres:secret@localhost:5434/postgres"
-- 3. Enable the extension (functions live in the df schema)
CREATE EXTENSION IF NOT EXISTS pg_durable;

-- 4. Verify
SELECT df.version();

-- 5. Run your first workflow:
--    save a step's result, then reuse it with $total
SELECT df.start(
  'SELECT 100 AS amount' |=> 'total'
  ~> 'SELECT $total * 2 AS doubled'
);

-- 6. Check the result
SELECT * FROM df.list_instances(limit_count => 1);

What CREATE EXTENSION actually starts

pg_durable is not only a set of functions. It ships a background worker that runs all the time. Two things must be in place.

1. The library must be preloaded. The worker is registered before the database accepts connections, so the library loads at startup through shared_preload_libraries:

SHOW shared_preload_libraries;   -- pg_durable

The Docker image sets this for you. On your own server, add pg_durable to shared_preload_libraries in postgresql.conf and restart.

2. CREATE EXTENSION sets up the engine. It then:

  • Creates the df schema — the functions you call (df.start, df.sql, df.seq, and so on).
  • Creates the internal duroxide schema — the polling functions and the bookkeeping tables (df.instances, df.nodes, df.vars).
  • Starts a single background worker that runs the duroxide engine.

That one worker opens several normal client connections to poll for and run work. So if pg_stat_activity shows a few sessions running duroxide.fetch_* queries, those are the engine’s own connections — not separate workers.

Settings you can tune

All settings use the pg_durable. prefix:

SELECT name, setting FROM pg_settings WHERE name LIKE 'pg_durable.%';
SettingDefaultWhat it controls
pg_durable.databasepostgresThe database the worker attaches to
pg_durable.worker_rolepostgresThe role the worker runs as
pg_durable.max_duroxide_connections10Pool for orchestration state and the listener
pg_durable.max_user_connections10Pool for running your SQL steps
pg_durable.max_management_connections6Pool for lifecycle and status updates
pg_durable.execution_acquire_timeout30Seconds to wait for a free slot before a step fails
pg_durable.enable_superuser_instancesonAllow workflows submitted by superusers

These settings need a restart, not a reload. They are read once at startup, so pg_reload_conf() will not apply them — you have to restart PostgreSQL for a change to take effect.

Keep in mind that the three connection pools use real backend slots. Make sure their total stays well below your server’s max_connections.

Each step is its own transaction

It is natural to assume a workflow is one atomic transaction — all steps commit, or none do. That is not how it works. Each step runs and commits in its own transaction.

Here is a three-step workflow. Step 3 fails on purpose:

CREATE TABLE orders (id int, status text, at_step text);

-- step 3 fails on purpose
SELECT df.start(
  'INSERT INTO orders VALUES (1, ''created'', ''step1'')'
  ~> 'UPDATE orders SET status = ''paid'', at_step = ''step2'' WHERE id = 1'
  ~> 'SELECT 1/0 AS this_step_fails'
);

If the workflow were one atomic transaction, step 3’s failure would roll back everything. Instead, the workflow is marked failed, but the first two steps remain committed:

SELECT status FROM df.list_instances(limit_count => 1);
-- failed

SELECT * FROM orders;
-- 1 | paid | step2

The order was created and marked paid even though the workflow stopped at the next step, because each step had already committed in its own transaction.

This is the foundation of durable execution: a crash never re-runs a completed step, so the workflow resumes from the last checkpoint. A single large transaction could not do this, and could not stay open for a workflow that waits for hours.

If you need a later failure to undo earlier work, add a compensating step (the saga pattern) yourself. The engine makes each step durable; rolling back across steps is the application’s job.

Durable signals: what LISTEN/NOTIFY cannot do

pg_durable has its own signals (df.signal and df.wait_for_signal), so a fair question is whether these are just NOTIFY in another form. They are not. NOTIFY is a transient broadcast to whoever is connected at that moment. A signal is a durable resume sent to one specific suspended workflow, even if it has been waiting for days.

LISTEN / NOTIFYpg_durable signal
DurabilityNone — in memoryCheckpointed to disk; survives restarts
TargetEvery listening connectionOne specific workflow instance
If nobody is listeningThe message is lostThe workflow resumes when the signal arrives
Holds a connection while waiting?YesNo — the workflow holds nothing
Best forFast, transient broadcastsDurable coordination: approvals, sagas

A workflow that submits a document, waits for approval, then publishes:

SELECT df.start(
  'INSERT INTO doc_review VALUES (1, ''submitted'')'
  ~> df.wait_for_signal('approved')
  ~> 'UPDATE doc_review SET state = ''published'' WHERE id = 1'
);

While it waits, only step 1 has run and the workflow holds no open connection. A reviewer resumes it by signaling that instance:

SELECT df.signal('04bf5dac', 'approved', '{"by":"alice"}');

The approval could arrive five seconds or five days later, even across a restart, and the result is the same. NOTIFY cannot do this: if no session is listening when the event is sent, the message is gone.

Note (0.2.2, preview): a signal is not a mailbox. A signal sent before the workflow reaches its wait_for_signal step is dropped. For human-driven flows this is fine; for fast machine events, plan for the race.

Use them together. They are not competitors: NOTIFY for the fast, transient “something happened” message, df.signal for the durable “resume this workflow” that holds while the workflow waits. A common design bridges the two — a listener catches the notification and turns it into a durable signal:

NOTIFY (fast, transient)  -->  app / listener  -->  df.signal() (durable resume)

The rest of the toolbox

Signals are only one part. pg_durable gives you a full workflow language inside SQL. Here is a short tour, grouped by what each piece is for.

Control flow

Function or operatorWhat it does
~>Run one step, then the next
df.join(), df.join3()Run steps at the same time and wait for all
df.race()Run steps together; the first to finish wins
df.if(), df.if_rows()Branch on a condition
df.loop(), df.break()Loop while a condition holds, and exit early

Activities (the actual work)

FunctionWhat it does
SQL (auto-wrapped) or df.sql()Run any PostgreSQL query as a durable step
df.http()Call an external API with method, body, headers, and timeout

Results and variables

SyntaxWhat it does
$name, $name.columnReuse a saved result, or one of its columns
$name?Use NULL instead of failing when data is missing
df.setvar(), df.getvar()Set and read durable variables

Time and events

FunctionWhat it does
df.sleep(seconds)Pause for a fixed time
df.wait_for_schedule(cron)Wait until a cron schedule matches
df.wait_for_signal(), df.signal()Wait for, and send, durable signals
df.wait_for_completion(id)Wait for another workflow to finish

Lifecycle, observability, and security

AreaFunctions
Lifecycledf.start() (with a label and target database), df.cancel(id, reason)
Observabilitydf.status(), df.result(), df.list_instances(), df.instance_nodes(), df.metrics(), df.explain()
Securitydf.grant_usage(), df.revoke_usage(), per-user isolation, and running as the submitting user

Built-in durability ties it together: failed SQL and HTTP steps retry automatically, and failed instances stay queryable so you can see what happened.

Where to learn more

This article only scratches the surface. For the full DSL reference and every feature in detail, see the official guide: pg_durable USER_GUIDE.md.