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.
On this page
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:
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:
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:
SELECT * FROM df.instance_nodes('your-instance-id');
SELECT * FROM df.instance_executions('your-instance-id');| Operator | Purpose | Example |
|---|---|---|
| <code>~></code> | Sequence: run this step after the previous one completes | 'SELECT step1' ~> 'SELECT step2' |
| <code>|=></code> | Name: label this step's result for later reference | 'SELECT now()' |=> 'timestamp' |
| <code>&</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.
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.
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:
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.
| Concern | cron + jobs table | pg_durable | Temporal |
|---|---|---|---|
| Where attempt state lives | A column you added and maintain | <code>df.*</code> tables in your database | Workflow event history on the server |
| Backoff | A timestamp column plus your own scheduling query | Managed by the extension | Declarative retry policy per activity |
| Crash mid-attempt | Row stays locked until a reaper resets it | Resumes from the last committed checkpoint | Server reschedules the activity on any healthy worker |
| Non-retryable failures | An error-type check you write by hand | Inspect <code>df.instance_executions</code> and decide in SQL | Declared as non-retryable error types |
| Inspecting attempt history | Whatever you thought to log | Ordinary SQL over the execution tables | Full 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.
| pg_durable | Temporal | |
|---|---|---|
| Workflow language | SQL with custom operators | Any language (Go, Java, Python, TS, etc.) |
| Infrastructure | PostgreSQL extension (no extra services) | Separate Temporal server + workers |
| Best for | SQL-native workflows within one database | Multi-system orchestration with complex logic |
| Checkpointing | Per-step, in Postgres tables | Per-activity, in Temporal's own storage |
| Monitoring | SQL queries against df.* tables | Temporal Web UI + SDK metrics |
| Maturity | New (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.
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:
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.
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.
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.
| Scale | cron + jobs table | pg_durable | Temporal |
|---|---|---|---|
| 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:
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.
- 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.
- 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.
- 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.
- 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.
- 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?
| Your situation | Workload shape | Recommendation |
|---|---|---|
| Solo developer or a team of two to five, one Postgres database | A handful of multi-step SQL jobs, hourly or nightly | pg_durable if your host allows extensions. Otherwise keep cron plus a jobs table and add idempotency keys. |
| Team of five to twenty, Postgres-centric monolith | Dozens of workflows, mostly SQL with the occasional HTTP call | pg_durable for the SQL-native majority. Leave the HTTP-heavy ones in application code. |
| Team of five to twenty, several services in different languages | Workflows fan out across service boundaries | Temporal Cloud. Pay to skip operating the server while you are still small. |
| A platform team exists and already runs stateful infrastructure | Thousands of concurrent, long-running workflows | Temporal self-hosted. The operational cost is marginal for you and the ceiling is far away. |
| Any size, on managed Postgres without extension support | Anything | pg_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 approval | Long waits, external signals, timeouts measured in days | Temporal. Durable timers and signals are the core use case and there is no clean Postgres-native equivalent. |
| Any size, the job is one SQL statement | A single statement on a schedule | Neither. 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:
docker run -d --name pg-durable-demo \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
ghcr.io/microsoft/pg_durable:latest-pg18Run the hello-world example:
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.
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.
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.
Related Articles
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.
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.