Modern applications demand long‑lasting workflows that can withstand crashes, restarts, and failures, often relying on external platforms. pg_durable integrates durable workflow orchestration directly into PostgreSQL, enabling workflows to be created in SQL and reliably executed, monitored, inspected, and recovered from disruptions.
This tool is particularly useful for ETL processes, data pipelines, background tasks, scheduled jobs, long‑running business procedures, and internal workflow management.
To truly grasp its capabilities, the best approach is to try it yourself. I did so by building and running it locally, which provided practical insight.
Installing pg_durable
I installed Rust to compile the extension:
curl https://sh.rustup.rs -sSf | sh
. "$HOME/.cargo/env"
I installed the PostgreSQL build dependencies.
sudo dnf install -y openssl-devel pkg-config libicu-devel readline-devel flex
I installed pgrx for PostgreSQL 17:
cargo install cargo-pgrx --version 0.16.1 --locked
cargo pgrx init --pg17=download
export PG_VERSION=pg17
I enabled the extension preload library:
sed -e "/shared_preload_libraries/s/^.*$/shared_preload_libraries='pg_durable'/" \
-i ~/.pgrx/data-17/postgresql.conf
I declared the superuser that I'll create to run the background worker:
cat >> ~/.pgrx/data-17/postgresql.conf <<<"pg_durable.worker_role='superworker'"
I cloned pg_durable from the Microsoft repository:
git clone https://github.com/microsoft/pg_durable.git
cd pg_durable
I built the extension, started PostgreSQL, and got a psql prompt:
I checked the pg_durable configuration, connected to the pg_durable.database, created the pg_durable.worker_role user, and installed the extension:
pg_durable=# \dconfig pg_durable.*
List of configuration parameters
Parameter | Value
---------------------------------------+-------------
pg_durable.database | postgres
pg_durable.enable_superuser_instances | off
pg_durable.execution_acquire_timeout | 30
pg_durable.max_duroxide_connections | 10
pg_durable.max_management_connections | 6
pg_durable.max_user_connections | 10
pg_durable.worker_role | superworker
(7 rows)
\c postgres
CREATE ROLE superworker SUPERUSER LOGIN;
CREATE EXTENSION pg_durable;
My PostgreSQL instance is now ready to use durable functions.
Hello World
I create a user that will execute workflows and grant access to pg_durable:
CREATE USER franck;
GRANT CREATE ON DATABASE postgres TO franck;
SELECT df.grant_usage('franck');
I reconnect as that user:
postgres=# \c postgres franck
You are now connected to the database "postgres" as user "franck".
postgres=>
I start a durable workflow:
postgres=> SELECT df.start(
$$SELECT ''Hello, durable world!'' AS message$$
);
start
----------
393f09e0
(1 row)
One second later, it is completed:
postgres=> SELECT df.status('393f09e0');
status
-----------
completed
(1 row)
The result can be fetched as a document with a list of rows:
postgres=> SELECT df.result('393f09e0');
result
------------------------------------------------------------------
{"rows": [{"message": "Hello, durable world!"}], "row_count": 1}
(1 row)
I used the same "Hello, durable world!" example as in the HorizonDB durable functions documentation, because this is where pg_durable comes from: Microsoft provides it to PostgreSQL as an open-source extension.
Here is more information about this execution:
postgres=> \x
Expanded display is on.
postgres=> SELECT * FROM df.list_instances();
-[ RECORD 1 ]---+-------------------------------------------------------------
instance_id | 393f09e0
label |
function_name | pg_durable::orchestration::execute-function-graph
status | completed
execution_count | 1
output | {"rows":[{"message":"Hello, durable world!"}],"row_count":1}
postgres=> \x
Expanded display is off.
postgres=>
The execution graph is basic, as there's a single operation:
postgres=> SELECT df.explain('393f09e0');
explain
------------------------------------------------------------------------
Instance: 393f09e0 +
Status: ✓ Completed +
Output: {"rows":[{"message":"Hello, durable world!"}],"row_count":1}+
+
SQL: SELECT 'Hello, durable world!' AS message ✓
(1 row)
There is a single node:
postgres=> \x
Expanded display is on.
postgres=> SELECT * from df.instance_nodes('393f09e0');
-[ RECORD 1 ]+-----------------------------------------------------------------
execution_id | 1
node_id | 60e433bb
node_type | SQL
query | SELECT 'Hello, durable world!' AS message
result_name |
left_node |
right_node |
status | completed
result | {"rows": [{"message": "Hello, durable world!"}], "row_count": 1}
updated_at | 2026-06-04 16:36:38.594906+00
postgres=> \x
Expanded display is off.
It was executed once:
postgres=> SELECT * from df.instance_executions('393f09e0');
execution_id | status | event_count | duration_ms | output
--------------+-----------+-------------+-------------+--------------------------------------------------------------
1 | Completed | 14 | 692 | {"rows":[{"message":"Hello, durable world!"}],"row_count":1}
(1 row)
Here are the global metrics:
postgres=> SELECT * FROM df.metrics();
total_instances | running_instances | completed_instances | failed_instances | total_executions | total_events
-----------------+-------------------+---------------------+------------------+------------------+--------------
1 | 0 | 1 | 0 | 1 | 14
(1 row)
This simply looks like ADBA—Asynchronous Database Access, but it can do more. We will see that it can define complex workflows with a declarative syntax. The examples start the workflow immediately, but it can also wait for a signal or a specific time using a cron syntax:
Beyond a Single Query
While a simple SQL statement demonstrates the basics, pg_durable becomes much more powerful when orchestrating multiple operations.
The SQL workflow DSL supports:
| Feature |
Operator |
| Variable capture |
=> |
| Sequential execution |
~> |
| Parallel execution (wait) |
& |
| Parallel execution (race) |
` |
| Conditional branching |
{% raw %}?> and !>
|
| Infinite loops |
@> |
Here are simple table-free examples demonstrating pg_durable's key features. Let's start with a sequential execution (~>):
postgres=> SELECT df.start(
'SELECT now() as step1' |=> 't1'
~> 'SELECT pg_sleep(5)'
~> 'SELECT now() as step2' |=> 't2'
~> 'SELECT now() as step3' |=> 't3',
'sequential-timing'
) as i \gset
-- Check the results after five seconds
postgres=> SELECT df.result(:'i');
result
---------------------------------------------------------------------------
{"rows": [{"step3": "2026-06-04T21:33:59.939785+00:00"}], "row_count": 1}
(1 row)
postgres=> SELECT node_type, result_name, result, node_id, left_node, right_node FROM df.instance_nodes(:'i');
node_type | result_name | result | node_id | left_node | right_node
-----------+-------------+---------------------------------------------------------------------------+----------+-----------+------------
THEN | t3 | {"rows": [{"step3": "2026-06-04T21:33:59.939785+00:00"}], "row_count": 1} | 49c9c8b4 | 48fcec43 | 9f818758
THEN | t2 | {"rows": [{"step2": "2026-06-04T21:33:59.507283+00:00"}], "row_count": 1} | 48fcec43 | 348c1948 | c78f2200
THEN | | {"rows": [{"pg_sleep": null}], "row_count": 1} | 348c1948 | c7317ea8 | 200f8a52
SQL | t1 | {"rows": [{"step1": "2026-06-04T21:33:53.746030+00:00"}], "row_count": 1} | c7317ea8 | |
SQL | | {"rows": [{"pg_sleep": null}], "row_count": 1} | 200f8a52 | |
SQL | | {"rows": [{"step2": "2026-06-04T21:33:59.507283+00:00"}], "row_count": 1} | c78f2200 | |
SQL | | {"rows": [{"step3": "2026-06-04T21:33:59.939785+00:00"}], "row_count": 1} | 9f818758 | |
(7 rows)
I displayed the timestamps to show that steps 2 and 3 occur 5 seconds after step 1 because I used pg_sleep(5) in the sequential execution.
The node_type column indicates the type of operation each node represents in the function graph. Here, THEN denotes sequential execution: running the left child, then the right child, and SQL is the leaf node with no children, executing the SQL query.
The complete list of valid node types in pg_durable is:
| node_type |
Description |
Operator/Function |
| SQL |
Execute SQL query |
Plain string or df.sql()
|
| THEN |
Sequential execution |
~> operator or df.seq()
|
| IF |
Conditional branch |
?> !> operators or df.if()
|
| JOIN |
Parallel execution (wait all) |
& operator or df.join()
|
| RACE |
Parallel execution (first wins) |
\ operator or df.race()
|
| LOOP |
Infinite loop |
@> operator or df.loop()
|
| BREAK |
Exit loop |
df.break() |
| SLEEP |
Pause execution |
df.sleep() |
| WAIT_SCHEDULE |
Wait for cron schedule |
df.wait_for_schedule() |
| HTTP |
Make HTTP request |
df.http() |
| SIGNAL |
Wait for external signal |
df.wait_for_signal() |
We can execute steps in parallel (&):
SELECT df.start(
('SELECT now() as branch1' |=> 'b1' ~> 'SELECT pg_sleep(20)')
&
('SELECT now() as branch2' |=> 'b2' ~> 'SELECT pg_sleep(10)')
~> 'SELECT now() as after_join' |=> 'final',
'parallel-sleep'
) as i \gset
postgres=> SELECT node_type, result_name, result, node_id, left_node, right_node FROM df.instance_nodes(:'i');
node_type | result_name | result | node_id | left_node | right_node
-----------+-------------+--------------------------------------------------------------------------------------------------+----------+-----------+------------
SQL | b1 | {"rows": [{"branch1": "2026-06-04T21:53:47.870539+00:00"}], "row_count": 1} | 9d479c96 | |
SQL | b2 | {"rows": [{"branch2": "2026-06-04T21:53:47.870606+00:00"}], "row_count": 1} | cb844cd8 | |
SQL | | {"rows": [{"pg_sleep": null}], "row_count": 1} | ad8ec143 | |
THEN | | {"rows": [{"pg_sleep": null}], "row_count": 1} | 1ff3adc8 | cb844cd8 | ad8ec143
SQL | | {"rows": [{"pg_sleep": null}], "row_count": 1} | 9db57c15 | |
THEN | | {"rows": [{"pg_sleep": null}], "row_count": 1} | 5e4d8069 | 9d479c96 | 9db57c15
JOIN | | [{"rows": [{"pg_sleep": null}], "row_count": 1}, {"rows": [{"pg_sleep": null}], "row_count": 1}] | 6f95c84d | 5e4d8069 | 1ff3adc8
SQL | | {"rows": [{"after_join": "2026-06-04T21:54:08.752247+00:00"}], "row_count": 1} | d4be28af | |
THEN | final | {"rows": [{"after_join": "2026-06-04T21:54:08.752247+00:00"}], "row_count": 1} | df59e250 | 6f95c84d | d4be28af
(9 rows)
Here, b1 and b2 started at the same time, in parallel. One branch completed after 20 seconds and the other after 10 seconds, so the final step started after 20 seconds, and the total duration was 20 seconds—it would have been more than 30 seconds if executed serially:
postgres=> SELECT * from df.instance_executions(:'i');
execution_id | status | event_count | duration_ms | output
--------------+-----------+-------------+-------------+----------------------------------------------------------------------------
1 | Completed | 26 | 22107 | {"rows":[{"after_join":"2026-06-04T21:54:08.752247+00:00"}],"row_count":1}
(1 row)
Parallel nodes are used when we need the result from all operations. The next step awaits all. If we want to continue as soon as one branch gets a result, we can run a race, where the first one wins (|):
SELECT df.start(
'SELECT pg_sleep(30)' | 'SELECT pg_sleep(90)' ~> 'SELECT ''fast'' as winner',
'race-test'
) as i \gset
select usename,backend_start,query_start,wait_event,state,query,application_name from pg_stat_activity where usename=user and pid!=pg_backend_pid(
)
\watch 10
Fri 05 Jun 2026 04:08:30 AM GMT (every 10s)
usename | backend_start | query_start | wait_event | state | query | application_name
---------+-------------------------------+-------------------------------+------------+--------+---------------------+------------------
franck | 2026-06-05 04:08:29.873785+00 | 2026-06-05 04:08:29.877368+00 | PgSleep | active | SELECT pg_sleep(30) |
franck | 2026-06-05 04:08:29.875738+00 | 2026-06-05 04:08:29.879296+00 | PgSleep | active | SELECT pg_sleep(90) |
(2 rows)
Fri 05 Jun 2026 04:08:40 AM GMT (every 10s)
usename | backend_start | query_start | wait_event | state | query | application_name
---------+-------------------------------+-------------------------------+------------+--------+---------------------+------------------
franck | 2026-06-05 04:08:29.873785+00 | 2026-06-05 04:08:29.877368+00 | PgSleep | active | SELECT pg_sleep(30) |
franck | 2026-06-05 04:08:29.875738+00 | 2026-06-05 04:08:29.879296+00 | PgSleep | active | SELECT pg_sleep(90) |
(2 rows)
Fri 05 Jun 2026 04:08:50 AM GMT (every 10s)
usename | backend_start | query_start | wait_event | state | query | application_name
---------+-------------------------------+-------------------------------+------------+--------+---------------------+------------------
franck | 2026-06-05 04:08:29.873785+00 | 2026-06-05 04:08:29.877368+00 | PgSleep | active | SELECT pg_sleep(30) |
franck | 2026-06-05 04:08:29.875738+00 | 2026-06-05 04:08:29.879296+00 | PgSleep | active | SELECT pg_sleep(90) |
(2 rows)
Fri 05 Jun 2026 04:09:00 AM GMT (every 10s)
usename | backend_start | query_start | wait_event | state | query | application_name
---------+-------------------------------+-------------------------------+------------+--------+---------------------+------------------
franck | 2026-06-05 04:08:29.875738+00 | 2026-06-05 04:08:29.879296+00 | PgSleep | active | SELECT pg_sleep(90) |
(1 row)
Fri 05 Jun 2026 04:09:10 AM GMT (every 10s)
usename | backend_start | query_start | wait_event | state | query | application_name
---------+-------------------------------+-------------------------------+------------+--------+---------------------+------------------
franck | 2026-06-05 04:08:29.875738+00 | 2026-06-05 04:08:29.879296+00 | PgSleep | active | SELECT pg_sleep(90) |
(1 row)
Fri 05 Jun 2026 04:09:20 AM GMT (every 10s)
usename | backend_start | query_start | wait_event | state | query | application_name
---------+-------------------------------+-------------------------------+------------+--------+---------------------+------------------
franck | 2026-06-05 04:08:29.875738+00 | 2026-06-05 04:08:29.879296+00 | PgSleep | active | SELECT pg_sleep(90) |
(1 row)
Fri... (truncated)
by Franck Pachot
VillageSQL Blog
Stay updated with the latest MySQL and database news, including the OurSQL Foundation announcement, Percona updates, and 2026 tech events.
by Dominic Preuss