Durable Workflows Inside Postgres: A Field Test of pg_durable
A field test of Microsoft's pg_durable: durable workflows inside Postgres, no extra services. Where the bet pays off, and where it bites.
I have built and operated generative AI products on top of nearly every kind of scheduler. Self-built job runners. Trigger.dev. Airflow. AWS Batch. Temporal-style durable engines. Some of those choices were good. One of them taught me a lesson I now state as a rule.
One of the most expensive mistakes a startup can make is building its own scheduler.
It is an easy mistake to make, because the first version is genuinely small. A jobs table, a status column, a worker that polls for pending rows, a cron entry to kick it off. It runs. It survives the demo. It survives the first few months. The cost arrives later, and it always arrives, because a scheduler is not the part you wrote. It is the parts you did not write: retries with backoff, idempotency, exactly-once versus at-least-once, crash recovery in the middle of a job, races between two workers claiming the same row, poison jobs that fail forever and clog the queue, visibility into what is stuck and why, backfills for the day something was quietly broken. Every one of those is a settled problem in a mature system and a 2am incident in a homegrown one. Every hour spent hardening your own orchestration layer is an hour not spent on the product that is the actual reason the company exists.
The mature systems have a different cost, and it is also real. They all work. What they share is that the orchestration lives somewhere other than the data it touches. Job state sits in one system, the business data in another, and a meaningful slice of every project becomes the glue that keeps the two honest: status columns mirrored across the boundary, polling that reconciles the two halves, jobs whose only purpose is to notice when they have drifted apart.
So you are left choosing between two flavors of overhead. Build your own and own every sharp edge of durable execution. Adopt a mature engine and operate a second system that lives a network hop away from your data.
That is the backdrop against which pg_durable is interesting. Microsoft released it as a PostgreSQL extension that runs durable workflows inside the database itself. Not as another service to operate, but as a thing your existing Postgres does. For a large class of jobs, the state already lives in Postgres, so the proposal is to let the orchestration live there too. No separate scheduler. No queue service. No second account. Something that runs entirely on a laptop and would drop into a Supabase stack without adding a single box to the architecture diagram.
I spent a day building a local harness, running it against real workflow shapes, and deliberately trying to break it. This post is what I found. The short version: the core idea is genuinely compelling when your workflow state already belongs in Postgres, and the cost is that you are now operating a real Postgres extension with all the authoring and deployment consequences that brings.
The accidental scheduler
You rarely set out to build a scheduler. You back into one.
Say you want to classify incoming emails. Nothing exotic: a model call, a category written back to a row. Doing it live in the request path is the obvious first move, until the latency or the cost of a synchronous model call makes that untenable. So you move it off the hot path. Now you need somewhere to hold the work, something to run it, a way to retry the calls that fail, and a record of what already ran. That is a scheduler. You did not plan it. You shipped your way into it, under time pressure, which is exactly when the cheap homemade version looks good enough.
This is where the agentic shift matters. Not because everyone is suddenly building orchestration on purpose, but because more features now have this shape. An agent that calls a tool, waits on a model, branches on the result, and resumes after a failure is the same background-work problem wearing a different hat. The more of your product leans on model calls you do not want to make synchronously, the more often you hit this fork: build the small thing that will haunt you, or pull in a real engine and run it next to your data.
pg_durable is one answer to that fork, and an unusual one. It says the orchestration belongs in your database. That is why it is worth a serious look right now, evaluated on the least glamorous and most important axis there is: what happens when a step fails.
What pg_durable actually is
pg_durable is a PostgreSQL extension, currently in preview at version 0.2.2, that adds durable execution to the database. You describe a workflow as a graph of SQL steps using a small domain-specific language (DSL), a mini-syntax purpose-built for one job, under the df.* schema, start it with df.start(), and a background worker executes the graph asynchronously, checkpointing each step into ordinary tables. If the database crashes or restarts mid-workflow, execution resumes from the last checkpoint instead of starting over.
Underneath, it is built with pgrx and sits on two Rust libraries: duroxide provides the orchestration runtime (deterministic replay, checkpoints, timers), and duroxide-pg persists runtime state into a dedicated duroxide.* schema. Your DSL graphs live in df.*. The worker is loaded through shared_preload_libraries, which means installing it is a real database operation: the extension, a preload entry, a restart, and a privilege grant, not a pip install.
The mental shift is the whole point. Retry state, progress tracking, and checkpointing stop being bespoke application code and become rows in your database, queryable with the same SQL, the same auth model, and the same backup story as everything else you already keep there.
The local harness
I wanted reproducible evidence, not a vibe. The harness builds the extension from a pinned upstream source revision, runs it in Docker, creates a non-superuser poc_runner role, grants it pg_durable usage plus HTTP access, seeds sample data, runs each SQL scenario, restarts the container for the durability probe, and writes the artifacts of every run to a timestamped directory. The whole thing is one script, and the public repository linked at the end reproduces all of it.
The first concrete insight came before any workflow ran. The clean source build took several minutes, because it compiles cargo-pgrx, then the extension in release mode, then a pgrx binary that generates the SQL. The cached image is pleasant afterward, but the first run is not a thirty-second affair, and that first impression matters when you are evaluating whether a tool earns a place in a stack.
HTTP capability is the other piece worth knowing up front, because it is a build-time feature flag rather than a runtime setting. The extension can be built with no HTTP, with an Azure-domains allowlist, with a test-domains allowlist, or with the guards off entirely for local development. The upstream Docker image builds with the test-domains option, which is why my scenarios can call api.github.com without secrets. The consequence is that local, test, and production builds can behave differently on outbound HTTP, so parity is something you package deliberately rather than assume.
The DSL, by example
The README shows you the happy path. The DSL is more expressive than that first glance suggests, and the operators are where the model clicks. A few that matter:
~>runs steps in sequence.&fans out into parallel branches and waits for all of them.|races branches and takes the first to finish.|=>captures a step’s result under a name, which later steps read with$name.df.loop(body, condition)repeats a step until a SQL predicate stops being true.df.setvar()sets a pre-start variable, referenced as{name}.
The smallest possible workflow returns a value and completes:
SELECT df.start($$SELECT 'hello pg_durable'$$, 'poc-readiness');
Two details are worth noting. Every SQL step is a string literal, and I am writing those literals with Postgres dollar-quoting ($$...$$) so the SQL inside reads normally instead of forcing me to double every quote. More on taming the syntax, including how to avoid inlining SQL at all, in a moment. The second argument is a human label you will be grateful for when you are reading df.instances later.
The first shape worth caring about is fan-out aggregation. Three independent queries that have no reason to run in series, joined back into one write:
SELECT df.start(
(
($$SELECT count(*)::int AS user_count
FROM events WHERE kind = 'signup'$$ |=> 'users')
& ($$SELECT count(*)::int AS order_count
FROM events WHERE kind = 'paid_order'$$ |=> 'orders')
& ($$SELECT coalesce(sum(amount), 0)::numeric(12,2) AS revenue
FROM events WHERE kind = 'paid_order'$$ |=> 'revenue')
)
~> $$INSERT INTO metric_snapshots(user_count, order_count, revenue)
VALUES ($users.user_count, $orders.order_count, $revenue.revenue)$$,
'poc-async-aggregation'
);
The & operator runs the three branches in parallel, each capturing its result, and the final sequential step reads all three by name. In my run, three branches that each slept for two seconds finished in about 5.8 seconds total, not the six-plus you would get running them one after another. The fan-out is real, and it is expressed in SQL with no application code coordinating the join.
The second shape is a work queue drained by a durable loop. Keep dispatching the next pending job until none remain:
SELECT df.start(
df.loop(
$$SELECT * FROM dispatch_next_job()$$,
$$SELECT EXISTS (SELECT 1 FROM jobs WHERE status = 'pending')$$
),
'poc-dispatch-jobs'
);
This is the pattern that usually costs you a pg_cron entry, a jobs table, a status column, a retry counter, and a polling worker. This is also, not coincidentally, the exact pattern teams reach for when they start hand-rolling a scheduler. Here it is one durable function. In my run it processed the three pending jobs and wrote the six audit rows I expected, with the loop condition deciding when to stop.
The third shape is the one people reach for when they hear “AI pipeline”: call an external endpoint as a step. pg_durable ships an HTTP node, df.http(), which I used to stand in for a transformer or enrichment call:
SELECT df.setvar('transform_url', 'https://api.github.com/rate_limit');
SELECT df.start(
$$SELECT id, input_text FROM api_transforms
WHERE status = 'queued' ORDER BY id LIMIT 1$$ |=> 'item'
~> $$UPDATE api_transforms SET status = 'processing' WHERE id = $item.id$$
~> (
df.http(
'{transform_url}',
'GET',
NULL,
'{"Accept": "application/vnd.github+json", "User-Agent": "pg-durable"}'::jsonb,
30
) |=> 'api_response'
)
~> $$UPDATE api_transforms
SET status = 'completed',
response = $api_response::jsonb,
transformed_text = upper(input_text)
WHERE id = $item.id$$,
'poc-http-ai-simulation'
);
The call returned HTTP 200 and the workflow persisted the response as a JSON column. One thing to internalize early: df.http() does not fail a workflow just because the endpoint returned a non-2xx status. During development a test endpoint returned 402, and pg_durable stored that as a completed HTTP node with ok=false. The workflow has to inspect the status and decide what to do. That is defensible, but it is the kind of thing you want to learn from a blog post rather than from a silent success in production.
What harder workflows look like
Toy examples prove the syntax. The interesting question is what happens when you push on the parts that make durable engines actually useful.
Triggers as an internal event source. A normal Postgres row trigger can start a workflow. I wired a BEFORE INSERT OR UPDATE OF status trigger that calls df.start() and stamps the new instance ID back onto the row:
CREATE OR REPLACE FUNCTION trigger_item_start_workflow()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE inst_id text;
BEGIN
IF NEW.status = 'ready' AND NEW.workflow_instance_id IS NULL THEN
inst_id := df.start(
format($q$SELECT * FROM process_trigger_item(%s, '{sys_instance_id}')$q$, NEW.id),
format('trigger-item-%s', NEW.id)
);
NEW.workflow_instance_id := inst_id;
END IF;
RETURN NEW;
END;
$$;
Insert a row, update a row, bulk-update twenty rows, and each transition started a workflow. In total 22 workflows fired across the insert, update, and bulk-update paths, and a rolled-back trigger start left nothing behind. This makes a Postgres-native, row-state pub-sub plausible: the database notices the state change and orchestrates the response, with no message bus in between. Note {sys_instance_id}, a built-in substitution that hands the workflow its own ID. For an agentic system, this is a quietly powerful primitive. A row landing in a table can kick off a durable, multi-step agent run, and the link between the row and its run is just a foreign key.
Surviving a restart. This is the test that is usually painful with external systems. I started a workflow that writes a marker, sleeps for eight seconds, then writes a second marker, and I restarted the entire PostgreSQL container while it was sleeping:
SELECT df.start(
$$INSERT INTO restart_probe(marker) VALUES ('before_sleep')$$
~> df.sleep(8)
~> $$INSERT INTO restart_probe(marker) VALUES ('after_sleep')$$,
'poc-restart-resilience'
);
After the container came back, the table held exactly one before_sleep and one after_sleep. The completed step was not re-run, and the pending step still executed. Durability worked, and I was able to test it on a laptop in under a minute. Reproducing that level of crash test on a clustered external engine is a much larger undertaking.
Human approval via signals. A workflow can pause until an external signal arrives, then branch on the payload:
SELECT df.start(
df.wait_for_signal('approval', 60) |=> 'decision'
~> df.if(
$$SELECT COALESCE(($decision::jsonb->'data'->>'approved')::boolean, false)$$,
$$UPDATE approvals SET status = 'approved' WHERE status = 'needs_review'$$,
$$UPDATE approvals SET status = 'rejected' WHERE status = 'needs_review'$$
),
'poc-signal-approval'
);
The signal arrives from another session with df.signal(inst_id, 'approval', '{"approved": true}'). The wait-first shape worked cleanly: the workflow parked until the signal arrived, then branched on the payload. This is exactly the human-in-the-loop pause that agentic workflows keep needing, where a run waits for a person to approve and then takes one path or the other.
Modest load. This was not a benchmark, but I wanted to know it would not fall over on a laptop. A single session submitting 200 tiny workflows finished in about 8.8 seconds. Eight concurrent psql sessions submitting 160 workflows between them finished in about 10.1 seconds, with no stuck instances. For local development and the job volumes most internal pipelines actually see, that is comfortable.
Observability you already know. The whole run was inspectable from SQL. df.list_instances(), df.instance_info(), df.instance_nodes(), df.instance_executions(), df.result(), and df.metrics() gave me status, per-node history, captured results, and aggregate counts without learning a separate dashboard. The final snapshot read 14 instances: 12 completed, 2 intentionally failed security tests, and 0 still running. Failed HTTP nodes preserved the JSON error detail. For debugging at 2am, querying your workflow state with the same tool you already use for your data is a real comfort.
Making the DSL bearable
The objection I expected to be fatal turned out to be mostly avoidable. Writing business logic as escaped strings inside SQL is genuinely unpleasant, and if that were the only option I would not recommend the tool for anything non-trivial. It is not the only option. Three techniques take most of the pain out, and I re-ran every example in this post against the pinned build in this form to confirm it executes.
Dollar-quote the nodes. Each node is a string literal, and Postgres lets you write literals as $$...$$ instead of '...'. The SQL inside then reads normally, with real single quotes, instead of doubling every one. All the examples above use this form, and result substitutions like $item.id work inside it without issue. It is how the official user guide writes its examples, even though, oddly, the bundled agent skill still teaches the older doubled-quote style.
Stop inlining logic. Call real functions. A node can be any SQL, including a call to a function you defined the ordinary way. So for anything beyond a trivial step, keep the logic in normal PL/pgSQL or SQL functions, with full editor support, syntax highlighting, and their own tests, and let the node be a thin call:
-- the real logic lives here, edited and tested like any other function
CREATE FUNCTION mark_processing(p_id bigint) RETURNS void
LANGUAGE sql AS $$
UPDATE api_transforms SET status = 'processing' WHERE id = p_id;
$$;
-- the workflow shrinks to topology plus thin calls
SELECT df.start(
$$SELECT id FROM api_transforms WHERE status = 'queued' LIMIT 1$$ |=> 'item'
~> $$SELECT mark_processing($item.id)$$
~> $$SELECT finalize($item.id)$$
);
Done consistently, none of your business logic lives in a string. The harness already used this for the job dispatcher and the trigger handler, and it is the single biggest readability win.
Use the function-form DSL. If the operator soup (& | ?> !> @>) is the off-putting part, every operator has a named equivalent: df.join(), df.race(), df.if(), df.if_rows(), df.loop(), df.seq(). The wiring then reads like ordinary function calls.
What none of this fixes is worth stating plainly, because it is the fair version of the criticism. There is no parameter binding: a df.sql(query, args) form with real bound parameters was proposed, built, then reverted, so values are still substituted into SQL as text, which is a SQL-injection surface you have to respect. There is no reusable named workflow: df.start() registers a per-call instance, so “define once, call by name with parameters” is something you build yourself by wrapping it in a function. And there is no typed SDK for authoring in another language. The underlying duroxide libraries can be driven from Rust, Python, or Node, but that is a different product. pg_durable is the SQL-authoring layer, and the graph stays SQL.
So the honest version of the objection is not “you write everything as plain strings.” The string ugliness that makes people recoil is fixable. The real ceiling is that orchestration is authored as a SQL expression with text substitution and no type checking.
The sharp edges
A balanced field test has to report the parts that drew blood. None of these are dealbreakers. All of them are things you would rather know in advance.
SQL exceptions did not auto-retry. This is the one that surprised me most. I built a deliberately flaky SQL function that fails on its first attempt and succeeds on the second. The workflow failed on attempt one and stayed failed. There was no automatic retry. A manually resubmitted workflow succeeded because the function was idempotent enough to pass on the next try. The documentation uses broad language about retries and resume, so I filed a fresh reproduction upstream (issue #155). Until a first-class retry policy is demonstrated, assume you need explicit idempotency and your own recovery path for application-level SQL failures. For agent steps that call flaky external models, that is not a footnote, it is core design.
In-flight workflows run the current version of your code, not the version they started with. This is the finding I would attach a warning label to. I started a workflow with a sleep, replaced a referenced SQL function with CREATE OR REPLACE while it was sleeping, and the later step ran the new v2 body. Worse, dropping the referenced function before the later step ran caused that workflow to fail outright. The implication is direct: a routine migration can change or break workflows that are already running. Workflow logic is live database code, and deployments have to account for whatever is mid-flight.
The transaction boundary is a footgun. My first load harness submitted many workflows and then waited on them inside the same uncommitted transaction. The worker cannot see instances that have not committed, so it deadlocked itself. The rule, once you know it, is simple: submit, commit, then wait or poll. df.start() and df.wait_for_completion() must be separated by a commit.
The HTTP allowlist does not cover everything that can make HTTP calls. pg_durable’s native df.http() has a serious security posture: a build-time domain allowlist plus an explicit execute grant, and it correctly blocked bare IPs and non-allowlisted domains in my tests. But the allowlist only governs df.http(). When I installed the separate postgres-http extension and called it from inside a workflow SQL node, it reached https://example.com/ freely, because pg_durable runs ordinary SQL with the submitting role and cannot police every other HTTP-capable extension in the database. The native controls are good, but they are not a database-wide outbound policy. I filed that as a docs clarification (issue #214).
Metrics can overcount failures. df.metrics() counts failed rows from the lower-level duroxide.executions table, which can include orphan rows left behind by rolled-back starts. Those rows have no matching df.instances entry, so aggregate failure counts can diverge from what df.list_instances('failed') reports. If you alert on failed_instances, validate it first (issue #213).
The authoring model has a ceiling, not just sharp edges. I covered the fixes and the limits above: dollar-quoting and real functions remove the escaping pain, but there is no parameter binding and no typed SDK, and values are substituted as text. One residual the fixes do not remove is that a malformed graph expression can still fail in the PostgreSQL parser before pg_durable can hand you a workflow-specific error. That is why SQL-level tests pay off, and they are easy here. I wrote plain assertion helpers that checked graph construction stays inert until df.start(), that identical expressions produce stable graph JSON, that captured results land where expected, and that invalid raw graph JSON is rejected with a clear message. That those tests are themselves just SQL is a genuine strength.
Handling secrets
The first question a real agentic call raises is where the API key goes, and this is where the preview status shows. I built a probe for it (45_secret_handling.sql in the companion repo): a workflow that calls an authenticated endpoint with Authorization: Bearer {agent_key}, where {agent_key} is set with df.setvar to a unique sentinel, followed by a scan of every text and JSON column in pg_durable’s own df and duroxide schemas for that sentinel.
Start with what is not there. The security model designs an admin-managed df.secrets store whose resolved values would never touch results, errors, or the graph. It is marked not implemented, and the build agrees: df.secrets and df.setsecret do not exist. So the only persistent in-database mechanism is df.setvar, which the user guide simultaneously recommends for “credentials” and warns against with “avoid storing secrets in plain text.”
The probe shows why that warning matters. Substitution works: the resolved key reaches the wire. The graph definition behaves well, df.nodes stores only the {agent_key} placeholder, so resolution genuinely happens at execution time. But the resolved key then comes to rest in two durable places, neither encrypted and both in your backups: df.vars (plain text, as the docs admit) and duroxide.history.event_data, the worker’s runtime history, which records the materialized request. In one run the endpoint returned an empty 402, which means the resolved key in the history could only have come from the request the worker sent, not an echoed response. The secret is persisted durably, as a side effect of how durable execution records its steps.
So the convenient path from the user guide writes your key into two tables. The fixes, in order:
Keep the key out of the database entirely. Put the provider key behind an internal endpoint that the workflow calls, so pg_durable never holds it and it never enters the runtime history. This is also what the project’s own Azure design note advises (“keep API keys secure, not in database”), and that endpoint is the natural home for the idempotency key and retry policy the engine does not provide. This is the only option that closes both leaks.
If the key must live in the database, do by hand what the unbuilt df.secrets is specced to do: store it encrypted (Supabase Vault, pgcrypto), keep SELECT off it for workflow roles, and resolve it inside the node at execution time through a SECURITY DEFINER function so it never lands in df.vars. Be clear about the limit, though: the resolved value is still materialized into the request, so it still appears in duroxide.history.event_data. Encryption at rest protects df.vars, not the history. Pair it with short-lived keys you can rotate.
Do not put a real key in df.setvar. It is fine for a local test against a throwaway token. It is not fine for anything you would mind seeing in a backup.
This is the clearest case of the preview label meaning something concrete. The feature that would make secrets safe is designed but not built, so until it ships, secret hygiene is entirely on you.
The Postgres-native tradeoff
The sharpest edge is not a bug. It is a consequence of the whole premise, and it is the part worth thinking hardest about before adopting.
When your workflow lives in SQL, your workflow becomes database code. Changes flow through migrations, extension upgrades, grants, and database rollout processes instead of an ordinary application deploy. If your team is already comfortable with Postgres-first development, this is a feature: one deployment surface, one auth model, one backup. If your team has people who would rather work in application code, pg_durable pulls them into a world where the orchestration logic, the thing most likely to need a quick fix at an awkward hour, sits behind a migration.
Take the most ordinary case in agent work: changing a prompt. If you are classifying emails with a model, you will tune that prompt constantly, often several times a day. If the prompt lives in the workflow definition or in the SQL function a node calls, every one of those tweaks is a database migration, reviewed and shipped like a schema change, not a config edit. And because in-flight workflows resolve their SQL at execution time, a prompt change can land on runs that are already moving. The escape is to treat the prompt as data: store it in a table and have the node read the current row, which turns a prompt change back into an ordinary UPDATE. But then the prompt is no longer versioned alongside the workflow that uses it, and you are back to substituting text into a model call. Neither option is wrong. You just have to choose one on purpose, because the default, inlining the prompt, quietly puts your most-edited string behind your migration process.
This is the same tension that makes some teams uneasy about Supabase-style architectures, where an increasing share of what matters lives in Postgres migrations and edge functions rather than in a familiar application codebase. pg_durable amplifies it. The reward is that your jobs stop being a distributed-systems problem and become a database problem. The price is that they inherit your database’s deployment discipline, and combined with the in-flight migration behavior above, that discipline has to be real, not aspirational.
Where the model runs out
There is a harder limit than developer experience, and it decides whether pg_durable belongs anywhere near a given workload at all.
pg_durable orchestrates two things well: SQL steps and HTTP calls. That is the shape of the model. It is excellent for IO-bound, data-adjacent work, and it is the wrong tool the moment a step needs something Postgres cannot or should not host. A GPU for inference or fine-tuning. A long-running compute job that would pin a database backend for minutes. Large in-memory state. A non-HTTP SDK. Anything CPU-heavy enough that running it inside a database connection is a bad idea on its own merits.
You can route around this, and for many AI pipelines the route is the right one. Wrap the heavy work behind an HTTP endpoint and call it from a workflow with df.http(). Keep the durable orchestration in Postgres, and let a separate inference service do the GPU work. That is a legitimate architecture. But be clear about what just happened. The heavy compute is back in a service, with its own deployment, scaling, and account. pg_durable became the orchestrator, not the executor. That is fine, and often correct, but it is no longer the “zero infrastructure” story, and you should choose it on purpose rather than discover it halfway in.
There is a second constraint that is easy to miss in a local PoC. Workflows running inside your primary database compete with your application for connections, CPU, and IO. A burst of workflows is a burst of load on the same Postgres your users are hitting. For a single-database stack, a Supabase deployment for instance, that blast radius is a real design consideration. The honest mitigation is often a dedicated Postgres for durable execution, which is, once again, infrastructure. The lesson is not that pg_durable is wrong here. It is that “it all runs in Postgres” is a feature and a coupling at the same time, and heavy or spiky workloads turn the coupling into the thing you have to manage.
This is the boundary of the model. For data-shaped orchestration, keeping everything in Postgres removes a system. For compute-shaped or hardware-bound work, it cannot remove the system that does the heavy lifting, and pretending otherwise is how the “no infrastructure” promise quietly becomes infrastructure again.
Where it fits
I would not reach for pg_durable as a universal replacement for Airflow, Temporal, Trigger.dev, or Batch. It is not trying to be that, and the preview maturity, the no-auto-retry behavior, the absent secret store, and the text-substitution authoring model (no parameter binding, no typed SDK) all argue against betting a heterogeneous, high-volume, hardware-bound orchestration layer on it today.
Where it looks genuinely strong is the case it was built for: workflows whose state already belongs in Postgres.
- Async aggregation and reporting.
- Queue draining where the jobs are rows.
- Database-local ETL and ingest pipelines.
- Scheduled maintenance with conditional and approval steps.
- Embedding and enrichment pipelines that chunk, call an API, and upsert.
- Scoped external API calls with explicit status handling.
- Agentic workflows whose steps are mostly SQL and HTTP, where the durable state and the business data want to live together.
For those, the value is real and unusual. A durable scheduler with no separate service, full SQL-native observability, restart resilience you can test on a laptop, and the ability to start a workflow straight off a row trigger. And it lands precisely on the lesson I opened with. The reason building your own scheduler is a mistake is that durable execution is harder than it looks and is not your product. pg_durable is most valuable exactly there: for the Postgres-shaped slice of your workflows, it is the thing that means you do not have to build one. For a Postgres-centered stack, and especially for anything that wants to run inside a Supabase deployment without adding boxes, it is the most interesting entrant I have tried in this space in a while.
The full harness, every experiment, the negative-path tests, and the raw evidence are in the public companion repository: pg-durable-evaluation. It builds the extension from a pinned source revision and runs everything in Docker, so you can reproduce the findings rather than take my word for them.
One honest closing question, because the answer decides whether this tool is for you. The next time a feature quietly needs background work, a model call you would rather not make in the request path, a queue you did not plan for, you will hit the build-or-adopt fork. If your workflow state already lives in Postgres, where is your line between a jobs table with a cron entry and a real durable engine inside the database? That line is exactly where pg_durable earns its place or fails to.
Facing a complex problem? Before any code, a conversation. No pitch, no commitment.
Book a call