WAL
The write-ahead log is Postgres’s own internal durability mechanism — it exists for crash recovery, with zero awareness of ELT process (Fivetran, Airbyte, CDC, etc.) or ClickHouse. Every transaction is written to the WAL before the actual table/heap pages are updated (hence “write-ahead”) — that’s what lets Postgres recover to a consistent state if it crashes mid-write. This mechanism predates CDC entirely and would exist even if no downstream replication ever happened.
CDC (Change Data Capture) streaming is a technique that tracks row-level inserts, updates, and deletes in a source database and streams those changes to downstream systems in real time.Instead of querying a database repeatedly or running heavy batch jobs, a CDC stream reads the database’s internal transaction logs (such as PostgreSQL WAL or MySQL binlogs) to capture changes the moment they happen. Tools like Debezium and PeerDB automate this extraction and turn database modifications into live event streams
But new version of Postgres can do the same thing directly using columnar tables.
Building an Append-Only OLTP/OLAP System with Postgres Logical Replication
This design uses two Postgres servers:
a row-based (heap) OLTP server that handles all application writes, and
a columnar OLAP server that receives data automatically and asynchronously via native logical replication.
Nothing is ever updated or deleted — corrections are new rows.
Design principles
- OLTP side: row storage (heap), receives all writes. Never updated, never deleted from — a correction is a new row.
- OLAP side: columnar storage, on a separate server, populated automatically and asynchronously from the OLTP side’s WAL. Never written to directly.
- Surrogate key, not business key:
event_iduniquely identifies a row;order_ididentifies an order and can legitimately appear more than once.
Step 1 — OLTP server: enable logical replication
# postgresql.conf on the OLTP server — requires a restart
wal_level = logical
Step 2 — OLTP server: create the table
CREATE TABLE orders (
event_id bigserial PRIMARY KEY, -- unique per row, not per order
order_id int NOT NULL, -- business key; NOT unique — repeats on correction
customer_id int NOT NULL,
order_date date NOT NULL,
revenue numeric NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT now(),
event_type text NOT NULL DEFAULT 'insert' -- e.g. 'insert', 'correction', 'cancellation'
);
Step 3 — OLTP server: create a replication role and grant access
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '<choose_a_strong_password>';
GRANT SELECT ON orders TO replicator;
# pg_hba.conf on the OLTP server — allow the OLAP server to connect for replication
host replication replicator <olap_server_ip>/32 scram-sha-256
host orders_db replicator <olap_server_ip>/32 scram-sha-256
(Reload or restart Postgres after editing pg_hba.conf.)
Step 4 — OLTP server: publish the table
CREATE PUBLICATION orders_pub FOR TABLE orders;
Step 5 — OLAP server: enable columnar storage using EXTENSION citus_columnar
CREATE EXTENSION citus_columnar;
Step 6 — OLAP server: create the matching table
Same name (orders) is required for native logical replication to match it up. No PRIMARY KEY here — Citus columnar doesn’t support indexes, so the constraint that made sense on the OLTP side would just error out here.
CREATE TABLE orders (
event_id bigint,
order_id int,
customer_id int,
order_date date,
revenue numeric,
recorded_at timestamptz,
event_type text
) USING columnar;
Step 7 — OLAP server: subscribe to the OLTP server’s publication
CREATE SUBSCRIPTION orders_sub
CONNECTION 'host=<oltp_server_ip> port=5432 dbname=orders_db user=replicator password=<the_password>'
PUBLICATION orders_pub;
Step 8 — verify it’s actually working
-- On the OLTP server: confirm the subscriber has connected
SELECT * FROM pg_stat_replication;
-- On the OLAP server: confirm subscription status
SELECT subname, pid, received_lsn, latest_end_lsn FROM pg_stat_subscription;
How you actually use it
Original write (application only ever does this):
INSERT INTO orders (order_id, customer_id, order_date, revenue)
VALUES (1, 101, '2026-08-01', 50.00);
Correction (never an UPDATE — a new row, same order_id):
INSERT INTO orders (order_id, customer_id, order_date, revenue, event_type)
VALUES (1, 101, '2026-08-01', 45.00, 'correction');
Reading current state (on either side — needed because history now has multiple rows per order):
SELECT DISTINCT ON (order_id) *
FROM orders
ORDER BY order_id, recorded_at DESC;
That’s the complete system: OLTP writes land in orders (heap) → the WAL records them → logical replication streams them asynchronously, with zero coupling to the OLTP transaction → they land in the OLAP server’s orders (columnar) → analytical queries run there without ever touching the transactional server.
![]()
