Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /pg_durable: Durable Workflows in PostgreSQL vs Temporal and Cron+Queue
databases29 min read

pg_durable: Durable Workflows in PostgreSQL vs Temporal and Cron+Queue

Microsoft's pg_durable brings durable workflow execution inside PostgreSQL. Here is the syntax, what it replaces, and an honest comparison against Temporal and cron+queue patterns.

Zeeshan Tofiq
Zeeshan Tofiq
June 23, 2026
On this page

On this page

  • What Problem pg_durable Solves
  • The Actual Syntax
  • What You Get for Free
  • Idempotency and Exactly-Once Semantics
  • Retries, Backoff, and Where Retry State Lives
  • pg_durable vs Temporal
  • pg_durable vs Cron + Jobs Table
  • Observability: Debugging a Stuck Run
  • Operational Burden: What You Run and Page On
  • Scaling Characteristics and Where Each Breaks
  • Cost at Small, Medium, and Large Scale
  • Testing Durable Workflows
  • Migration Path If You Outgrow pg_durable
  • Decision Table: Which Should You Pick?
  • When pg_durable Is the Wrong Choice
  • Trying It Locally
  • The Honest Verdict
  • Frequently Asked Questions

Background jobs that need to survive a crash are one of those problems every backend team eventually reinvents from scratch. You build a jobs table, add status columns, write a polling worker, bolt on retry counters, and six months later you are debugging why a job got stuck in "processing" forever because the worker died mid-update.

Microsoft open-sourced pg_durable in early June 2026 to attack this problem from a different angle: define the workflow in SQL, and let PostgreSQL itself handle checkpointing, retries, and crash recovery. No separate orchestrator service. No queue infrastructure. The state lives in the same database as everything else you already trust with your data.

Here is what it actually does, and an honest comparison against the patterns you are probably already using.

What Problem pg_durable Solves

The pattern everyone reinvents: background work that must survive a crash, restart, or failed step, with retries, checkpointing, and progress tracking. Currently solved with cron jobs plus status tables, external orchestrators like Temporal or AWS Step Functions, or hand-rolled plpgsql procedures with manual retry counters.

pg_durable replaces all of that with a SQL-native DSL. You define workflows as composable SQL expressions using three operators, and PostgreSQL handles the rest: checkpoint after every step, resume from the last completed step on crash, and expose the workflow state through ordinary SQL tables.

The Actual Syntax

A minimal example, straight from the project's own quick start:

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

Three operators do the work. ~> sequences one step after another. |=> gives a step's result a name you can reference later. & runs two branches in parallel:

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

You query the workflow's progress and result with ordinary SQL:

sql
SELECT * FROM df.instance_nodes('your-instance-id');
SELECT * FROM df.instance_executions('your-instance-id');
pg_durable's three composable operators.
OperatorPurposeExample
<code>~&gt;</code>Sequence: run this step after the previous one completes'SELECT step1' ~> 'SELECT step2'
<code>|=&gt;</code>Name: label this step's result for later reference'SELECT now()' |=> 'timestamp'
<code>&amp;</code>Parallel: run two branches concurrently, join before continuing(branch1) & (branch2) ~> 'after-join'

What You Get for Free

Each step checkpoints automatically. If the process crashes mid-workflow (server restart, OOM kill, whatever), resuming picks up from the last completed step, not from the beginning. You are not writing that retry logic by hand.

Operational visibility comes from regular tables: df.instances, df.instance_nodes, df.instance_executions. No separate monitoring dashboard to stand up. The same SQL client and backup tooling you already use for your application data works here too.

Security and access control inherit from PostgreSQL directly. The workflow state is just data in your database, covered by your existing RBAC roles, backup schedules, and encryption-at-rest configuration.

Idempotency and Exactly-Once Semantics

Exactly-once is the phrase every durable execution engine gets asked about, and neither pg_durable nor Temporal delivers it the way people imagine. What both actually give you is exactly-once state transition combined with at-least-once side effect execution. The difference stops being academic the moment a step touches something outside the database.

In pg_durable the boundary is unusually clean, because the checkpoint and the work can share a transaction. A step is a SQL expression, so applying the step's effect and recording that the step completed commit together or not at all. If the server dies before commit, neither happened. That is the strongest structural argument for keeping workflow state in the same database as your business data: no two-phase commit, no dual-write problem, no reconciliation job to chase divergence.

Temporal cannot offer that, and does not pretend to. An activity runs in a separate worker process and talks to systems Temporal knows nothing about. If a worker charges a card and then dies before reporting completion, Temporal will retry the activity, because from the server's point of view the attempt never finished. The card gets charged twice unless you made the activity idempotent yourself.

So the practical rule is identical in both systems, and it is the rule most teams skip: make every side-effecting step idempotent, keyed on something stable across retries and unique across runs.

sql — Idempotency key derived from the workflow instance and step
INSERT INTO payments (idempotency_key, order_id, amount_cents, status)
VALUES (
  'wf:' || :instance_id || ':charge',
  :order_id,
  :amount_cents,
  'captured'
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;

The workflow instance id plus the step name produces a key that survives any number of retries of that specific run, while a different run of the same workflow gets a different key. Re-running the step after a crash hits the unique constraint and does nothing the second time.

One sharp edge worth knowing: ON CONFLICT DO NOTHING returns no row when the conflict fires, so a step that expects a RETURNING value will see an empty result on the retry rather than the original row. If the following step needs that id, you want DO UPDATE with a no-op assignment, or a separate select. Choosing between those variants correctly is its own small topic, and the same upsert questions show up constantly in SQL interview questions.

ℹ Push the key outward, not just inward

Storing an idempotency key in your own table only protects your own writes. If the step calls an external API, that same key has to travel with the request. Most payment, email, and messaging providers accept an idempotency header for exactly this reason, and a retried step that omits it will happily perform the action twice.

Retries, Backoff, and Where Retry State Lives

Retry logic is where hand-rolled job systems rot. Version one has a retry_count column. Then someone adds next_attempt_at for backoff. Then a poison job pins a worker in a hot loop and someone adds a dead letter table. Every one of those columns is state your team now owns, tests, and explains to the next hire.

Temporal treats retry policy as a declarative property of an activity: an initial interval, a backoff coefficient, a maximum interval, a maximum number of attempts, and a list of error types that must never be retried. The attempt count and the next scheduled attempt live in the workflow's event history on the server, so a worker that restarts mid-backoff does not reset the clock or lose its place.

pg_durable keeps checkpoint and execution state in the df.* tables inside your own database. That is a real advantage for inspection, because you can query it, join it against business tables, and back it up with everything else in the same snapshot. It is also a real constraint on expressiveness: the retry semantics are whatever the extension implements, not whatever you can express in application code.

In the cron plus jobs table pattern, retry state lives in columns you wrote, and the failure mode nobody plans for is the stuck row. A worker claims a job, sets status to processing, and dies. Nothing ever resets it. Both durable execution engines exist largely to delete that entire class of bug, which is why almost every team writes a reaper query like this one before eventually reaching for a real orchestrator:

sql — The reaper query every hand-rolled job system ends up with
UPDATE jobs
SET status     = 'pending',
    attempts   = attempts + 1,
    locked_by  = NULL,
    locked_at  = NULL
WHERE status = 'processing'
  AND locked_at < now() - interval '10 minutes'
  AND attempts < 5;

Note the attempts < 5 guard. Without it, a genuinely poisonous job cycles between pending and processing forever, burning worker capacity and filling logs. With it, the job silently stalls in processing and nobody notices until a customer complains. Neither branch is good, and that is the point.

Retry state across the three approaches. The column that should worry you is the first one.
Concerncron + jobs tablepg_durableTemporal
Where attempt state livesA column you added and maintain<code>df.*</code> tables in your databaseWorkflow event history on the server
BackoffA timestamp column plus your own scheduling queryManaged by the extensionDeclarative retry policy per activity
Crash mid-attemptRow stays locked until a reaper resets itResumes from the last committed checkpointServer reschedules the activity on any healthy worker
Non-retryable failuresAn error-type check you write by handInspect <code>df.instance_executions</code> and decide in SQLDeclared as non-retryable error types
Inspecting attempt historyWhatever you thought to logOrdinary SQL over the execution tablesFull event history in the Web UI

pg_durable vs Temporal

Temporal is mature, proven at scale, and built for the general case: arbitrary application logic, calling out to multiple heterogeneous systems, complex branching and loops in whatever language your services are written in. If your workflow needs to call five different microservices with substantial conditional logic between steps, Temporal's general-purpose execution model is the better fit. pg_durable is specifically a SQL-native tool, and forcing complex non-SQL logic into it works against the grain.

pg_durable's case is narrower and specific: when the workflow steps are already SQL statements (or close to it), and you would rather not run, monitor, and keep available a second piece of infrastructure just to get durability for jobs that mostly touch your own database anyway.

ℹ Two unrelated things named Temporal

Temporal the workflow engine has nothing to do with Temporal the JavaScript date and time API. If you landed here looking for the latter, the Temporal Type Picker helps you choose between Instant, ZonedDateTime, and PlainDate.

pg_durableTemporal
Workflow languageSQL with custom operatorsAny language (Go, Java, Python, TS, etc.)
InfrastructurePostgreSQL extension (no extra services)Separate Temporal server + workers
Best forSQL-native workflows within one databaseMulti-system orchestration with complex logic
CheckpointingPer-step, in Postgres tablesPer-activity, in Temporal's own storage
MonitoringSQL queries against df.* tablesTemporal Web UI + SDK metrics
MaturityNew (June 2026, open-source)Battle-tested (years of production use)

pg_durable vs Cron + Jobs Table

This is the comparison that matters for most teams, because the cron+queue pattern is what almost everyone already has: a jobs table, status columns (pending, processing, failed, done), retry counters, and a worker process polling for work.

pg_durable's pitch is that this entire layer (the worker, the queue consumer, the scheduler glue) can disappear. Retry state and checkpointing move into Postgres tables the project maintains for you, instead of bespoke application code your team owns and debugs.

The job that used to be a single INSERT ... SELECT or one ordinary SQL statement might not need pg_durable at all. The project's own documentation explicitly lists "the problem is a single SQL statement" as a case where you do not need it.

ℹ Related tool

If you are also evaluating PostgreSQL upsert patterns for your background jobs, see UpsertPicker to decide between ON CONFLICT DO NOTHING, DO UPDATE, and the new DO SELECT in PostgreSQL 19.

Observability: Debugging a Stuck Run

The question you will actually ask at 2am is not how to define a workflow. It is why this particular one has been running for six hours.

With pg_durable the answer is a SQL query, which is either wonderful or limiting depending on your temperament. There is no timeline UI, no click-through history view, and no built-in alerting. There is also nothing new to learn, no extra port to expose, and no second authentication system to keep in sync with your directory.

The underrated advantage is that a workflow running inside Postgres is already covered by your existing database monitoring. A run that looks stuck is usually blocked on a lock rather than genuinely slow, and the tools that answer that question are the ones you already have open:

sql — Find the blocked backend and what it is waiting on
SELECT pid,
       state,
       wait_event_type,
       wait_event,
       now() - query_start AS running_for,
       left(query, 120)    AS query
FROM pg_stat_activity
WHERE state <> 'idle'
  AND backend_type = 'client backend'
ORDER BY query_start;

A non-null wait_event_type of Lock tells you the step is not slow, it is queued behind another transaction. From there pg_locks identifies the holder, and pg_blocking_pids(pid) gives you the chain directly. Cross-referencing that against df.instance_nodes() tells you which workflow step is stalled and why.

Temporal inverts the whole model. You get a Web UI showing the complete event history of a run: every activity attempt, every failure with its stack trace, every timer that fired, every signal received. You can query a running workflow for its internal state without stopping it, and you can send it a signal to unblock it. For genuinely complex workflows that is a much better debugging experience, and it is the single thing teams miss most when they move off Temporal.

The cost is that this visibility lives in a separate system with its own storage, retention policy, and access control. Workflow history is not in your database, so you cannot join a failed run against the customer record that triggered it without exporting one side or the other first.

⚠ Long transactions block vacuum everywhere

A workflow step that waits inside an open transaction holds back the xmin horizon, which stops autovacuum from cleaning dead tuples across the entire database, not just the workflow tables. Watch for long-lived idle in transaction sessions in pg_stat_activity before you scale this pattern up, and set idle_in_transaction_session_timeout as a safety net.

Operational Burden: What You Run and Page On

Strip away the marketing and count the things you have to run, patch, upgrade, and get paged for.

  • pg_durable: your existing PostgreSQL cluster plus one extension version to track. Backups, failover, and connection pooling are already solved problems in your stack. The new failure mode is upgrade coupling: an extension pinned to a Postgres major version can hold your upgrade path hostage.
  • Temporal self-hosted: the Temporal service itself (frontend, history, matching, and worker roles), a persistence store, usually a search store for advanced visibility, and your own worker fleet. That is a genuine platform commitment, not a weekend install.
  • Temporal Cloud: the server becomes someone else's on-call rotation, but you still run and scale the workers. Workers are where the majority of your real failures live anyway, so this removes less operational surface than it first appears.
  • cron + jobs table: nothing new to run, everything to write. The burden is code your team maintains forever rather than a service your team operates.

The honest framing is that Temporal moves complexity out of your application code and into your infrastructure, while pg_durable moves it into your database. Neither deletes it. Pick based on which of those your team is genuinely better at running.

If your on-call rotation is three people covering everything, adding a stateful distributed system with four service roles is a decision to make deliberately and with eyes open. If you already have a platform team running Kafka and Kubernetes, Temporal is close to zero marginal burden and the calculus flips entirely.

Scaling Characteristics and Where Each Breaks

pg_durable's ceiling is your primary Postgres instance, and there is no way around that by design. Every checkpoint is a write, so workflow throughput consumes write capacity, WAL bandwidth, and replication lag budget that your application queries also need. You cannot shard workflow execution away from the data it operates on, which is exactly the property that makes it simple at small scale and awkward at large scale.

The pressure points arrive in a predictable order. WAL volume and replica lag go first. Then autovacuum falls behind on the workflow tables because checkpoint rows churn constantly. Then connection saturation, if each in-flight workflow occupies a backend. Read replicas do not rescue you here, because checkpoints are writes by definition.

Temporal scales in the opposite direction. Workers are stateless and horizontally scalable, and the persistence layer is built to be sharded, which is how teams run very large numbers of concurrent executions on it. The tradeoff is that per-workflow overhead is high relative to a single SQL statement, so Temporal is a poor fit for enormous volumes of tiny, high-frequency tasks.

Temporal's real structural limit is per-workflow history size. Every event is retained so the workflow can be replayed deterministically, which means a workflow that loops thousands of times accumulates history until it approaches the server's event count and payload size limits. The fix is continue-as-new, which closes the current execution and starts a fresh one with a clean history while carrying state forward. It is a well-trodden pattern, but teams usually learn it by hitting the wall in production first.

pg_durable has no replay-history problem at all, because it stores state rather than reconstructing it from an event log. It has the mirror-image problem instead: stored state means row churn, index bloat, and vacuum pressure. The tradeoffs here rhyme with the storage and partitioning questions in NoSQL system design, where the same choice between replaying a log and storing materialised state keeps reappearing under different names.

⚠ Do not model long waits as sleeping SQL

A step like pg_sleep(5) is fine in a quick start demo. It is not a way to wait hours or days: a sleeping backend holds a connection, and if a transaction is open it also holds a snapshot. Durable timers exist in Temporal precisely because parking a real process for a day is not a thing you can do. If your workflow needs long waits, that is a strong signal about which tool you need.

Cost at Small, Medium, and Large Scale

Vendor pricing changes often enough that quoting figures would date this article within a quarter, so treat the table below as cost shapes rather than a quote. What matters is which direction each cost curve bends as you grow.

Cost shape by scale. The first column is the one teams consistently underestimate.
Scalecron + jobs tablepg_durableTemporal
Small (a few thousand runs a day)Looks free. Actually costs the engineering weeks to build it and the incidents while you debug it.No new infrastructure at all, assuming your host allows the extension.Cloud tier billed per action is cheap in absolute terms, but you still host and pay for workers.
Medium (tens of thousands to a million a day)Real cost shows up as on-call time, stuck-job incidents, and a growing pile of bespoke code nobody owns.Primary instance sizing goes up to absorb checkpoint write volume and vacuum load.Per-action billing scales linearly with workflow chattiness, and the worker fleet becomes a visible line item.
Large (millions a day)Not viable without effectively rebuilding it into a real orchestrator.Likely at the ceiling of a single primary. Needs partitioning, offloading, or a different tool.Designed for exactly this. Cost is higher but predictable, and the operational model is proven.

The number almost everyone gets wrong is the first column. A hand-rolled job system appears free because the infrastructure invoice does not change. The cost is paid in incidents, in the reaper query nobody documented, and in the six hours an engineer spends every quarter explaining why a job ran twice.

Temporal's per-action billing has a subtle consequence worth planning around: chatty workflows made of many tiny activities cost more than coarse-grained workflows doing identical work. Batching activities is a cost lever, not only a latency one.

pg_durable's cost never appears on an invoice. It is write amplification on your primary and the fact that workflow load and user-facing query load now compete for the same buffers, the same WAL, and the same autovacuum workers. Buying headroom is the real spend, and reducing that competition is the same instinct behind most caching strategies: keep the expensive shared resource out of the path of the thing users are waiting on.

Testing Durable Workflows

The bug you are trying to catch is never whether the happy path works. It is what happens when the process dies between step three and step four. Test for that explicitly or you are not testing durability at all, you are testing a sequence of function calls.

pg_durable tests well precisely because everything lives in the database. Start the Docker image as a service container, launch a workflow, then simulate the crash by terminating the backend running it and reconnecting:

sql — Force a crash mid-workflow in an integration test
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
  AND application_name = 'workflow-test-runner';

Then resume, and assert two things: that the already-completed steps did not execute a second time, and that your side-effect table still contains exactly one row for the idempotency key. If the count is two, you have found the bug that would otherwise have found you in production.

Temporal has the stronger story for testing complex logic. Its test framework provides a time-skipping environment, so a workflow that waits thirty days for an approval runs in milliseconds, and activities can be mocked to force specific failure sequences. Replay tests are the other half: you capture a real workflow's history and replay it against your new code to prove the change did not break determinism.

That determinism constraint is Temporal's sharpest edge and the thing that most surprises teams arriving from ordinary application development. Workflow code is replayed from history, so it must not read the wall clock directly, generate random numbers, or depend on nondeterministic iteration order. Every SDK ships deterministic replacements for those operations. pg_durable has no equivalent constraint, because it stores state rather than replaying code, and that is a legitimate simplicity advantage.

  • Kill the process mid-run and assert the workflow resumes from the last checkpoint rather than step one
  • After a forced crash and retry, assert every side effect happened exactly once, not just that the run succeeded
  • Force each individual step to fail and confirm the retry, backoff, and give-up behaviour you expect
  • Test the timeout path separately from the error path: a step that hangs behaves nothing like a step that throws
  • Run workflows against a database with realistic row counts and realistic lock contention, not an empty schema
  • For Temporal, add a replay test against recorded production history before every workflow code change

Migration Path If You Outgrow pg_durable

Suppose you adopt pg_durable, it works, and eighteen months later you outgrow it. What does the exit actually look like? This question deserves an answer before you adopt, not after.

The encouraging part is that the shape transfers cleanly. A pg_durable workflow is a sequence of named steps with checkpoints between them, which is structurally what a Temporal workflow is: a sequence of activities with the orchestrator recording progress between them. The named-result operator maps onto activity return values almost one to one.

The work concentrates in three places. First, each SQL step becomes an activity function in your language of choice, which is largely mechanical when your steps are already single statements. Second, the orchestration expression becomes ordinary control flow in workflow code, which is usually a simplification rather than a burden. Third, and this is the one that hurts, anything that depended on the step and its checkpoint sharing a transaction now needs an explicit idempotency key, because the activity and the orchestrator no longer share a database transaction.

That third point is the argument for writing idempotency keys from day one even if you never migrate. It costs one column and one unique index, and it is the difference between a two-week port and a two-month one.

  1. Inventory every workflow and flag the steps that touch systems outside Postgres. Those are the ones that lose transactional safety in a move, and they set the real difficulty of the project.
  2. Add an idempotency key plus a unique index to every side-effecting step and backfill it, before touching any orchestration code. This step is independently valuable even if the migration is cancelled.
  3. Port one low-risk workflow end to end and run it in parallel with the pg_durable version, comparing outputs rather than cutting over. Shadow running catches the semantic differences that code review misses.
  4. Move the remaining workflows in dependency order, and leave behind anything that turned out to be a single SQL statement. Those never needed an orchestrator in the first place.
  5. Keep the df.* tables readable for a full retention window so you can still answer questions about historical runs after the switch.

If your schema is managed in code, adding those idempotency columns is one more generated migration rather than a hand-written script, which is one of the quieter arguments for a typed migration tool. The workflow for that is covered in the guide to Drizzle ORM migrations with drizzle-kit.

Decision Table: Which Should You Pick?

Match the tool to the workload shape and the team you actually have, not to the architecture you aspire to.
Your situationWorkload shapeRecommendation
Solo developer or a team of two to five, one Postgres databaseA handful of multi-step SQL jobs, hourly or nightlypg_durable if your host allows extensions. Otherwise keep cron plus a jobs table and add idempotency keys.
Team of five to twenty, Postgres-centric monolithDozens of workflows, mostly SQL with the occasional HTTP callpg_durable for the SQL-native majority. Leave the HTTP-heavy ones in application code.
Team of five to twenty, several services in different languagesWorkflows fan out across service boundariesTemporal Cloud. Pay to skip operating the server while you are still small.
A platform team exists and already runs stateful infrastructureThousands of concurrent, long-running workflowsTemporal self-hosted. The operational cost is marginal for you and the ceiling is far away.
Any size, on managed Postgres without extension supportAnythingpg_durable is simply not available. Choose Temporal, a hosted workflow service, or a queue with idempotent handlers.
Any size, workflows that wait days for human approvalLong waits, external signals, timeouts measured in daysTemporal. Durable timers and signals are the core use case and there is no clean Postgres-native equivalent.
Any size, the job is one SQL statementA single statement on a scheduleNeither. Schedule it, add monitoring, and move on.

The pattern in that table is that team size decides how much infrastructure you can afford to operate, and workload shape decides which guarantees you actually need. Those two axes explain nearly every real decision here, and reasoning out loud along them is exactly what interviewers are listening for in a full-stack system design interview.

When pg_durable Is the Wrong Choice

Worth being direct about this, straight from the project's own scoping:

  • Sub-millisecond synchronous request handling: pg_durable is for durable background execution with checkpointing overhead, not your hot request path
  • Cannot install extensions: if your managed Postgres tier does not allow custom extensions or background workers, pg_durable cannot run
  • Multi-system workflow orchestration: if your workflow calls five microservices with complex conditional logic, branching, and loops in application code, that is Temporal/Step Functions/Argo territory
  • Single SQL statement: if the job is one query, you do not need a workflow orchestrator at all

Trying It Locally

Docker quick start using the official image:

bash
docker run -d --name pg-durable-demo \
  -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 \
  ghcr.io/microsoft/pg_durable:latest-pg18

Run the hello-world example:

sql
SELECT df.start(
  'SELECT ''Hello, durable world!'' AS message',
  'hello-world'
) as i;

Query the result with SELECT df.result('your-instance-id'); and inspect the checkpoint structure with df.instance_nodes().

If you use GitHub Actions for CI, you can add the pg_durable Docker image as a service container in your workflow to test durable workflows alongside your integration tests.

💡 Check extension compatibility first

Before planning any production use, verify that your PostgreSQL hosting environment supports custom extensions and background workers. Most managed tiers with "standard" Postgres do not. Azure HorizonDB (where Microsoft ships pg_durable natively) is the exception.

The Honest Verdict

This is genuinely new, open-sourced in early June, with Microsoft already running it inside Azure HorizonDB as a signal of internal confidence, but without years of broad production track record yet.

If you are a Postgres-heavy shop dealing with the specific pain of "background jobs that need to survive crashes reliably," it is worth experimenting with now. It is not yet an obvious default replacement for Temporal in complex, multi-system workflow scenarios, and teams with critical infrastructure should weigh the relative newness against the appeal of removing a layer of infrastructure.

For teams currently managing a cron+queue pattern that handles a handful of multi-step SQL workflows, pg_durable solves exactly the pain they are carrying. For teams running Temporal for complex cross-service orchestration, this is not a replacement. It is a complement for the subset of workflows that happen to be SQL-native. Like any caching strategy, the right answer depends on your specific workload shape.

Frequently Asked Questions

What is pg_durable?

pg_durable is a PostgreSQL extension open-sourced by Microsoft in June 2026 that brings durable, fault-tolerant workflow execution directly inside the database. Workflows are defined using a SQL-native DSL with three composable operators (~> for sequencing, |=> for naming, & for parallel branches). Each step checkpoints automatically, so a crash mid-workflow resumes from the last completed step.

Microsoft ships it inside Azure HorizonDB and open-sourced it on GitHub for self-hosted use.

Does pg_durable replace Temporal?

Not for most use cases. Temporal is built for arbitrary application logic across heterogeneous systems with complex branching, loops, and multi-language support. pg_durable is specifically for SQL-native workflows that run inside your PostgreSQL database.

If your workflow steps are mostly SQL statements touching your own database, pg_durable can replace the need for a separate orchestrator. If your workflow calls multiple external services with substantial conditional logic, Temporal is the better fit.

Can pg_durable survive a database restart?

Yes. Every step checkpoints to PostgreSQL tables. After a crash, restart, or OOM kill, the workflow resumes from the last completed step. This is the core value proposition: crash recovery without writing your own retry and checkpoint logic.

What database operations can pg_durable orchestrate?

Any SQL statement that PostgreSQL can execute. Each step is a SQL expression string. You can run SELECT, INSERT, UPDATE, DELETE, function calls, or any other valid SQL. Steps can reference the named results of previous steps using the |=> operator.

The limitation is that each step must be expressible as SQL. If a step requires arbitrary application logic (HTTP calls, file system operations, complex branching in Python/Go/Java), it does not fit pg_durable's model.

Is pg_durable production-ready?

It is very new. Microsoft open-sourced it in early June 2026 and ships it inside Azure HorizonDB, which signals internal confidence, but there is no broad multi-year production track record yet from the wider community.

For experimental branches and non-critical background workflows, it is worth trying now. For critical infrastructure, weigh the newness against the benefits and consider waiting for the ecosystem to mature.

Can I use pg_durable on managed PostgreSQL (RDS, Cloud SQL)?

Most managed PostgreSQL services (AWS RDS, Google Cloud SQL, Supabase) do not allow custom extensions or background workers, which pg_durable requires. Azure HorizonDB is the notable exception, as Microsoft ships pg_durable as a built-in feature.

For self-hosted PostgreSQL or managed services that support custom extensions, pg_durable can be installed normally.

Does pg_durable or Temporal give me exactly-once execution?

Neither one does in the general case. Both give you exactly-once state transition with at-least-once execution of side effects. A step can run more than once if a crash happens after the work but before the completion is recorded.

pg_durable narrows the gap for SQL-only steps, because the step and its checkpoint can commit in the same transaction, so a partial outcome is impossible. Temporal cannot do that, because activities run in separate worker processes talking to systems outside its control.

The safe assumption in both systems is that every side-effecting step will eventually run twice. Design each one to be idempotent and the distinction stops mattering.

How do I make a durable workflow step idempotent?

Derive a key from the workflow instance id plus the step name, store it with a unique constraint, and let the database reject the duplicate. The key is stable across retries of one run and distinct across different runs.

sql
ALTER TABLE payments
  ADD COLUMN idempotency_key text;

CREATE UNIQUE INDEX payments_idempotency_key_idx
  ON payments (idempotency_key);

Then insert with ON CONFLICT (idempotency_key) DO NOTHING. If the step also calls an external API, send the same key in that provider's idempotency header, because a local unique index cannot undo a duplicate charge that already happened remotely.

How do I debug a workflow that appears stuck?

For pg_durable, start with df.instance_nodes() to find the last completed step, then look at pg_stat_activity for the backend running the current one. A wait_event_type of Lock means the step is blocked rather than slow, and pg_blocking_pids() names the transaction holding things up.

For Temporal, open the run in the Web UI and read its event history. Every activity attempt, failure, retry, and timer is recorded, so you can see whether the workflow is waiting on a timer, retrying a failing activity, or waiting for a worker that is not polling the task queue.

The most common stuck-workflow cause in both systems is neither the engine nor the workflow definition. It is a downstream dependency timing out slowly enough that retries never exhaust.

Can I migrate from pg_durable to Temporal later?

Yes, and the structure translates well: each SQL step becomes an activity and the operator expression becomes ordinary control flow in workflow code. There is no automated converter, so it is a rewrite, but a mechanical one.

The genuinely hard part is that a step and its checkpoint no longer share a database transaction once the step lives in a separate worker. Anything that silently relied on that atomicity needs an explicit idempotency key before the move, which is the strongest reason to add those keys on day one even if you never expect to migrate.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

devops

GitHub Actions Tutorial: CI/CD from Push to Deploy (2026)

Learn GitHub Actions: write your first workflow, run tests automatically, use secrets safely, deploy via SSH, cache dependencies, and run matrix builds.

Jun 12, 2026·11 min read
databases

40 SQL Interview Questions and Answers (2026)

40 SQL and relational database interview questions covering joins, indexes, ACID, window functions, CTEs, MySQL vs PostgreSQL, and query challenges.

Jun 14, 2026·47 min read

On this page

  • What Problem pg_durable Solves
  • The Actual Syntax
  • What You Get for Free
  • Idempotency and Exactly-Once Semantics
  • Retries, Backoff, and Where Retry State Lives
  • pg_durable vs Temporal
  • pg_durable vs Cron + Jobs Table
  • Observability: Debugging a Stuck Run
  • Operational Burden: What You Run and Page On
  • Scaling Characteristics and Where Each Breaks
  • Cost at Small, Medium, and Large Scale
  • Testing Durable Workflows
  • Migration Path If You Outgrow pg_durable
  • Decision Table: Which Should You Pick?
  • When pg_durable Is the Wrong Choice
  • Trying It Locally
  • The Honest Verdict
  • Frequently Asked Questions