Postgres Transactions Are a Distributed Systems Superpower
Co-locating workflow state with application data collapses distributed systems complexity into standard ACID transactions.
Distributed systems developers have a habit of overcomplicating their architectures. When faced with the classic challenges of state management, we routinely reach for external workflow engines, heavy message brokers, and complex consensus protocols. We treat our database as a passive data store and build layers of fragile coordination logic on top of it.
But the database we already run in production, PostgreSQL, has a transactional engine capable of solving these hard distributed systems problems natively. By co-locating workflow orchestration state with application data, or by leveraging Postgres's built-in distributed transaction capabilities, we can collapse complex distributed state machines into standard ACID guarantees.
The High Cost of Separating State and Logic
In a typical microservices architecture, we separate concerns religiously. Application data lives in one database, while workflow state (the metadata tracking the progress of multi-step business processes) lives in another, often managed by an external orchestrator.
This separation introduces a fundamental distributed systems problem: partial failures. If a workflow step updates the application database but the orchestrator crashes before recording that the step completed, the system becomes inconsistent.
To survive this, developers must make every single workflow step strictly idempotent. If a step adds $100 to a bank account, we cannot simply run an UPDATE statement. If we do, and the workflow engine crashes and retries, we will credit the account twice. Instead, we are forced to build application-level bookkeeping, such as checking against an applied_payments table inside a manual transaction, to guard against duplicate execution. This is a massive tax on developer velocity and system simplicity.
Collapsing Idempotency into a Single Commit
Co-location changes the game. If your workflow engine's state tables live in the exact same Postgres database as your application data, you can execute the application update and write the workflow checkpoint inside the same database transaction.
Consider how this simplifies a non-idempotent operation like crediting an account. Instead of managing external state, the workflow engine starts a local Postgres transaction, executes the business logic, writes the step's completion checkpoint to a metadata table, and commits:
BEGIN;
-- 1. Perform the application update
UPDATE accounts
SET balance = balance + 100
WHERE account_id = 'alice_123';
-- 2. Record the workflow checkpoint in the same DB
INSERT INTO workflow_checkpoints (workflow_id, step_id, status)
VALUES ('wf_9988', 'step_credit_alice', 'completed');
COMMIT;
Because Postgres guarantees atomicity, this transaction either fully commits or fully rolls back. There is no middle ground. If the database or the application process crashes mid-execution, the entire block rolls back. When the workflow engine recovers, it sees no checkpoint, safely re-runs the step, and no double-crediting can ever occur.
By wrapping the state change and the checkpoint in a single transaction, you achieve exactly-once execution semantics for your database steps. The need for custom, application-level idempotency checks disappears.
Eliminating the Outbox Infrastructure Tax
Another classic distributed systems headache is the atomic commit-and-publish problem. When a user places an order, you need to write the order to your database and trigger a downstream action, such as notifying a warehouse fulfillment service.
To prevent sending notifications for orders that failed to commit, developers use the transactional outbox pattern. You write the order and a pending message to an outbox table in a single transaction. Then, you run a separate background service to poll the outbox, publish the messages to a broker, and mark them as sent. This works, but it requires you to build, monitor, and scale a polling infrastructure.
When your workflow engine is co-located in Postgres, the outbox pattern is built in. Initiating a downstream task is as simple as inserting a row into the workflow's task queue table within your primary transaction. The workflow engine, running alongside your database, picks up the task asynchronously. If the primary transaction rolls back, the task is never queued. You get reliable, atomic message dispatch without provisioning a single queue or polling daemon.
Scaling Out: When You Need Distributed 2PC
Co-location is highly effective when your data fits on a single Postgres instance. But what happens when your application scales out and your data is partitioned across multiple physical database nodes?
This is where PostgreSQL's native support for the Two-Phase Commit (2PC) protocol becomes highly relevant. Designed to conform to the X/Open XA standard, Postgres's 2PC allows multiple independent database nodes to coordinate commits atomically.
The protocol split commits into two distinct phases:
- The Prepare Phase: The coordinator node tells all participating nodes to prepare the transaction. The workers write the transaction to their Write-Ahead Log (WAL) and shared memory, ensuring it can survive a crash, but they do not commit it yet.
- The Decision Phase: If all nodes successfully prepare, the coordinator issues a commit command. If any node fails, the coordinator aborts the entire transaction.
In Postgres, this is exposed via explicit SQL commands. You can test this directly in any standard Postgres client:
-- On Worker Node A
BEGIN;
INSERT INTO students (name, cgpa) VALUES ('Khan', 3.80);
PREPARE TRANSACTION 'txn_001';
-- On Worker Node B
BEGIN;
UPDATE classes SET enrolled = enrolled + 1 WHERE class_id = 'cs_101';
PREPARE TRANSACTION 'txn_001';
Once prepared, the transaction enters a staging state. It is recorded in the pg_twophase directory if it spans checkpoints, and you can inspect it using the pg_prepared_xacts system view. To finalize the transaction across both nodes, the coordinator issues:
COMMIT PREPARED 'txn_001';
If something goes wrong during the prepare phase, the coordinator can safely roll back the prepared state on all nodes:
ROLLBACK PREPARED 'txn_001';
This native capability is exactly how distributed Postgres extensions like Citus execute distributed transactions across sharded tables. Citus uses Postgres's 2PC under the hood, writing transaction metadata to distributed tables so that if a coordinator node fails mid-commit, a promoted secondary can recover the incomplete transactions and maintain consistency.
The Developer's Trade-off
Before you migrate your entire architecture to a co-located Postgres model, you must weigh the architectural trade-offs.
To use 2PC, you must configure your database appropriately. The max_prepared_transactions parameter in postgresql.conf defaults to zero in standard installations. You must set this to a value at least equal to max_connections so that your database has enough shared memory allocated to track prepared transactions.
Furthermore, prepared transactions hold active locks on rows. If a coordinator node prepares a transaction and then crashes permanently, those locks will remain held indefinitely, blocking other transactions until an operator manually runs ROLLBACK PREPARED. It requires robust high-availability replication and automated recovery mechanisms to prevent database-wide lockups.
For co-located workflows, the trade-off is resource contention. Running your workflow engine's state machine inside your primary application database means your database CPU, memory, and disk I/O are shared between business transactions and orchestration overhead. If your application requires millions of short-lived, high-throughput workflows, the database can easily become a bottleneck.
The Verdict
For high-integrity, transactional workflows, such as financial ledger updates, order processing, or inventory management, co-locating workflow state in Postgres is an incredibly elegant design pattern. It replaces complex, error-prone application-level idempotency logic with simple, bulletproof database transactions.
If you are building a system where data integrity is paramount, do not immediately reach for external orchestrators and message queues. Look at your Postgres instance first. The distributed systems superpower you need might already be running on port 5432.
Sources & further reading
- Postgres transactions are a distributed systems superpower — dbos.dev
- Demystifying Two Phase Commit (2PC) for Distributed Transaction in Microservices - DEV Community — dev.to
- How Citus Executes Distributed Transactions on Postgres - Citus Data — citusdata.com
- PostgreSQL: Documentation: 18: 67.4. Two-Phase Transactions — postgresql.org
- Mastering Transactions in PostgreSQL- Scaler Topics — scaler.com
Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.
Discussion 6
i love how this article highlights the power of postgres transactions - using them to manage workflow state can really simplify distributed systems, and it's amazing that this capability is already built into postgres
i don't see why people overcomplicate things, just use the standard library and existing db transactions, it's all there already, no need for extra workflow engines or message brokers
totally agree with you @golang_greg, i've seen so many serverless projects over-engineer their workflows when postgres transactions could've handled it all, it's like we forget that our databases are already super powerful 🚀
i love how this article highlights the power of postgres transactions - we've been using them to simplify our k8s deployments and it's been a game changer, no more yaml pain from orchestrating external workflow engines
okay but does it actually hold up in production? i'd love to see some real-world benchmarks on postgres handling distributed transactions at scale, not just theoretical benefits
i'm curious how this approach handles backfills or historical data corrections, since those often require re-running workflows or transactions on already-committed data - does postgres's transactional engine provide any built-in support for that?