apitap/apitap-lib
apitap/apitap-lib has 53 GitHub stars and 6 forks. Star changes are not measured yet. Last checked 2026-11-12 08:07 UTC. Last push 2026-11-11.
Move whole tables between databases fast — Postgres, MySQL, ClickHouse, BigQuery. Rust engine, one-line Python API, bounded memory.
Measured star growth
- in 24h
- Collecting history
- in 7 days
- Collecting history
- in 15 days
- Collecting history
- in 1 month
- Collecting history
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
README.md
Originalapitap
Move whole tables between databases at wire speed, in bounded memory.
apitap is a transfer engine, not an API client. It speaks the databases' own
wire formats — binary COPY, RowBinary, LOAD DATA, logical replication, the
MySQL binlog — through its own Rust wire clients, moves tables over parallel
range pipes, publishes them with atomic swaps, and keeps memory flat no matter
how big the table is. The Python package is a thin binding in the spirit of
Polars: one call, no daemon, no local state files. Docs and a public sandbox
are at apitap.dev.
pip install apitap
import apitap
report = apitap.transfer(
"postgres://user:pass@src-host/db",
"clickhouse://user:pass@warehouse:8123/db",
table="public.events",
)
print(f"{report.rows:,} rows in {report.elapsed_ms} ms over {report.parallel} pipes")
The same call does incremental loads and batch CDC, and mixes modes per table:
apitap.transfer(src, dst, table="public.orders", mode="append", cursor="id")
apitap.transfer(src, dst, table="public.orders", mode="log_based") # full WAL capture
apitap.transfer(src, dst, tables={ # one call, one replication slot
"orders": "log_based",
"customers": "log_based",
"dim_date": "replace",
})
df = apitap.read(src, table="public.orders").to_polars() # or .lazy() for tables bigger than RAM
And it joins tables of different databases itself, in Rust, exactly:
apitap.sql("""SELECT o_orderpriority, SUM(l_extendedprice * (1 - l_discount)) AS revenue
FROM pg.lineitem l JOIN my.orders o ON l.l_orderkey = o.o_orderkey
WHERE l.l_shipdate > DATE '1995-03-15' GROUP BY 1 ORDER BY 1""",
sources={"pg": PG_URL, "my": MYSQL_URL}).to_polars()
Contents — At a glance · Why apitap · Quick start · Connection URLs · The API · How a transfer runs · Incremental sync · Batch CDC · Cross-source SQL · Guarantees · Operating it · Performance, measured · Type mapping · Roadmap · Development
At a glance
| Sources | postgres:// · mysql:// (MySQL 8.x, MariaDB) · clickhouse:// · gsheets:// · github:// (repo CSVs as tables) · github+api:// (issues, PRs, commits… as typed tables) |
| Destinations | postgres:// · mysql:// · clickhouse:// · bigquery:// · gcs:// · s3:// (AWS, MinIO, R2, any S3-compatible store) · iceberg:// (REST catalogs with bearer auth) |
| Modes | replace (default, atomic swap) · append · merge (Postgres, Iceberg) · log_based batch CDC from Postgres and MySQL/MariaDB into Postgres, ClickHouse, MySQL, BigQuery, Iceberg |
| SQL | apitap.sql() joins and aggregates tables of different databases (all 22 TPC-H queries across Postgres × MySQL: CTEs, correlated subqueries, HAVING, LEFT JOIN) in apitap's own Rust engine, exact, in a 256 MB container; into a DataFrame (streamed), a destination, or an incremental view |
| DataFrames | apitap.read() → polars, pyarrow, duckdb, pandas through the Arrow C stream protocol, zero-copy, no pyarrow dependency |
| Runtime | CPython ≥ 3.9, abi3 wheels: manylinux_x86_64 (the PGO build every gate leg and number here ran on), manylinux_aarch64 and macOS arm64 (built and unit-tested in CI, not PGO, not gated), and an sdist; the engine is Rust, nothing in the data path is Python |
| Memory | bounded by pipes × buffers, planned from the container's cgroup limits — never by table size; a 256 MB container moves 100 GB |
| Version | 0.67.2 — pin exactly while pre-1.0 (apitap==0.67.2). Upgrade from 0.66.0–0.67.1 if you use apitap.sql(): a join on a database's text column (ON a.code = b.code with both varchar, or an integer against text) answered 0 rows, or through a CTE matched '099' with '99', without an error; 0.67.0 also mis-compared a WHERE across tables of text or unlike kinds. 0.67.2 refuses all of these by name |
| License | MIT |
Why apitap
Moving a lot of data should not require a lot of machine. Most ingestion pipelines pay twice — once in wall-clock hours, once in the oversized workers those hours run on. An engine that is careful about memory and wire formats finishes the same job faster on the smallest container you can rent, and that difference is real money every hour, on every pipeline.
Where that stands today, every number measured on the published wheel and checksum-validated against the source (environments are spelled out under Performance, measured):
- 100 GB (232M rows, 15 columns) through a 0.5 vCPU / 256 MB container in 8m57s, peak RSS 170.8 MB; the same table also completes inside a 44 MB cap. On three dedicated machines the same zero-config call takes 30.3 s (~3.3 GB/s). The tools we compared against were OOM-killed in ~21 s on the small box and had landed zero rows when cut on the big one.
- Ten 1M-row tables in one call, the tool alone capped at 0.5 vCPU / 256 MB: Postgres → ClickHouse in 18.4 s at a 119 MB peak; MySQL → ClickHouse in 36.1 s at 99 MB. walshadow and ingestr were OOM-killed in every leg.
- CDC: one 650K-event replication window, every tool at 0.5 vCPU / 256 MB — apitap 12 s, ape-dts 22 s, ingestr 78 s, pipelinewise 227 s, all row-matched. A 30-table group in one slot sustains 1.90M changes/min pure catch-up on half a core, checksum-exact, under 200 MB. At fleet scale (180M changes from four sharded Postgres into one ClickHouse, both movers capped at 6 CPU / 2 GB) apitap verifies 400/400 tables in 125 s of apply work at ~3 µs of CPU per change; PeerDB had not finished when the 30-minute cap fell.
- DataFrames: filter + group_by over 50M rows in 9.9 s on 0.5 vCPU / 256 MB, tying raw SQL run inside Postgres itself; polars/connectorx is OOM-killed on that box and ADBC takes 45 s.
The next tiny-box goal is stated as plainly as the first one was: 3 million changes per minute of CDC on 0.5 vCPU / 256 MB, with throughput that rises by itself when the box grows. Today's honest number is 1.9M/min catch-up and ~1.5M/min keep-up; the design is written and is being executed one measured lever at a time. If a number here looks wrong or a workload goes badly, open an issue — this project has been corrected by its own failed runs more than once.
Try it in a browser: apitap.dev/lab runs the real PyPI wheel next to ingestr and dlt against a seeded Postgres and ClickHouse; pick the box (1 GB / 2 vCPU or 256 MB / 0.5 vCPU), press run, and every result is row-count-verified before a number appears.
Quick start
import apitap
PG = "postgres://user:pass@pg-host:5432/shop"
CH = "clickhouse://default:pass@ch-host:8123/analytics" # clickhouse+https:// for TLS
MY = "mysql://user:pass@my-host:3306/shop" # TLS required off-loopback; ?ssl-mode=disabled to opt out
# 1. Full refresh — staging table, atomic swap, never a partial table
apitap.transfer(PG, CH, table="public.events")
# 2. Incremental — only rows past the destination's watermark (integer or timestamp cursor)
apitap.transfer(PG, CH, table="public.events", mode="append", cursor="id")
apitap.transfer(PG, "postgres://…/warehouse", table="public.customers", mode="merge", cursor="updated_at")
# 3. Batch CDC — first run bootstraps from a pinned snapshot, every later run drains the log
apitap.transfer(PG, CH, table="public.orders", mode="log_based")
apitap.transfer(MY, CH, table="shop.orders", mode="log_based") # MySQL / MariaDB binlog
apitap.transfer(PG, CH, tables=["orders", "order_items", "payments"], # one slot, one window, one watermark
mode="log_based")
apitap.transfer(PG, CH, tables=["t1", "t2", …, "t100"], mode="log_based", slots=4) # sharded over 4 slots
apitap.transfer(PG, CH, tables=["orders"], mode="log_based", changelog=True) # append every change, keep history
# 4. Many tables, one memory budget — or a whole schema
apitap.transfer(PG, CH, tables=["public.a", "public.b"])
apitap.transfer(PG, CH, schema="public")
apitap.transfer(PG, CH, tables={"orders": "log_based", "customers": "log_based", "dim_date": "replace"})
# 5. ClickHouse DDL: engine, ORDER BY, ON CLUSTER
apitap.transfer(PG, CH, table="public.events",
engine="ReplicatedReplacingMergeTree(ins_dt)", order_by="client_id, id", on_cluster="prod")
# 6. Warehouses, lakes, files
apitap.transfer(PG, "bigquery://my-project/raw?credentials=/path/key.json", table="public.events")
apitap.transfer(PG, "iceberg://catalog:8181/lake?warehouse=s3://lake/wh&endpoint=http://minio:9000",
table="public.events", mode="merge")
apitap.transfer(PG, "s3://bucket/exports?format=parquet&endpoint=http://minio:9000", table="public.events")
apitap.transfer(PG, "gcs://bucket/exports?format=csv&credentials=/path/key.json", table="public.events")
# 7. Spreadsheets and repositories as sources (all-text columns, replace only)
apitap.transfer("gsheets://1AbC…?credentials=/path/key.json", PG, table="Sheet1")
apitap.transfer("github://apitap/apitap-lib/tests/data?ref=main", PG, table="users")
apitap.transfer("github+api://apitap/apitap-lib", CH, table="issues", mode="append", cursor="updated_at")
# 8. DataFrames — same parallel pipes, Arrow batches built in Rust
df = apitap.read(PG, table="public.events").to_polars()
top = (apitap.read(PG, table="public.events").lazy()
.filter(pl.col("amount") > 100).group_by("status").agg(pl.len())
.collect(engine="streaming")) # tables bigger than RAM
apitap.read(MY, table="shop.orders").to_parquet("orders.parquet") # constant memory
Schedule any of these from cron, Airflow or Kubernetes; a CDC run is just the same call again. There is nothing to install at the databases and nothing to keep running between runs.
Connection URLs
| scheme | form | notes |
|---|---|---|
| Postgres | postgres://user:pass@host:5432/db?sslmode=verify-full |
sslmode = disable · prefer · require · verify-full (verify-ca is refused by name — it cannot be expressed safely). TLS is required for any non-loopback host by default since 0.62 (sslmode=verify-full, appending to a URL that says nothing); Unix sockets and loopback are untouched, and a set PGSSLMODE counts as your explicit choice. A cleartext or MD5 password request on a channel that is not verify-full is refused (APITAP_ALLOW_INSECURE_AUTH=1 overrides). Unqualified table names mean public. |
| MySQL / MariaDB | mysql://user:pass@host:3306/db?ssl-mode=verify_identity |
TLS is required for any non-loopback host since 0.55.1; ssl-mode = disabled (send in clear, on purpose) · required (encrypt, no verify) · verify_identity (default off-loopback; verify_ca is refused). Tables are db.table; the URL's database is the default |
| ClickHouse | clickhouse://user:pass@host:8123/db · clickhouse+https://…:8443/db |
HTTP interface; wait_end_of_query=1 so a failed INSERT is a failed run; session_timezone=UTC; every request pins limit=0, offset=0 and *_overflow_mode=throw, so a source user needs readonly=2 (not 1) |
| BigQuery | bigquery://project/dataset?credentials=/path/key.json[&location=EU] |
service-account key; GOOGLE_APPLICATION_CREDENTIALS is the fallback. Bulk modes use the free load/copy-job path and work on sandbox projects; CDC needs billing (row-level DML) |
| GCS | gcs://bucket[/prefix]?format=csv|parquet[&credentials=…] |
csv = one composed .csv.gz per table (atomic visibility); parquet = a directory of ZSTD parts |
| S3-compatible | s3://bucket[/prefix]?format=parquet[&endpoint=…][®ion=…][&access_key_id=…&secret_access_key=…][&session_token=…] |
AWS_* env vars are the fallback; an explicit endpoint means path-style (MinIO, R2, OVH/Scaleway/Hetzner); SigV4 signed by hand, no SDK |
| Iceberg | iceberg://catalog-host[:port]/namespace?warehouse=…[&base=…][&token=…][&tls=0|1][&endpoint=…][®ion=…][&access_key_id=…&secret_access_key=…] |
a REST catalog that takes a bearer token or none (Lakekeeper, Polaris, Nessie, R2 Data Catalog); not AWS Glue REST or S3 Tables, which need SigV4-signed catalog calls; a token is static, so one that expires mid-run fails the run (nothing is committed); format-version 2, single-level namespace, data on s3:// |
| Google Sheets | gsheets://<spreadsheet_id>?credentials=/path/key.json |
tabs are tables, row 1 the header, every column nullable text as displayed |
| GitHub files | github://owner/repo[/dir]?ref=main |
.csv files are tables (RFC 4180, streamed); GITHUB_TOKEN/GH_TOKEN for private repos and the 5,000/h rate limit |
| GitHub API | github+api://owner/repo |
issues, pull_requests, commits, stargazers, releases, issue_comments, workflow_runs, branches, tags, labels as typed tables plus a raw jsonb column; incremental where the API filters server-side |
A password containing @ / ? # : [ ] must be percent-encoded, or the URL means
something else (p@ss turns ss into the host): quote(password, safe='')
from urllib.parse. apitap never prints a password — a URL that fails to parse
is shown with the secret struck out and the offending character named.
The API
apitap.transfer(src, dst, table=None, *, tables=None, schema=None, dest_table=None,
mode="replace", cursor=None, parallel=None, chunk_bytes=None, durable=True,
engine=None, order_by=None, on_cluster=None, partition_by=None,
changelog=False, slots=None) -> TransferReport
| option | applies to | meaning |
|---|---|---|
table / tables / schema |
all | exactly one: a table (schema.table or bare), a list of tables, a {table: mode} dict (per-table modes; all log_based members share one slot), or every base table of a schema |
dest_table |
single table | destination name; defaults to table. Multi-table runs keep source names |
mode |
all | replace · append · merge · log_based |
cursor |
append/merge/read | integer or date/timestamp column; default = the integer primary key |
parallel |
bulk/read | concurrent range pipes; default auto from the cgroup's CPU and memory; an explicit value is never overridden |
chunk_bytes |
bulk | bytes coalesced per send, default 4 MiB (floor 64 KiB); the planner thins it to 2 MiB on memory-capped boxes |
durable |
Postgres dest | False loads through an UNLOGGED staging table (~30% less wall); the swapped-in table stays unlogged until ALTER TABLE … SET LOGGED |
engine, order_by, on_cluster |
ClickHouse dest | engine of the created table (any MergeTree family, Replicated included), its ORDER BY (default the cursor; the dedup key for Replacing engines), and ON CLUSTER DDL (requires a Replicated* engine). append into an existing table treats it as the authority and only checks engine family, arguments and ORDER BY agree |
partition_by |
ClickHouse, BigQuery changelog | a column name = monthly partitions on it (both engines), or a verbatim ClickHouse expression; a {table: clause} dict for multi-table runs; default monthly on _apitap_at |
changelog |
log_based into ClickHouse/BigQuery |
True appends every change with _apitap_op (I/U/D/T, B for the bootstrap baseline), _apitap_lsn, _apitap_seq, _apitap_at, and derives a <table>__current view — nothing is ever updated or deleted |
slots |
log_based, many tables, Postgres |
drain the group over N replication slots in parallel (cut deterministically from the sorted table list; changing N renames the slots and is refused until the old state is cleared) |
Results and errors. TransferReport(rows, elapsed_ms, parallel, tables);
for a multi-table run tables is a tuple of TableResult(table, rows, elapsed_ms, parallel, error) and rows sums the successful ones. Bad input —
unknown table, unsupported type, a bad URL — raises ValueError at probe time,
never mid-copy; transfer failures raise RuntimeError; apitap.LockedError
(a RuntimeError subclass) means another run already holds the destination
table, so a scheduler can back off on a type; apitap.MultiTransferError
carries .report when some tables of a multi-table run failed — the ones that
succeeded are committed. The GIL is released for the whole transfer.
apitap.request_stop() asks a running CDC drain to land its window and return.
apitap.read(src, table=None, *, cursor=None, parallel=None, query=None, columns=None, where=None) -> Reader
Reader method |
what you get |
|---|---|
.to_polars() · .to_arrow() · .to_pandas() |
the whole table, built as one batch per pipe in Rust (fewest FFI crossings) |
.lazy() |
a polars LazyFrame over the stream; .collect(engine="streaming") runs in constant memory; the query's column projection and a conservative subset of its filter (arithmetic, comparisons, AND/OR, string =) are pushed into the SQL |
.batches() |
small polars DataFrames, one per Arrow batch |
.to_parquet(path, compression="zstd", row_group_bytes=None) |
table → Parquet file at constant memory; returns the row count |
__arrow_c_stream__ |
pl.DataFrame(reader), pa.table(reader), DuckDB, pandas — zero-copy |
parallel=1 preserves source order; columns= reads a projection; where=
is a row filter in the source's own SQL, run by the server (one expression,
e.g. "l_shipdate > DATE '1995-03-15'"; .lazy() filters still push down on
top of it); query= is refused today (whole tables and cursor ranges only). Typed end to end:
int16/32/64, float32/64, bool, decimal128, date32, timestamp µs (UTC or naive),
utf8, binary; uuid, json/jsonb and exotic types arrive as text, so every table
reads.
How a transfer runs
probe ─► cursor & spans ─► negotiate wire format ─► stage ─► N parallel pipes ─► count ─► atomic swap
Probe the source catalog: columns, types, nullability, primary key, row estimate. Everything that can fail on types fails here.
Plan spans. An integer cursor splits into contiguous key ranges (
parallel × 6, so stragglers balance); a PK-less Postgres table splits byctidpage ranges (PG 14+ TID range scans); anything else streams as one span. Adeltapredicate (append/merge) and the lazy plugin's pushed-down filter ride inside every span statement.Negotiate the wire format — the first format the destination accepts that the source can produce for every column:
route how bytes move Postgres → Postgres raw binary COPYpassthrough: no row decode at all, likepsql | psqlwithout the shellPostgres → ClickHouse binary COPYtranscoded in flight toRowBinary(byte swaps, epoch rebasing, exactnumeric → Decimal); TSV only for types RowBinary cannot carryPostgres → MySQL binary COPYrendered in flight asLOAD DATAtext (exactnumerictoDECIMAL(65,30),byteaas HEX)Postgres → BigQuery / GCS / S3 / Iceberg binary COPY→ typed Parquet column chunks (ZSTD-1), noarrowdependency; small cores take the CSV+gzip lane into BigQueryMySQL → ClickHouse / Postgres / MySQL binary-protocol rows read by apitap's own MySQL wire client and encoded straight into RowBinary / binary COPY/LOAD DATAtext — one dispatch and at most one copy per cellClickHouse → ClickHouse RowBinaryrelayed untouched, every column cast server-side to the exact destination type so the bytes are correct by constructionSheets / GitHub → any text rows framed into the destination's format Stage. A staging table (or object prefix) named with this run's identity is created; it mirrors the source DDL on same-engine routes and maps types losslessly otherwise.
Pipes. Each pipe is one source connection streaming its spans into one destination loader (one
COPY, one HTTPINSERT, oneLOAD DATA, one resumable upload…), coalescing tochunk_bytesand recycling its buffers. A failed pipe cancels its siblings before anything is swept.Count and swap. The staged count must equal what the loaders report; then
DROP+RENAME(Postgres, one transaction),EXCHANGE TABLES(ClickHouse),RENAME TABLE(MySQL), a copy job (BigQuery), a snapshot commit (Iceberg), or a server-side copy of parts (GCS/S3). A 0-row source never touches an existing table.
Parallelism and memory are planned, not hoped. The pipe count starts from a
per-route CPU profile — Postgres→Postgres cores (1–8), anything → ClickHouse
8 × cores (2–32), MySQL → Postgres 4 × cores (2–8), MySQL → MySQL 4 × cores
(2–16), → BigQuery 2 × cores (2–8) — and is then fitted to the cgroup memory
limit: 40 MiB reserved, then per pipe 10 × chunk_bytes plus whatever the
destination lane holds (a Parquet pipe adds an 8 MiB part buffer, a 1 MiB frame
buffer, a 2 MiB page and three row groups of 24/8/4 MiB, chosen to fit). When
memory, not CPU, is the limit the chunk thins to 2 MiB before a pipe is dropped,
so a 128 MB container is a differently shaped run, not a slower copy of the
256 MB one. APITAP_MEM_BUDGET caps the budget below the cgroup when the
container is shared. Multi-table runs share one pipe budget, scheduled
largest-first with per-table grants re-fitted to real span counts, so peak
memory stays at the single-table ceiling no matter how many tables you pass.
read() plans the same way: (mem − 56 MiB) / (4 × (workers + queue + 1))
per batch, clamped 1–32 MiB.
Incremental sync (append and merge)
mode="append" loads rows whose cursor is past the destination's watermark;
mode="merge" does the same and upserts by the destination's primary key
(Postgres: one INSERT … SELECT DISTINCT ON (pk) … ON CONFLICT DO UPDATE;
Iceberg: an equality-delete file plus the delta's data files in one
snapshot). Merge is not available on ClickHouse, MySQL or BigQuery — use
append, or log_based for a true replica.
- The watermark lives in
_apitap_state, a plain table in the destination (dest_table, source_id, cursor_col, watermark, mode, last_rows, synced_at), written in the same transaction as the data on Postgres; on Iceberg it is a table property committed in the same snapshot. On ClickHouse, MySQL and BigQuery (no cross-statement transaction) the next run takes the greatest of the state row andmax(cursor)in the data — a crash between the two costs a bounded re-read, never a skip. BigQuery's state table is append-only (no DML on sandbox projects); readers take the newest row. - The first run of a missing table is a
replace. An emptied table carries no watermark, soTRUNCATEis a legitimate resync.replaceclears every source's state for the table. appendassumes the cursor is monotonic with commit order; for update-prone or concurrently written tables usemergewith anupdated_atcursor, or CDC. A state row written by a different cursor, or by a CDC drain (whose watermark is an LSN), is refused, never silently reinterpreted.- Fan-in — two
appendruns from different sources into one table — is supported: each (table, source) has its own watermark.
Batch CDC: mode="log_based"
Every change the log saw — inserts, updates (primary-key changes included),
deletes, TRUNCATE, unchanged-TOAST columns — captured on a schedule, with no
daemon and no extra columns in your rows.
Postgres source. The first run creates a logical replication slot
(pgoutput) and a publication for the member tables, exports the slot's
snapshot, and bootstraps with a full load pinned to it — no gap, no duplicate.
Every later run drains the WAL delta. Requirements: wal_level=logical, a role
that can create the publication (table ownership or superuser) and replicate, a
primary key on every member (REPLICA IDENTITY FULL works for the rest;
NOTHING is refused), and max_replication_slots ≥ the slots you use.
logical_decoding_work_mem follows the server's value (APITAP_DECODE_WORKMEM
opts into a per-session value, capped at 256 MiB).
MySQL / MariaDB source. The binlog coordinate is captured before an
idempotent full load, then every later run drains the binlog from it — the same
guarantee without a slot. Requirements: log_bin=ON, binlog_format=ROW,
binlog_row_image=FULL, no binlog compression or partial-JSON row values,
UTF-8 (utf8mb4) string columns, a primary key per table, and REPLICATION SLAVE/REPLICATION CLIENT. MyISAM/Aria transactions ending on a QUERY COMMIT are handled; a purged binlog, a position ahead of the server, or a
changed server identity is refused with its remedy. server_id is minted
uniquely per connection.
How a window lands. The drain reads the log into a window bounded by
bytes (default (mem − 24 MiB) / 16, capped at 24 MiB — 14.5 MiB in a 256 MB
container; APITAP_CDC_WINDOW_BYTES overrides), by APITAP_WINDOW_MAX_SECS,
or by the stop line captured at run start; a window never spans a source
table's column change, and one source transaction is never split. Each table's
changes are collapsed to one final image per key, then applied set-based:
delete the touched keys, insert the final images, in one unit per window whose
watermark commits with the data — one transaction on Postgres and MySQL, one
fenced multi-statement transaction on BigQuery, one snapshot on Iceberg, and
on ClickHouse a key table + lightweight DELETE + one INSERT per table with
the watermark written in one statement. The next window drains while the
previous one applies. Replaying a window is idempotent, so a crash anywhere
converges on the next run.
| destination | shape | notes |
|---|---|---|
| Postgres | temp tables + COPY + DELETE USING + INSERT SELECT, one tx per table-window |
a destination FK pointing into the group is refused at admission; a PK is added after bootstrap |
| ClickHouse | MergeTree family; lightweight DELETE (patch parts on 25.7+) |
Replicated* and ON CLUSTER tables are refused for CDC unless APITAP_CH_CDC_ALLOW_REPLICATED=1 and the URL names one node; changelog=True makes a window one INSERT and never mutates a part |
| MySQL | TEMPORARY tables + LOAD DATA LOCAL + DELETE … JOIN + INSERT SELECT, one tx |
GTID-enforced destinations work |
| BigQuery | all-STRING staging table + one MERGE per table inside a fenced transaction |
needs a billed project (DML); changelog=True replaces the ~7 s MERGE floor with a load job plus one INSERT … SELECT |
| Iceberg | data file + equality-delete file + watermark properties in one snapshot | single-column integer/text/uuid key, unpartitioned tables, format v2; Iceberg drains are the one destination the concurrency guard does not cover |
Groups and slots. tables=[…] shares one slot: every member lands at
the same LSN each window, one retention risk, one watermark; a group fails as a
unit and nothing is confirmed past a window every member applied. slots=N
splits a large group across N slots drained concurrently — Postgres decodes
each slot in one walsender process that saturates a core long before apitap
does, so on 100 tables / 100M changes slots=4 went from 121,789 to 278,947
changes/s (2.29×; every slot decodes the whole WAL and keeps its own tables, so
gains flatten past 4–16). The source pays one busy core per slot.
Memory. Windows are sized from the cgroup; the bootstrap uses the bulk
planner (divided by slots); one source transaction is capped at 256 MiB of
buffered changes (APITAP_TX_BUF_BYTES) and refused with its table, size
and knob rather than OOM-killing the container. Measured: a 2.1M-event backlog
replays inside a 0.5 vCPU / 44 MB container (33 MB peak); the 30-table
group runs under 200 MB.
What it does not do. DDL is not replicated: when a source column set
changes the window closes at that point, and a destination that lacks the
column is refused with the remedy (mode="replace" once to realign). A CDC
schedule paused past the source's retention (wal_status = lost, a purged
binlog) is refused before replication starts — clear the table's
_apitap_state rows to re-bootstrap. changelog=True is at-least-once on
ClickHouse (the append and the watermark are two statements, so a crash
between them can leave duplicate history under a new _apitap_lsn;
<table>__current stays correct); row stores and Iceberg refuse it.
Cross-source SQL: apitap.sql()
apitap.sql(query, *, sources, dest=None, table=None, mode=None) -> SqlResult | TransferReport
sources names each database ({"pg": "postgres://…", "my": "mysql://…"});
the query calls tables name.table. Without dest= the answer is a
SqlResult (an Arrow stream read a chunk at a time: .to_polars(), or hand
it to DuckDB / pyarrow);
with dest= and table= it lands in any destination through the ordinary
transfer path (typed, staged, atomic swap) and a TransferReport comes back;
with mode="log_based" as well it is an incremental view (below).
What runs. One table, two tables joined on an integer key, or a star of
three or more: one aggregated (fact) table, every other table joined by its
key — single or two-column (partsupp on (ps_partkey, ps_suppkey)) — to a
table already joined. Then:
| piece | supported |
|---|---|
| joins | INNER JOIN … ON a.k = b.k or the comma form with the equalities in WHERE; the key is an integer column on a database, while a sheet's, CSV's or GitHub API table's text key is read as an integer ('010' equals '10', spaces are trimmed, an empty cell is NULL, a value that is not an integer is refused); a text column from a database, directly or through a CTE or derived table, is refused as a key |
WHERE |
per-table conditions (comparisons, BETWEEN, IN, LIKE, IS NULL, AND/OR/NOT, date literals, folded constants) are pushed to that table's server; an OR across tables (TPC-H Q19) is checked here per matched row, its single-table parts implied to each server too; a comparison of a column of each table is checked on each joined row when both are numbers, both dates or both timestamps of one kind (l.shipdate > o.orderdate) |
| subqueries | IN / EXISTS / NOT IN / NOT EXISTS (SELECT …), including IN (SELECT k … GROUP BY k HAVING SUM/COUNT/AVG(…) > x) (TPC-H Q18) |
| expressions | arithmetic in aggregates, CASE WHEN … THEN … END over one table, EXTRACT(YEAR FROM col), derived tables FROM (SELECT … ) AS t without GROUP BY |
| aggregates | SUM, COUNT(*), COUNT(col), MIN, MAX, AVG; decimals exact (i128, digit for digit), integer overflow widened, floats f64 |
| result | GROUP BY (columns of any joined table), ORDER BY (exact for decimals), LIMIT, HAVING, COUNT(DISTINCT col), a plain SELECT with no aggregate |
| staged | WITH (CTEs, nested), derived tables with GROUP BY, scalar subqueries, correlated scalar subqueries used as a NULL-rejecting comparison (TPC-H Q2, Q17, Q20, Q22), EXISTS comparing two tables' columns (Q21), LEFT JOIN … GROUP BY the preserved table's key with COUNT(*) or aggregates of a joined-table column (Q13) |
Refused before a row is read, naming the piece: SELECT *, UNION, window
functions, WITH RECURSIVE, OFFSET/FETCH/LIMIT BY at any depth, right
and full outer joins, a LEFT JOIN grouped coarser than its key or with
HAVING/DISTINCT or an aggregate over an expression of the joined table, a
correlated subquery in any other place, a CTE shadowing another, a timestamp
scalar subquery used as a literal, a WHERE expression over two tables'
columns (l.a * 2 > o.b), a comparison of two tables' text columns (their
collations may differ) or of unlike kinds (a date against a timestamp or a
number), joins on non-integer keys (a database's text column as a join key included).
How it runs. Each server only scans and sends: its table's conditions and
columns go into its own SELECT, never a join, aggregate or another table's
keys (opt-in exceptions below). The join key's range is asked of both sides
and intersected; the smaller side becomes a direct-address key map (dimension
tables load concurrently into their own maps); the large side streams past it
over parallel pipes, and the decoder drops rows whose key the map lacks before
decoding their other columns. Aggregates fold as rows arrive — no joined row is
ever built. A build past its memory budget (40% of the cgroup limit) splits into
key-range passes; on a Postgres table stored in key order each pass scans only
the pages its keys lie in, and a MySQL table without a leading index on the key
is read once and its rows spilled to disk by range (grace) instead of re-read.
Groups past an eighth of the limit are answered in parts of a GROUP BY
column. A query the engine cannot answer in one pass (CTEs, subqueries,
HAVING, COUNT(DISTINCT), LEFT JOIN) runs as stages: each inner answer is a
stage table, held in memory up to a sixteenth of the limit for all stages
together, then written to disk in chunks of at most 8 MiB and read back a chunk
at a time. The final answer leaves the same way, so a 5.7M-row answer reaches
DuckDB or a dest= table at 244 MB peak.
Consistency and semantics. Every read of one Postgres server in a query —
passes, page spans, dimension and late reads, subqueries, every stage — runs
in one exported snapshot for the whole statement, so an UPDATE committing
mid-query never loses or doubles a row. MySQL sources take no snapshot: a
statement reading one MySQL table twice can see two states. Text compares follow the column's collation: Postgres CHAR(n) ignores
trailing spaces, VARCHAR/TEXT do not; a MySQL _ci column compares
case-insensitively, _ai_ci / general_ci also accent-insensitively
('Brésil' = 'bresil'), PAD SPACE collations ignore trailing spaces, and a
GROUP BY on such a column makes the server's groups (shown as the first value
met). Not emulated: the full Unicode collation algorithm beyond accents and the
common Latin base letters; text join keys compare bytewise.
Incremental views (mode="log_based", needs dest=, table=): the first
run reads the tables once at a replication snapshot (Postgres slot snapshot;
MySQL FLUSH TABLES WITH READ LOCK + consistent snapshot + binlog position),
every later run moves the answer by the changes since — no re-read. One
Postgres table, or a Postgres × MySQL join; SUM/COUNT/AVG
(MIN/MAX refused: a delete needs the rest of the group); Postgres tables
need REPLICA IDENTITY FULL. State lives in APITAP_STATE_DIR (default
~/.apitap/views), bucketed on disk with a SHA-256 per block.
Measured: TPC-H across two databases, a 10g seed (not standard SF10:
lineitem 60.3M, partsupp 52.8M, customer 9.9M rows on Postgres; orders 69.9M,
part 13.1M, supplier 0.65M on MySQL; about 29 GB), 0.5 vCPU / 256 MB container
(--cpus=0.5 --memory=256m, swap off), the 0.67.0 release wheel, one run each.
lineitem, partsupp and customer in PostgreSQL; orders, part, supplier, nation
and region in MySQL. All 22 answers equal DuckDB's (decimals digit for digit;
Q1's float AVG columns to the last digits of summation order).
| query | wall | CPU | peak | query | wall | CPU | peak | |
|---|---|---|---|---|---|---|---|---|
| Q1 | 62.2 s | 31.1 s | 200 MB | Q12 | 12.8 s | 3.5 s | 98 MB | |
| Q2 | 30.7 s | 11.8 s | 130 MB | Q13 | 171.6 s | 71.8 s | 259 MB | |
| Q3 | 39.8 s | 12.8 s | 162 MB | Q14 | 12.6 s | 5.2 s | 165 MB | |
| Q4 | 13.2 s | 3.9 s | 116 MB | Q15 | 20.2 s | 6.1 s | 113 MB | |
| Q5 | 33.4 s | 12.0 s | 123 MB | Q16 | 82.7 s | 30.4 s | 150 MB | |
| Q6 | 3.8 s | 1.0 s | 50 MB | Q17 | 29.4 s | 10.9 s | 179 MB | |
| Q7 | 24.7 s | 10.2 s | 176 MB | Q18 | 55.7 s | 19.4 s | 219 MB | |
| Q8 | 36.8 s | 14.4 s | 148 MB | Q19 | 11.3 s | 3.3 s | 53 MB | |
| Q9 | 77.4 s | 26.7 s | 189 MB | Q20 | 16.7 s | 6.5 s | 149 MB | |
| Q10 | 24.9 s | 8.1 s | 156 MB | Q21 | 103.2 s | 32.0 s | 175 MB | |
| Q11 | 55.0 s | 22.7 s | 147 MB | Q22 | 16.8 s | 7.6 s | 90 MB |
Total 934.9 s of wall and 351.4 s of CPU for all 22; no query passed 259 MB (the cgroup peak, page cache included). The twelve 0.66.0 ran (Q3–Q10, Q12, Q14, Q18, Q19) take 346.4 s against 0.66.0's 342.7 s; side by side on one host the two releases are within each query's run-to-run spread. Q13 (a LEFT JOIN of 9.9M customers to 69.9M orders, grouped twice) is the slowest at 171.6 s. A plain SELECT of 5.7M rows streamed into DuckDB finishes in 225 s inside the same cage. What these do not show: no other engine was run under the same cap (DuckDB, the truth, ran with more memory); the servers did no join or aggregate work for apitap; this is not standard SF10, and no bounded-memory claim is made at larger scales (Q9 at SF50 was not run to completion).
Limits. An answer read into a Python list (.to_polars(), fetchall) is
bounded by Python's objects, not the engine: past roughly 500K wide rows in
256 MB, stream it to DuckDB/pyarrow or land it with dest=. A Postgres view or
partitioned table as the large side is re-read per key-range pass (no page
spans). MySQL sources take no snapshot. A process killed mid-query (SIGKILL) or
a stream held open to interpreter exit can leave stage files in the temp
directory. dest= with a 0-row answer creates no table.
Opt-in levers (off by default because they move work onto the source):
APITAP_SQL_KEY_FILTER=1 sends a selective build's keys to Postgres as a
bitmap filter; APITAP_SQL_PUSH_ARGS=1 has Postgres compute an aggregate's
argument as one scaled int8 (rerun without it on overflow).
Guarantees
- Atomic. Rows land in a staging object and are published in one metadata operation; readers never see a partial table and a mid-run failure leaves the previous table untouched. 0-row guard: an empty source never wipes a destination.
- Exact types or a refusal. Every mapping is lossless by construction
(exact
NUMERICup toDECIMAL(65),BIGINT UNSIGNED→numeric(20,0), UTC-normalized timestamps,jsonbround trips); a value a destination cannot hold —NaNinto MySQL,infinitydates, a year outside MySQL's range — fails the run instead of being coerced. The 15-column benchmark schema is asserted byte-faithful, values and column types, in the test suite. - One run per destination table. The run's identity (start time, mode,
source, nonce) is part of every staging name, so a run can only publish what
it minted; a second run is refused at
preparewithLockedErrornaming the run that holds the table. A run announces itself before it looks, so two runs starting in the same instant cannot both proceed (when each sees the other, both fail loudly with nothing written). Drains are guarded the same way against bulk runs and against each other, across versions since 0.55.1. A drain renews a lease while it runs and every write it makes is conditional on that lease, so a drain killed outright stops blocking its table once the lease lapses (APITAP_LEASE_TTL_SECS, 300 s) and the next run resumes from the watermark; a killed bulk run's staging holds data and must be dropped by hand — the error names it, and nothing collects it on a guess. - Refuse rather than corrupt or hang. A source transaction past the
buffer cap, a half-open replication socket (TCP keepalive plus a 120 s
silence budget,
APITAP_REPLICATION_SILENCE_SECS), a lost slot, a foreign key into a CDC group, a non-UTF-8 MySQL column, aREPLICA IDENTITY NOTHINGtable, a cursor or lane mismatch in_apitap_state, an ambiguous Iceberg commit (settled by the client-chosen snapshot id, never assumed failed) — each is a named error before or instead of a wrong write. One source feeding two destinations gets two slots, because slot names carry the destination's identity. - Safe by default. TLS required for MySQL off-loopback; cleartext/MD5
Postgres auth refused off a verified channel; credentials only in the
Authorizationheader, never in a URL or a log line; aLinkheader that leaves the GitHub API host is not followed; every wire decoder is run against mutated and truncated input in the test suite, and lengths a peer chooses are capped before they become allocations. - Stoppable. A
log_basedrun takes the first SIGTERM — what Kubernetes, Airflow and systemd send — as a request to land the window in flight, advance the watermark and exit 0, and chains to any handler your process installed; a second SIGTERM is not absorbed. Bulk runs and a CDC table's first (bootstrap) run stay killable, since a half-done bulk load has nothing worth keeping.
Every one of these sentences is a row in the release gate's claim matrix, proved by a leg that asks the server what happened, not the exit code — including what a killed process leaves behind, produced on purpose against live databases. 0.63.0 shipped on 188 legs passed, 0 failed, 17 skipped — every skip a capability the rig lacked (cloud credentials, a TLS container, an old wheel), named in the gate's output, because a skipped leg is a reported leg.
Operating it
Progress, with no flag. On a terminal, one rewritten line every 2 s:
apitap ▸ bank_transfer · 6,261,134 rows · 2.37 GB · ≈63% (est) · 844K/s · 0:08 · 32 pipes
Everywhere else — Airflow, Kubernetes, docker logs, cron — a plain key=value
line every 30 s, flushed per line, no ANSI:
2026-08-17T06:48:41Z apitap progress table=bank_transfer rows=206656 bytes=8495323 rows_per_s=497554 bytes_per_s=20453727 elapsed_s=0.4 pipes=32
2026-08-17T06:48:42Z apitap done table=bank_transfer rows=1000000 bytes=413190127 rows_per_s=608954 bytes_per_s=251614149 elapsed_s=1.8 pipes=32
A CDC run counts changes and windows (cdc window 3 · 1,240,000 changes) and
emits a slot.wal gauge with the WAL the slot is retaining on the source,
warning past APITAP_SLOT_WAL_WARN (4 GB) — the difference between a paused
schedule and a full source disk. APITAP_PROGRESS=json makes every line one
JSON object for Loki/Fluentd. Security-relevant notes print even when progress
is off.
Environment knobs (all optional; the defaults are the measured ones):
| variable | default | effect |
|---|---|---|
APITAP_PROGRESS / APITAP_PROGRESS_INTERVAL |
auto · 2 s / 30 s | 0 silences, json structures; the cadence in seconds |
APITAP_MEM_BUDGET |
the cgroup limit | plan pipes, batches and windows against a smaller number in a shared container (200M, 1G) |
APITAP_GRACEFUL_STOP |
1 |
0 = die on the first SIGTERM instead of landing the window |
APITAP_LEASE_TTL_SECS |
300 (30–3600) |
how long a drain's claim outlives its last renewal |
APITAP_CDC_WINDOW_BYTES |
auto (≤ 24 MiB) | bytes buffered per CDC window; 32 MiB is the measured best at 256 MB |
APITAP_WINDOW_MAX_SECS |
3600 |
wall-clock cap on one window |
APITAP_TX_BUF_BYTES |
256 MiB | the one-transaction buffer cap behind the refusal |
APITAP_REPLICATION_SILENCE_SECS |
120 |
silence budget on the replication socket (lower only) |
APITAP_READ_COALESCE_US |
5000 (µs) |
how long the plain-TCP replication reader may wait for its window to fill before one recvfrom — epoll wakes on the first arrival, so each read otherwise carried ~5 KB; the 5 ms knee measured −6% CPU/change (the 0.59.0 APITAP_READ_LOWAT receive watermark was reverted: ET epoll parked the reader past wal_sender_timeout); a read that already has data is never delayed, so keepalives still flow; 0/off disables (the A/B control) |
APITAP_DECODE_WORKMEM |
server value | per-session logical_decoding_work_mem, ≤ 256 MiB |
APITAP_CDC_APPLY_LANES |
auto (ClickHouse min((mem−96 MiB)/20 MiB, 16·cores) clamped 1–16; BigQuery ≤ 8; Postgres/MySQL 1) |
concurrent table applies per window |
APITAP_CH_MAX_BODY |
one request per pipe | cap each ClickHouse request body (512K, 64M) for a proxy with a body limit |
APITAP_CH_CDC_ALLOW_REPLICATED |
unset | allow CDC into a Replicated* table when the URL names a single node |
APITAP_HTTP_CONNECT_TIMEOUT / APITAP_HTTP_READ_TIMEOUT |
15 s / off |
connect deadline; the read timeout is a total request deadline and stays off because a long load sends nothing until it is done — TCP keepalive detects dead peers instead |
APITAP_ALLOW_INSECURE_AUTH |
0 |
accept a cleartext/MD5 password request on an unverified Postgres channel |
APITAP_SLOT_WAL_WARN |
4G |
retained-WAL threshold for the warning |
APITAP_PG_BINARY |
auto | binary pgoutput: on by default for ClickHouse lanes whose every column type has a binary renderer (else text); 1 forces on, 0 forces off |
APITAP_STATE_DIR |
~/.apitap/views |
where apitap.sql(mode="log_based") keeps each view's state |
APITAP_SQL_BUILD_BYTES |
40% of the memory limit | the bytes an apitap.sql() key map may hold before the join splits into key-range passes |
APITAP_SQL_GROUP_BYTES |
1/8 of the memory limit | the bytes an apitap.sql() GROUP BY may hold before it is answered in parts of a GROUP BY column |
APITAP_SQL_STAGE_BYTES |
1/16 of the memory limit | the bytes all of a statement's stage tables (and a streamed answer) hold in memory together before they go to disk |
APITAP_SQL_GRACE |
1 |
0 = a key-range pass re-reads its probe range from the server instead of the grace spill to disk |
APITAP_SQL_SNAPSHOT |
1 |
0 = each apitap.sql() statement in its own snapshot (rows an UPDATE moves mid-query can be lost) |
APITAP_SQL_KEY_FILTER / APITAP_SQL_PUSH_ARGS |
0 |
opt-in work on the source: a key bitmap filter / the aggregate argument computed by Postgres |
APITAP_SQL_VIEW_PG_AGG |
0 |
1 lets an incremental view's first run aggregate the non-GROUP BY Postgres side on its server |
APITAP_DEBUG |
off | per-phase timings on stderr |
A/B escape hatches also exist (APITAP_RAW_COPY=0, APITAP_MY_RAW=0,
APITAP_MY_ARROW=0, APITAP_BATCH_BYTES, APITAP_BQ_APPLY_LANES,
APITAP_BQ_CLUSTER=0, APITAP_GSHEETS_PAGE_ROWS, and for apitap.sql()
APITAP_SQL_GATE=0, APITAP_SQL_NARROW=0, APITAP_SQL_OVERLAP=0,
APITAP_SQL_PREFETCH=0, APITAP_SQL_PROBE_PIPES, APITAP_SQL_VIEW_BULK=0,
APITAP_SQL_VIEW_BUCKET_BYTES); they select slower reference lanes or force a
shape for measuring and are not tuning knobs.
Troubleshooting
| you see | why | do |
|---|---|---|
invalid port number, or a connect error naming a host you never typed |
a password with @ / ? # : [ ] is being read as URL syntax |
quote(password, safe='') before building the URL |
mysql source connect TLS: server does not support TLS |
TLS is required off-loopback and the server has none | add ?ssl-mode=disabled to send in clear on purpose, or enable TLS |
sslmode=verify-ca is not implemented |
chain-only verification cannot be expressed safely | use verify-full or require |
postgres source connect TLS: server does not support TLS, or a certificate error naming a private CA |
TLS is required off-loopback by default and the server has none / a CA rustls's roots do not carry | set ?sslmode=require (encrypt, no verify) or ?sslmode=disable (clear, on purpose) in the URL, or point sslrootcert=/path/ca.crt at its CA |
ClickHouse 413 Payload Too Large |
a proxy (nginx) caps request bodies | APITAP_CH_MAX_BODY=512K (bulk: also chunk_bytes=256*1024) |
log_based: … Replicated… refused |
CDC into a replicated/cluster table would apply on one replica | point at one node and set APITAP_CH_CDC_ALLOW_REPLICATED=1, or use a non-replicated table |
locked: another apitap run is already loading this table |
a live peer, or a dead one's leftovers | wait; a dead drain clears itself after the lease TTL; a dead bulk run's object is named in the message — drop it once |
wal_status = lost / binlog … purged |
the schedule paused past the source's retention | clear the table's _apitap_state rows (both t and schema.t spellings) and re-run: it re-bootstraps |
one transaction … exceeds the cap |
a source transaction bigger than the buffer | split the writer's transaction, or raise APITAP_TX_BUF_BYTES within the box |
state row tracks cursor 'x' / is CDC-managed |
the table is owned by another lane or cursor | keep that lane, or clear the state rows to hand it over |
column 'c' is in the WAL but not in the … target |
the source gained a column | mode="replace" once to realign, then resume |
string column … charset latin1 |
non-UTF-8 text cannot round-trip through the CDC lane | convert the column to utf8mb4 |
| BigQuery CDC fails on DML | sandbox (no-billing) project | CDC into BigQuery needs billing; bulk modes work on sandbox |
Performance, measured
Every table here is a full refresh of the same 15-column table (ingestr's own benchmark schema and value generators), each tool in its own container with identical caps, stock Docker databases, warm runs, and the destination checksummed against the source (19 per-column aggregates) before a time counts. Rivals run their own documented best configuration. Timing is inside the container around the transfer only.
10M rows, every tool at 16 vCPU / 4 GB (apitap from PyPI):
| route | apitap | ingestr 1.0.75 | dlt default | dlt + pyarrow |
|---|---|---|---|---|
| Postgres → Postgres | 20.2 s | 500 s | 2,604 s | 708 s |
| Postgres → ClickHouse | 9.9 s | 111 s | 1,893 s | 360 s |
| MySQL → ClickHouse | 10.4 s | 97 s | 2,231 s | failed (type inference) |
| MySQL → Postgres | 22.5 s | 481 s | 2,899 s | failed (type inference) |
| Postgres → MySQL | 64.3 s | 366 s | 329.7 s at 1M (52×) | 181.5 s at 1M (28×) |
| Postgres → BigQuery | 28.4 s | 860 s | 2,160 s | — |
dlt + connectorx was OOM-killed on every route at the same 4 GB. At 10M the
Postgres → ClickHouse source itself is the wall: 16 parallel binary COPYs to
/dev/null take ~11 s on that host with no tool involved. durable=False cuts
pg → pg from ~24 s to 15.5 s and mysql → pg from ~27 s to 19.5 s.
The tool alone capped at 0.5 vCPU / 256 MB, databases uncapped (0.57.0 from PyPI, three interleaved rounds):
| job | apitap | the other tool |
|---|---|---|
| Postgres → ClickHouse, 10 × 1M rows, one call | 18.4 s, 119 MB peak, 30/30 MATCH | walshadow 0.1.2: OOM-killed 6/6 legs (needs ~550 MB; lands all ten at 1 GB in ~125 s) |
| MySQL → ClickHouse, 10 × 1M rows, one call | 36.1 s, 99 MB peak, 30/30 MATCH | ingestr 1.1.61: OOM-killed 6/6 legs, 0 rows |
| Postgres → ClickHouse CDC, 30 tables, 30M-row bootstrap | 63.8 s, 105 MB peak, 60/60 MATCH; then 2.97M changes at a flat 65.6 MB | walshadow: OOM-killed 8/8 legs, ≤ 70,656 rows |
| MySQL → ClickHouse CDC, 30 tables, 30M-row bootstrap | 106.2 s, 119 MB peak, 60/60 MATCH; then 2.97M changes at a flat 55.5 MB | ingestr: refuses ClickHouse as a CDC destination |
| Postgres → ClickHouse, 5M rows, apitap.dev/lab rig | 25.6 s (29.1 s at 1 GB / 2 vCPU — fewer pipes, less insert contention) | ingestr 1.1.1 201 s; dlt 1.29 + pyarrow OOM-killed |
TPC-H, 10 tables × 1M / × 5M, Postgres → Postgres, one schema= call |
33 s / 168 s, 52–53 MB peak, 10/10 | dlt OOM-killed in 7–14 s; ingestr (10 sequential invocations) 327 s / 1,238 s |
Scale. 232M rows / 101 GB, Postgres → ClickHouse, 0.5 vCPU / 256 MB: 8m57s, 170.8 MB peak (latest ingestr and dlt+pyarrow OOM-killed in ~21 s on the same box); the same table completes inside a 44 MB cap. 100M rows / 46 GB at 16 vCPU / 4 GB: 139.2 s over 32 pipes (1.2 TB/hour, past the source's page cache). Three dedicated GCE machines over an internal VPC: the 232M-row table in 30.3 s (~3.3 GB/s), the alternatives at 0 rows when cut.
CDC (CDC side at 0.5 vCPU / 256 MB, writer unconstrained, every leg count- and digest-verified):
| shape | result |
|---|---|
| 650K-event window, Postgres → Postgres, every tool at 0.5 vCPU / 256 MB | apitap 12 s · ape-dts 22 s · ingestr 78 s · pipelinewise 227 s; apitap's 12 s is the same on a 16-core box |
| 650K-event backlog, MySQL → ClickHouse | 15.6 s; ape-dts had not converged at 900 s |
| 10 tables, 40M changes per source, 80M total | MySQL binlog 84K changes/s (5 cols, updates) to 135K/s (inserts); Postgres WAL 51K/s (5 cols) to 34K/s (15 wide cols) — the wide-row figure is 92% of what pg_recvlogical manages in the same cage just receiving the stream |
changelog=True vs replica |
free on the Postgres lane; +34% on MySQL (113.8K/s) |
| 30 tables in one group, 15 columns, 0.5 CPU | 31,679 changes/s = 1.90M/min pure catch-up (15.35 µs of client CPU per change, 163 MB, 30/30); ~1.50M/min paced by a 35K/s writer. Flat above 1 CPU: a single-threaded drain and one walsender are the ceiling the 3M/min design removes |
| 1 table, 0.59.0 wheel | kept up with a 2.19M/min writer for 150 s at 0.40 core, 101 MB peak, digest matched |
| 100 tables, 100M changes, 1 core | slots=4: 278,947 changes/s (2.29× one slot) |
| 180M changes, 4 sharded Postgres → 1 ClickHouse, both movers at 6 CPU / 2 GB | apitap 125 s of apply work, 400/400 tables verified, ~3 µs CPU/change; PeerDB (its own best configuration) DNF at ~89% when the 30-minute cap fell, ~47 µs/change, 5.9 of 6 cores pegged (n=1 per leg) |
| the stress question: 1M changed rows per table per minute × 30 tables at 0.5 CPU | no — the slot was lost before a change committed, and the refusal said so |
read() (0.5 vCPU / 256 MB unless noted): 10M Postgres rows → polars in
13.2 s at ~100 MB (bench box uncapped: 14.9 s vs connectorx 55.9 s, pandas
295 s); 11.8M string-heavy MySQL rows in 29 s at 126 MB, count-style scans in
3.4 s (driver-based readers: 51 s); .lazy() filter + group_by over 50M rows in
9.9 s at ~180 MB (polars/connectorx OOM-killed until 1–2 GB; ADBC 45.2 s);
50M rows filtered and sunk to Parquet in 34 s; a Postgres-50M × MySQL-50M
join as one polars expression in 165 s on 4 cores, 154 s on the half-core box
with both extracts concurrent.
Files and lakes (16 vCPU / 4 GB, every output read back with DuckDB and checksummed): Postgres → GCS 10M in 15.5 s (csv.gz) / 18.2 s (Parquet), ingestr 124 s, dlt ~7–29 min extrapolated; → S3/MinIO Parquet 14.5 s (dlt 379 s; ingestr OOM-killed with 0 objects); → Iceberg full 10.3M / append +1M / merge 1M upsert in 15.2 s · 2.2 s · 3.8 s (dlt+pyiceberg OOM-killed; ingestr has no Iceberg destination). ClickHouse → ClickHouse 10M: 8.4 s uncapped, 20.6 s inside 256 MB.
What these numbers do not show. Source and destination ran on one host
(loopback) in most rows; a two-instance control landed within noise and a netem
study found the gap narrows as RTT rises, so WAN production is slower than
this and the margin is smaller. Databases were uncapped and ordinary-sized — a
1.5 GB ClickHouse cannot receive a 5M-row insert from any tool. First-run
install time is excluded for everyone. The CDC rows are a 15-column shape; row
width moves every number. Two legs in the original runs failed because the
benchmark filled its own disk and were re-run after cleanup; a comparison that
retracted one of our own numbers (5.2× → 2.6× on read()) is recorded the same
way.
Type mapping
Postgres source → each destination (RowBinary / Parquet lanes; anything not listed rides as text):
| Postgres | ClickHouse | MySQL | BigQuery | Iceberg / Parquet |
|---|---|---|---|---|
int2 · int4 · int8 |
Int16 · Int32 · Int64 |
SMALLINT · INT · BIGINT |
INT64 |
long |
float4 · float8 |
Float32 · Float64 |
FLOAT · DOUBLE |
FLOAT64 |
float · double |
numeric(p,s), p ≤ 38 |
Decimal(p,s) |
DECIMAL(p,s) (≤ 65,30) |
NUMERIC / BIGNUMERIC |
decimal(p,s) |
numeric unconstrained |
Float64 |
DOUBLE (range-checked) |
BIGNUMERIC, exact (FLOAT64 before 0.65.0) |
text lane |
bool |
UInt8 |
TINYINT(1) |
BOOL (INT64 on the text lane before 0.65.0) |
boolean |
date |
Date32 |
DATE |
DATE |
date |
timestamp · timestamptz |
DateTime64(6) · DateTime64(6,'UTC') |
DATETIME(6) (UTC session) |
DATETIME · TIMESTAMP |
timestamp · timestamptz |
uuid |
UUID |
CHAR(36) |
STRING |
string |
text · varchar(n) · bpchar · json |
String |
LONGTEXT · VARCHAR(n) |
STRING |
string |
jsonb |
String |
JSON |
STRING |
string |
bytea |
String holding the \x-hex text |
LONGBLOB (via HEX) |
BYTES (STRING \x-hex text before 0.65.0) |
binary |
MySQL source: tinyint…bigint → ClickHouse Int8…Int64 /
UInt8…UInt64 for unsigned; → Postgres smallint/int/bigint widened for
unsigned, bigint unsigned → numeric(20,0). decimal(p,s) exact (p ≤ 38 →
Decimal, larger → String on ClickHouse; numeric on Postgres up to
DECIMAL(65)). datetime → DateTime64(6) / timestamp; timestamp →
DateTime64(6,'UTC') / timestamptz (sessions run UTC). json → String /
jsonb; enum/set/time → text; binary/blob/bit → String (the bytes) / bytea /
BigQuery BYTES (refused before 0.65.0).
Same-engine routes (pg → pg, my → my) mirror the source DDL verbatim, charset
and collation included.
Roadmap
- Postgres, MySQL/MariaDB, ClickHouse, Google Sheets, GitHub files and the GitHub API as sources; Postgres, MySQL, ClickHouse, BigQuery, GCS, S3-compatible stores and Apache Iceberg as destinations — the full mesh enforced by a test that fails the build on an unlisted pair
- Incremental
append/mergewith a transactional state table - Batch CDC from Postgres and MySQL/MariaDB into five destinations; groups
on one slot,
slots=N,changelog=True, per-table modes in one call -
apitap.read()→ polars / pyarrow / duckdb / pandas with projection and filter pushdown; Parquet sinks at constant memory; cross-engine joins - Multi-table and whole-schema transfers under one memory budget
- Concurrency guard with run identity in every artifact name, leases that let a killed drain heal itself, SIGTERM that lands the window, progress in terminal / plain / JSON shapes
- 3M changes/min of CDC on 0.5 vCPU / 256 MB, scaling automatically with the box (measured today: 1.90M/min catch-up)
-
apitap.sql(): joins and aggregates across databases in Rust, exact, one snapshot per Postgres server, incremental views over Postgres × MySQL -
apitap.sql(): all 22 TPC-H queries (CTEs, correlated subqueries, HAVING, COUNT(DISTINCT), LEFT JOIN by key), answers streamed -
apitap.sql(): incremental views over three or more tables; window functions, UNION, outer joins beyond LEFT JOIN by key -
query=forread()(arbitrary SQL, not just tables) - Snowflake destination
- aarch64 and macOS arm64 wheels, plus an sdist (0.67.1)
Development
cargo test -p apitap-core # engine tests (MSRV 1.94; a patched sqlx-core is vendored)
uv pip install -e py-apitap # build the wheel with maturin (needs Rust)
python benchmarks/gate.py --matrix # release pre-flight: every claim × engine must have a proving leg
Everything lives in one crate, crates/apitap-core; py-apitap is a thin PyO3
shim that releases the GIL and exports an Arrow C stream for read().
pipeline/ runs every bulk route's lifecycle and holds the route table and the
memory planner; source/ and sink/ implement Source and Sink per engine;
wire/ holds the encoders and apitap's own wire clients (Postgres walsender and
COPY, pgoutput, MySQL protocol and binlog, RowBinary, LOAD DATA text,
Parquet, Arrow); logbased/ is CDC (the drain/collapse/window pipeline, groups
and slots, one apply per destination); naming.rs, guard.rs and lease.rs
are the run identity, the announce-then-check guard and the fence every CDC
write is conditional on. Encoders are deliberately per-(source, format) and
fully monomorphized — there is no neutral in-memory row representation, because
the fast lanes are the product. A new destination is one sink/<name>.rs
(plus a logbased/dest_<name>.rs for CDC) and its route-table arms; it then
works with every source whose wire format it accepts.
The release gate runs 243 end-to-end legs against live servers (Postgres with
logical replication, MySQL 8.4, MariaDB, ClickHouse, MinIO and a REST Iceberg
catalog, BigQuery, two older wheels for upgrade and rollback legs) and prints
one verdict; a skipped leg is a reported leg. Longer write-ups — the benchmark
ledgers with raw logs, the failure-mode catalogue, the CDC design and the
3M/min plan, the per-release reviews — live under benchmarks/, docs/,
docs/design/ and docs/review/ in this repository.
License
MIT.
Explore related projects
Asabeneh/30-Days-Of-Python
The 30 Days of Python programming challenge is a step-by-step guide to learn the Python programming language in 30 days. This challenge may take more than 100 days. Follow your own pace. These videos may help too: https://www.youtube.com/channel/UC7PNRuno1rzYPb1xLa4yktw
78,618 starsgrafana/grafana
The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
77,859 starsredis/redis
For developers, who are building real-time data-driven applications, Redis is the preferred, fastest, and most feature-rich cache, data structure server, and document and vector query engine.
77,472 starsapache/superset
Apache Superset is a Data Visualization and Data Exploration Platform
75,495 stars