PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 41: Sharding and distributed PostgreSQL

Transactions across databases

Level 8: Scale and Architecture30 min

Once data lives in more than one database, a single ACID transaction no longer covers a business operation. Two-phase commit, the transactional outbox, idempotency keys and sagas are the patterns that keep the system correct anyway.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Explain two-phase commit and its operational risk
  • Implement a transactional outbox and an idempotent consumer
  • Choose between 2PC and sagas

conceptThe concept

Two-phase commit: a coordinator runs PREPARE TRANSACTION 'id' on every participant, which durably saves the transaction without committing it, then COMMIT PREPARED 'id' on all of them. If the coordinator dies between the phases, prepared transactions stay open, holding locks and blocking VACUUM until someone resolves them. PostgreSQL disables the feature by default (max_prepared_transactions = 0).

Transactional outbox: write the business change and an event row into an outbox table in the same local transaction. A relay process reads unpublished events and publishes them to a queue or another service. The event is published if and only if the change committed.

Idempotency: every message may be delivered more than once, so consumers record processed message ids (an inbox) or use idempotency keys with a unique constraint, making a repeat a no-op.

Sagas: a sequence of local transactions, each with a compensating action (refund the payment, release the stock) that runs if a later step fails.

whyWhy it matters

Dual writes ("commit to the database, then publish to Kafka") lose events whenever the process crashes in between. The outbox and idempotency patterns make each step locally atomic and safely repeatable, which is the practical foundation of consistent microservices.

Use caseReal-world scenario

Checkout writes the order and an OrderPaid outbox event in one transaction. The relay publishes it; the loyalty service consumes it, records the event id in its inbox and adds points in the same transaction. A redelivered event finds its id already in the inbox and does nothing.

Try itExamples

Run and edit these in the interactive lesson.

Two-phase commit is off by default
SHOW max_prepared_transactions;
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 7;
PREPARE TRANSACTION 'order-981-inventory';
A failed PREPARE TRANSACTION rolls the transaction back.
The full 2PC protocol
-- postgresql.conf on every participant
max_prepared_transactions = 100

-- phase 1, on each participant
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 7;
PREPARE TRANSACTION 'order-981-inventory';

-- phase 2, once every participant prepared successfully
COMMIT PREPARED 'order-981-inventory';
-- or, if any participant failed
ROLLBACK PREPARED 'order-981-inventory';

-- the check you must monitor
SELECT gid, prepared, owner FROM pg_prepared_xacts WHERE prepared < now() - interval '5 minutes';
Transactional outbox and relay
BEGIN;
CREATE TABLE outbox (
  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  aggregate     text NOT NULL,
  aggregate_id  bigint NOT NULL,
  event_type    text NOT NULL,
  payload       jsonb NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  published_at  timestamptz
);

WITH o AS (
  INSERT INTO orders (customer_id, status, created_at, total)
  VALUES (42, 'paid', now(), 59.90)
  RETURNING id, customer_id, total
)
INSERT INTO outbox (aggregate, aggregate_id, event_type, payload)
SELECT 'order', id, 'OrderPaid', jsonb_build_object('order_id', id, 'customer_id', customer_id, 'total', total)
FROM o;

-- relay: claim a batch without blocking other relay workers
WITH batch AS (
  SELECT id FROM outbox
  WHERE published_at IS NULL
  ORDER BY id
  LIMIT 100
  FOR UPDATE SKIP LOCKED
)
UPDATE outbox SET published_at = now()
FROM batch WHERE outbox.id = batch.id
RETURNING outbox.event_type, outbox.payload ->> 'customer_id' AS customer;
ROLLBACK;
Idempotency keys
BEGIN;
CREATE TABLE payments (
  idempotency_key uuid PRIMARY KEY,
  order_id        integer NOT NULL REFERENCES orders (id),
  amount          numeric(12,2) NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now()
);
INSERT INTO payments (idempotency_key, order_id, amount)
VALUES ('6f1c2a7e-6c1b-4d47-9a43-1d3c4e5f6a7b', 1, 25.00)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING 'charged' AS result;
INSERT INTO payments (idempotency_key, order_id, amount)
VALUES ('6f1c2a7e-6c1b-4d47-9a43-1d3c4e5f6a7b', 1, 25.00)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING 'charged' AS result;
SELECT count(*) AS payments_recorded FROM payments;
ROLLBACK;
The client retried with the same key; the second insert returned no row, so the caller knows it was a repeat.

internalsUnder the hood

A prepared transaction keeps its XID, locks and snapshot horizon until resolved, survives restarts and appears in pg_prepared_xacts. An orphaned one silently holds back the xmin horizon and can lead to bloat and, in the extreme, wraparound protection. Monitoring pg_prepared_xacts is mandatory wherever 2PC is enabled.

Avoid / preferDo this, not that

Avoid
-- application code
-- db.commit(order)
-- kafka.publish(OrderPaid)   -- crash here and the event is lost
Prefer
-- one local transaction
-- INSERT INTO orders ...;
-- INSERT INTO outbox ...;
-- COMMIT;  -- a relay publishes from outbox

Two separate writes cannot be atomic. The outbox makes the event part of the same commit as the change.

Avoid
-- enable 2PC everywhere and never look at it again
max_prepared_transactions = 1000
Prefer
-- enable only where needed, with an external transaction manager,
-- and alert on pg_prepared_xacts older than a few minutes

Orphaned prepared transactions hold locks and the xmin horizon indefinitely, causing blocking and bloat.

PitfallsCommon mistakes

  • Assuming exactly-once delivery from a message broker; consumers must be idempotent.
  • Relaying outbox events without SKIP LOCKED, so parallel relay workers block each other.
  • Writing sagas without compensating actions for every step that can be followed by a failure.

LimitsWhen not to use it

Avoid 2PC across services owned by different teams or across the internet: availability drops to that of the weakest participant and failures need manual cleanup. Avoid sagas when all the data can live in one database; a local transaction is simpler and stronger.

SecuritySecurity notes

Outbox payloads often end up in queues and logs with weaker access control than the database. Put identifiers in events rather than personal data, and let consumers fetch details they are allowed to see.

ChallengeTry it yourself

Build an idempotent consumer: an inbox table keyed by event id and a loyalty_points table. Process event 1001 (60 points for customer 42) twice and show that the customer ends up with 60 points, not 120.

Show one solution
One solution
BEGIN;
CREATE TABLE inbox (event_id bigint PRIMARY KEY, received_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE loyalty_points (customer_id integer PRIMARY KEY, points integer NOT NULL);

WITH claim AS (
  INSERT INTO inbox (event_id) VALUES (1001) ON CONFLICT DO NOTHING RETURNING event_id
)
INSERT INTO loyalty_points SELECT 42, 60 FROM claim
ON CONFLICT (customer_id) DO UPDATE SET points = loyalty_points.points + EXCLUDED.points;

WITH claim AS (
  INSERT INTO inbox (event_id) VALUES (1001) ON CONFLICT DO NOTHING RETURNING event_id
)
INSERT INTO loyalty_points SELECT 42, 60 FROM claim
ON CONFLICT (customer_id) DO UPDATE SET points = loyalty_points.points + EXCLUDED.points;

SELECT points FROM loyalty_points WHERE customer_id = 42;
ROLLBACK;

InterviewQuestions you should be able to answer

What is the transactional outbox pattern and why use it?
Instead of writing to the database and then publishing to a broker (a dual write that can half-fail), the service inserts the event into an outbox table in the same transaction as the business change. A separate relay, polling with FOR UPDATE SKIP LOCKED or reading logical decoding (for example with Debezium), publishes outbox rows and marks or deletes them. Events are published if and only if the change committed; delivery is at least once, so consumers must be idempotent.
What are the risks of two-phase commit in PostgreSQL?
Prepared transactions survive crashes and restarts and keep holding locks and the xmin horizon until COMMIT PREPARED or ROLLBACK PREPARED. If the coordinator fails between phases, they become orphans that block DDL, cause bloat and can push toward wraparound. Availability also drops because every participant must be up. It needs max_prepared_transactions > 0, a reliable transaction manager and monitoring of pg_prepared_xacts.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation