Replicate PostgreSQL tables into Apache Paimon using logical replication. A small SQL extension registers the tables you want replicated and carries their DDL; an external Java process reads a replication slot and writes to a Paimon warehouse on a local path, HDFS or S3.
Branches.
mainis the logical-replication implementation described here (v2.x). The earlier C++ table-access-method implementation (paimon_heap, v1.x) is kept on thepg-extensionbranch.
PostgreSQL 18 paimon-writer (JVM) Paimon warehouse
┌──────────────────────────────┐ pgoutput ┌───────────────────────────┐ ┌──────────────────┐
│ tables ──WAL──▶ slot "paimon"├──────────────▶│ decode ▶ resolve ▶ batch ├──────▶│ <schema>.db/ │
│ paimon_slot extension: DDL │ (proto v2) │ ▶ commit to Paimon │ │ <table>/ │
│ emitted as logical messages │ │ confirm LSN after commit│ │ bucket-N/ ... │
└──────────────────────────────┘ └───────────────────────────┘ └──────────────────┘
Nothing is loaded into the PostgreSQL server process: paimon_slot is pure SQL (no .so, no
shared_preload_libraries), and the writer is an ordinary client that can be stopped, upgraded and
restarted independently of the database.
- Effectively-once delivery. The slot's confirmed position advances only after a Paimon commit
has returned, and a replayed transaction is filtered by its commit identifier, so a crash neither
loses nor duplicates data. Verified with
kill -9mid-load, between a commit and its confirmation, and with a batch buffered. - A faithful changelog. Tracked tables use
REPLICA IDENTITY FULLand Paimon'schangelog-producer = input, so everyUPDATEproduces a real-U/+Upair, including for rows whose large (TOASTed) columns were not touched. - An operation history of every table. Beside each replicated table
Tthe writer keepsT_hist, an append-only Paimon table partitioned by day, with one row per row image --+I, an update's-Uand+U,-D-- carrying the table's columns plus operation, commit and change LSN, transaction id and commit time, in commit order and never collapsed, even inside one transaction or one commit batch. Every DDL archives it asT_hist_<lsn>and starts a new one with the new schema. It survivesDROP TABLE. - Tables without a primary key are replicated as append-only operation logs: one row per insert, update, delete or truncate, with the same metadata.
- DDL follows the data:
ADD/DROP/RENAME COLUMN,ALTER COLUMN TYPE,DROP TABLEandTRUNCATEare propagated. Changes the writer could not apply faithfully are refused up front by guardrails instead of failing later. - One Paimon database per PostgreSQL schema, so
public.ordersandarchive.ordersare separate Paimon tables at<warehouse>/public.db/ordersand<warehouse>/archive.db/orders. - Fixed bucketing with a size-based default that you can override per table, and mini-batch commits (several transactions per Paimon commit, bounded by rows, bytes and time) so a busy table does not produce a snapshot per transaction.
- Verified against TPC-C. A GitHub Actions workflow (
tpcc.yml, manual dispatch) runs BenchBase through the pipeline and compares every replicated row of PostgreSQL against Paimon column by column, and checks that replaying each history table rebuilds PostgreSQL's table exactly.
Requirements: PostgreSQL 18 with wal_level = logical, a JDK 17 and Maven.
# 1. Build the writer (a shaded jar; runs its unit tests).
cd java/writer && mvn -q package # -> java/writer/target/paimon-writer.jar
# 2. Install the extension (standard PGXS) and enable it in your database.
make -C sql/paimon_slot install # or install-local, see the guide
psql mydb -c "CREATE EXTENSION paimon_slot"
# 3. Register a table (with a primary key it is merged; without one it is an operation log).
# Do this BEFORE starting the writer, or while it is still empty.
psql mydb -c "CREATE TABLE orders (id int PRIMARY KEY, customer text, amount numeric(12,2))"
psql mydb -c "SELECT paimon.add_table('orders'::regclass)"
# 4. Start the writer. It is configured entirely from the environment.
PGPAIMON_JDBC_URL=jdbc:postgresql://127.0.0.1:5432/mydb \
PGPAIMON_USER=postgres PGPAIMON_PASSWORD=... \
PGPAIMON_WAREHOUSE=/var/lib/paimon/warehouse \
java -jar java/writer/target/paimon-writer.jarThe full setup, every setting, the operational hazards (an abandoned slot retains WAL and fills the disk), the limitations and why each exists, troubleshooting and the test procedure are in docs/logical-slot-architecture.md. Read its section 3 before running this anywhere that matters, and give its section 8, "Rules for developers and administrators", to everyone who changes the database: one table of everything not to do, each marked REJECTED, STOPS WRITER, REINIT / HISTORY LOSS or SILENT, with the recovery and the test that proves it.
Each is deliberate and explained, with its reason, in section 5 of the guide:
- a table without a primary key is replicated as an operation log, not as a merged table, and a
key cannot be added to a replicated table later; a primary key must not be
DEFERRABLE; - the history tables (
<table>_hist) roughly double write volume and grow without bound, in rows and in archived tables (one per DDL): retention is yours to run (drop old_dtpartitions, and drop old archives by hand: nothing written to an archive again, so Paimon never expires it); a dropped table's history and its archives are kept and their names stay taken until you move them aside and callpaimon.forget_history(); a table re-added afterpaimon.remove_table()must keep its key (or you drop the kept Paimon table and callpaimon.forget_kept_table()); the seven history field names (_op,_commit_lsn,_lsn,_op_seq,_xid,_ts,_dt) are reserved column names; numericcolumns need an explicit precision of at most 38;- any role that can read
paimon.secret, including members ofpg_read_all_data, can forge a DDL message the writer applies (paimon.secret_readers()lists them), and a DDL message without the credential stops the writer rather than being ignored; do not change the secret while the slot holds unconsumed DDL; ADD COLUMN ... DEFAULTandALTER COLUMN ... SET NOT NULLare refused;- a non-empty table cannot be added once the writer's slot exists;
- PostgreSQL
infinity/-infinityare stored as the finite extremes of the Paimon type, which is order-preserving but lossy at the very end of the range; - a few things are not detected at all (guide, section 8): consuming or advancing the writer's
slot with another client after a crash of the writer (after a clean stop it is refused), a
changed
PGPAIMON_COMMIT_USER, a new partition nobody registered, a file-level snapshot restore started once the WAL has passed the warehouse; sequences and large objects are not replicated; - one transaction is bounded by
PGPAIMON_TXN_MAX_BYTES(a quarter of the heap by default): a larger one stops the writer, losing nothing, until it is given more memory; - S3 support is wired in but has not been exercised in this repository's own environment: the
test is skipped unless
E2E_MINIO_BINpoints at a MinIO binary.
sql/paimon_slot/ the SQL extension: registry, type mapping, add_table, event-trigger
guardrails and DDL messages, with a pg_regress suite
java/writer/ paimon-writer: decoder, TOAST resolver, schema registry, batcher, sink,
slot consumer, backfill, with unit tests
java/ acceptance suites (JDBC) and the TPC-C comparison tool
test/slot-acceptance/ one command that builds a throwaway PostgreSQL cluster, installs the
extension, runs the writer and executes every acceptance suite
test/tpcc/ BenchBase TPC-C driven through the same pipeline
.github/workflows/ java.yml (build the Java project), codeql.yml, tpcc.yml
docs/ the architecture and operations guide; design spec and plan under
docs/superpowers/
cd java/writer && mvn test # writer unit tests
make -C sql/paimon_slot install-local && make -C sql/paimon_slot installcheck # pg_regress
./test/slot-acceptance/run_acceptance.sh # all acceptance suites
./test/tpcc/run_tpcc.sh # TPC-C, then comparerun_acceptance.sh needs no Docker: it starts its own PostgreSQL 18 cluster on port 5442. See
test/slot-acceptance/README.md.
Apache License 2.0. See LICENSE. Every source file carries an
SPDX-License-Identifier: Apache-2.0 header.