How PGSync ClickHouse works¶
From Postgres rows to a live analytics table, the whole pipeline one step at a time. No ETL jobs, no nightly batch.
The pipeline¶
-
Your Postgres tables
Your database stays the source of truth. Nothing changes about how your application writes to it: inserts, updates and deletes happen as normal.
-
PGSync captures every change
Committed changes are read straight from the PostgreSQL write-ahead log (logical decoding), in commit order, transactionally, with a checkpoint so a crash resumes exactly where it left off.
-
Versioned rows land in ClickHouse
Each change is an append to a
ReplacingMergeTree: an upsert carries a_version(the source WAL position); a delete writes a tombstone. The newest_versionalways wins, with no updates-in-place and no races. -
Query the clean, live state
A companion
<table>_liveview appliesFINAL+ the tombstone filter for you, so you read the current, deduplicated state with plain SQL, and the ReplacingMergeTree mechanics stay out of your way.
What lands in ClickHouse¶
A row committed in Postgres becomes a versioned append: the latest version
wins under FINAL, and deletes tombstone cleanly:
-- one book, then a price change, then a delete: all appended, newest wins
INSERT INTO book VALUES ('1984', 'Nineteen Eighty-Four', 14.99, /*_version*/ 100, /*_is_deleted*/ 0);
INSERT INTO book VALUES ('1984', 'Nineteen Eighty-Four', 12.99, /*_version*/ 140, 0); -- price update
INSERT INTO book VALUES ('1984', '', 0, /*_version*/ 180, 1); -- delete (tombstone)
-- you never write the above; PGSync does. You just read the live view:
SELECT title, price FROM book_live; -- clean, deduplicated, tombstone-free
_version is the source WAL LSN, so re-applying a change (a crash-replay)
re-inserts an identical row that ReplacingMergeTree collapses: exactly-once,
with no duplicates and no resurrected deletes.
Denormalized mode: one wide table¶
Add children to your schema.json and PGSync ClickHouse folds a whole relational
tree into one wide table: one-to-one children flattened into typed
columns, one-to-many children as native Array(Tuple(...)), with no JSON
wrangling and queryable with ARRAY JOIN.
{
"database": "book", "index": "book_wide",
"nodes": {
"table": "book",
"columns": ["isbn", "title", "price"],
"children": [
{ "table": "publisher", "relationship": { "variant": "object", "type": "one_to_one" } },
{ "table": "author", "label": "authors",
"relationship": { "variant": "object", "type": "one_to_many" } }
]
}
}
-- publisher flattened into typed columns; authors as a native nested array
SELECT title, publisher_name, a.name AS author
FROM book_wide FINAL ARRAY JOIN authors AS a
WHERE _is_deleted = 0;
A change to a child row (a new author, a renamed publisher) re-emits the parent, so the wide row stays in sync automatically.
Same schema.json, same CLI, a ClickHouse sink instead of Elasticsearch.