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
June 03, 2026
VillageSQL Blog
Learn how VillageSQL balances ease and power in API design using the Happy Path, the Escape Hatch, and the deliberate patience in between.
by Dan Bentley
AWS Database Blog - Amazon Aurora
In Part 1, Part 2, and Part 3 of this series, we explored PostgreSQL’s native indexes (B-tree, GIN, GiST, HASH, BRIN) and specialized extension-based index types (SP-GiST, btree_gin, btree_gist). In this post, we dive into three additional extensions: Bloom (for space-efficient multi-column equality filtering), pg_trgm (for fuzzy text matching and similarity searches), and pg_bigm (for full-text search optimized for Asian languages)
by Rajkumar Raghuwanshi
AWS Database Blog - Amazon Aurora
In this post, the third in the series, we dive into three extension-based index types: SP-GiST, btree_gin, and btree_gist. These are available in Amazon Aurora PostgreSQL-Compatible Edition and Amazon Relational Database Service (Amazon RDS) for PostgreSQL. PostgreSQL’s index infrastructure is extensible. Operator classes define how indexes behave for specific data types and operations. The SP-GiST, btree_gin, and btree_gist extensions take advantage of this extensibility to give you additional indexing strategies beyond the native options. We walk through when to use each extension, the data types they support, and practical examples that demonstrate their performance benefits.
by Rajkumar Raghuwanshi
AWS Database Blog - Amazon Aurora
This post walks through migrating data from an Oracle source to Amazon Aurora DSQL, using AWS DMS, Amazon S3, AWS Glue, and AWS Step Functions to create an automated, cost-effective migration pipeline suitable for enterprise-scale deployments.
by Aliasghar Hussain
AWS Database Blog - Amazon Aurora
In this post, we demonstrate how to implement a production-ready CDC solution by using Amazon Aurora for PostgreSQL, Debezium connectors, and Amazon Managed Streaming for Apache Kafka (Amazon MSK). This solution captures database changes in real time and streams them to Kafka topics so that downstream consumers can process the same data for different business purposes.
by Kirthi Vishal Dourla
Tinybird Engineering Blog
Data scientists need a database that handles large-scale feature computation, fast iteration in notebooks, and statistical queries over billions of rows without waiting minutes for results. ClickHouse fits that workflow better than most tools built for data science.
by Tinybird
Tinybird Engineering Blog
Ecommerce generates event data at a volume and velocity that traditional databases struggle to query in real time. ClickHouse handles order tracking, inventory analytics, real-time merchandising, and customer behavior analysis at the scale modern ecommerce produces.
by Tinybird
Tinybird Engineering Blog
Event tracking generates the highest-volume, most query-intensive data in most products. ClickHouse handles the ingestion throughput, the schema patterns, the session analysis, and the funnel queries that make product analytics fast and fresh.
by Tinybird
Tinybird Engineering Blog
ClickHouse is fast by default, but how fast depends entirely on schema design and query patterns. The difference between a 50ms query and a 50-second query on the same data is usually one of five things: sort key misuse, missing pre-aggregation, COUNT DISTINCT overhead, no PREWHERE, or querying raw data when a rollup exists.
by Tinybird