Archiving Data to ClickHouse: The Proven Engineering Guide for PostgreSQL and MySQL (2026)

ChistaDATA · ClickHouse as the archive store for PostgreSQL and MySQL

Archiving data to ClickHouse is the most reliable way we know to make a transactional database smaller and faster without losing a single historical row. The history moves to a columnar store that compresses it well and answers analytical questions over it in milliseconds; the source keeps only the window that transactions still touch. This guide covers the mechanics end to end for PostgreSQL 16+ and MySQL 8.4 LTS sources on ClickHouse 25.x and 26.x LTS.

It is written for the engineer who has to do the work: which rows to move, the three ways to move them and when each applies, how to shape the archive table, how to prove the copy before anything is deleted, how retention works inside ClickHouse, and how to keep seven years of history queryable without scanning seven years.

3Paths for archiving data to ClickHouse: bulk via object storage, direct table-function pull, continuous CDC
8Steps in the reconciliation protocol before a source partition is dropped
15 minSeverity 1 response for ChistaDATA support customers, 24×7×365
100%Open-source ClickHouse; the archive is yours in an open format

Why ClickHouse

Why archiving data to ClickHouse beats a cold bucket or a bigger primary

ClickHouse is known for real-time analytics, but the same properties make it an unusually good archive store for transactional systems. Columnar storage with per-column codecs means historical rows, which are mostly repetitive timestamps, enumerations and identifiers, compress far better than they do in a row store’s heap pages. The sparse primary index and partition pruning mean a query over one customer’s seven-year history reads a few granules, not a table scan. Replication through ClickHouse Keeper and tiered storage to object storage give the archive durability and a cost profile close to cold storage, while keeping it queryable with plain SQL.

The alternative most teams start with is a cold bucket of CSV or Parquet exports. It is cheap and it is not queryable; every audit or restatement becomes a re-import project. The other alternative, keeping everything in the primary and buying a larger instance, treats the symptom: the working set still competes with dead history for cache, and every index, backup and vacuum grows with data nobody writes to. Archiving data to ClickHouse removes the history from the transactional path and gives it a home where analytical reads are the native workload.

ChistaDATA runs this pattern for customers as a one-off backfill, as a scheduled monthly job, and as a continuous pipeline through ChistaDATA Fabric and oltp-archiver. The mechanics below are the same in all three cases; only the transport differs.

Sizing in this guide (a 12-month hot window, monthly partitions, seven-year retention) is illustrative. Every figure that matters is measured on your own tables during the assessment. Test each statement on a replica or staging copy before production, and keep a tested backup and DR posture on both the source and the archive throughout.

Selection

Which rows are candidates for archiving data to ClickHouse

The right candidates are found in the catalog, not in a meeting: large tables whose old rows are almost never written and mostly read by wide scans. On PostgreSQL the first pass is size and churn per table; the second pass is how much of each table lies beyond a candidate cutoff and when it was last written.

-- PostgreSQL 16+: tables ranked for archiving data to ClickHouse
SELECT
    s.relname,
    pg_size_pretty(pg_total_relation_size(s.relid))     AS total_size,
    s.n_live_tup,
    s.n_dead_tup,
    s.seq_scan,
    s.idx_scan,
    s.n_tup_upd + s.n_tup_del                            AS writes_to_existing_rows,
    s.last_autovacuum
FROM pg_stat_user_tables AS s
ORDER BY pg_total_relation_size(s.relid) DESC
LIMIT 20;

-- Staleness of one candidate against a 12-month cutoff
SELECT
    COUNT(*) FILTER (WHERE created_at < now() - INTERVAL '12 months') AS rows_beyond_cutoff,
    COUNT(*)                                                           AS rows_total,
    MAX(updated_at) FILTER (WHERE created_at < now() - INTERVAL '12 months') AS last_write_beyond_cutoff
FROM orders;
-- MySQL 8.4 LTS: the same two questions before archiving data to ClickHouse
SELECT table_name,
       ROUND((data_length + index_length) / 1024 / 1024 / 1024, 1) AS size_gb,
       table_rows
FROM information_schema.tables
WHERE table_schema = 'shop'
ORDER BY data_length + index_length DESC
LIMIT 20;

SELECT SUM(created_at < NOW() - INTERVAL 12 MONTH) AS rows_beyond_cutoff,
       COUNT(*)                                     AS rows_total,
       MAX(CASE WHEN created_at < NOW() - INTERVAL 12 MONTH THEN updated_at END) AS last_write_beyond_cutoff
FROM orders;

A table qualifies when the rows beyond the cutoff are a large share of its size and last_write_beyond_cutoff is comfortably older than the cutoff. A table where old rows keep receiving updates is not a candidate yet; widen the window or archive with continuous CDC so the archive follows the updates. The query fingerprints that touch the table, from pg_stat_statements or performance_schema.events_statements_summary_by_digest, tell you which reads will move to the archive and therefore what the archive’s sort key must serve.

Transport

Three paths for archiving data to ClickHouse

There are three ways to move rows from a row store into ClickHouse, and the choice is decided by volume, network and whether the archive must stay current. They share the same archive DDL, the same reconciliation and the same purge; only the transport differs.

Three paths for archiving data to ClickHouse from PostgreSQL or MySQL: one-time bulk via object storage, direct pull with table functions, and continuous CDC through Debezium and Kafka
Fig. 1 — The three transports for archiving data to ClickHouse. Bulk via object storage for the first backfill, table-function pulls for recurring jobs, CDC for archives that must stay current.

Path 1 exports partition-sized chunks from a replica to Parquet on object storage and loads them with s3(). It leaves an auditable file trail, works across accounts and air gaps, and is the right way to move years of history the first time. Path 2 has ClickHouse pull rows directly over the wire with the postgresql() or mysql() table functions; it needs no intermediate files and suits recurring monthly jobs on a private network. Path 3 streams every change through Debezium and Kafka into a ReplacingMergeTree table; it is the only path whose copy stays seconds behind the source, and it is how oltp-archiver works.

Most estates use Path 1 for the backfill and Path 3 for the tail.

Physical design

Shaping the archive table before archiving data to ClickHouse

The archive table is not a copy of the source schema. Each column is mapped by type, nullability and codec, and the sort key and partitioning are derived from how the archive will be queried and purged, not from the transactional primary key. The mapping below is for a typical orders table; the rules under it are the ones we apply on every engagement.

Schema and type mapping when archiving data to ClickHouse from a PostgreSQL orders table: column types, nullability, codecs, sort key and partitioning
Fig. 2 — Source-to-archive mapping for archiving data to ClickHouse. Types tightened, Nullable made the exception, codecs chosen per column, and the sort key built from the archive’s read pattern.
-- ClickHouse 25.x / 26.x LTS: target table for archiving data to ClickHouse (engine parameters in full)
CREATE TABLE archive.orders ON CLUSTER '{cluster}'
(
    order_id     UInt64,
    customer_id  UInt64,
    tenant_id    UInt32,
    status       LowCardinality(String),
    total        Decimal(18, 2)                CODEC(ZSTD(3)),
    currency     LowCardinality(FixedString(3)),
    created_at   DateTime64(3, 'UTC')          CODEC(DoubleDelta, ZSTD(3)),
    updated_at   Nullable(DateTime64(3, 'UTC')) CODEC(DoubleDelta, ZSTD(3)),
    notes        String                        CODEC(ZSTD(6)),
    attrs        String                        CODEC(ZSTD(3)),   -- or JSON on 24.8+ once validated
    _version     UInt64,                                          -- source LSN / GTID, or export batch id
    _deleted     UInt8 DEFAULT 0
)
ENGINE = ReplicatedReplacingMergeTree(
    '/clickhouse/tables/{shard}/archive/orders',
    '{replica}',
    _version,
    _deleted
)
PARTITION BY toYYYYMM(created_at)
ORDER BY (tenant_id, customer_id, created_at, order_id)
TTL toDateTime(created_at) + INTERVAL 12 MONTH TO VOLUME 'cold',
    toDateTime(created_at) + INTERVAL 84 MONTH DELETE
SETTINGS
    index_granularity = 8192,
    storage_policy    = 'tiered';

-- Reference data becomes a dictionary, not a second archived table
CREATE DICTIONARY archive.dim_customer ON CLUSTER '{cluster}'
(
    customer_id UInt64,
    segment     String,
    country     String
)
PRIMARY KEY customer_id
SOURCE(POSTGRESQL(NAME crm_pg TABLE 'customers'))
LAYOUT(HASHED())
LIFETIME(MIN 3600 MAX 4200);

Three decisions in that DDL do most of the work. The sort key leads with the columns the archive’s queries filter on most selectively, in order of cardinality, then time, then the primary key last so duplicates collapse correctly during merges. Monthly partitions match the granularity at which the source will be purged, so a source partition and its archive partition are reconciled, detached and dropped as a unit. And ReplacingMergeTree with a version and a delete marker is used even for a one-time backfill, because it makes every reload idempotent: loading the same month twice cannot double-count.

Loading

The statements that do the archiving data to ClickHouse work

Path 1: Parquet through object storage

-- On a PostgreSQL replica: export one month as Parquet (psql + DuckDB or pg2parquet; illustrative)
\copy (SELECT * FROM orders WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01')
      TO PROGRAM 'duckdb -c "COPY (SELECT * FROM read_csv(''/dev/stdin'')) TO ''s3://${LAKE_BUCKET}/orders/2025-01.parquet'' (FORMAT PARQUET)"' CSV HEADER;

-- Archiving data to ClickHouse, path 1: load the month; the WHERE guards against a mis-named file
INSERT INTO archive.orders
SELECT order_id, customer_id, tenant_id, status, total, currency,
       created_at, updated_at, coalesce(notes, '') AS notes, coalesce(attrs, '{}') AS attrs,
       20250101 AS _version, 0 AS _deleted
FROM s3('https://${LAKE_BUCKET}.s3.${AWS_REGION}.amazonaws.com/orders/2025-01.parquet', 'Parquet')
WHERE toYYYYMM(created_at) = 202501
SETTINGS max_threads = 16, max_insert_block_size = 1048576;

Path 2: direct pull with a table function

-- Named collection keeps the credentials out of every statement
CREATE NAMED COLLECTION shop_pg AS
    host = '${PG_REPLICA_HOST}', port = 5432, database = 'shop',
    user = '${PG_RO_USER}', password = '${PG_RO_PASSWORD}';

-- Archiving data to ClickHouse, path 2: one month per run; rerunnable because the archive is ReplacingMergeTree
INSERT INTO archive.orders
SELECT order_id, customer_id, tenant_id, status, total, currency,
       created_at, updated_at, coalesce(notes, ''), coalesce(attrs::String, '{}'),
       toUnixTimestamp(now()) AS _version, 0 AS _deleted
FROM postgresql(shop_pg, table = 'orders')
WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01'
SETTINGS max_threads = 4;   -- bound the number of source connections

For archiving data to ClickHouse from MySQL the statement is identical with mysql(shop_my, table = 'orders'). The pull runs against a replica with a statement timeout set on the source role, and the archive job’s own SETTINGS max_threads bounds the connections it opens. Pulls larger than a few terabytes are better served by Path 1, which parallelises on the export side and leaves files to audit.

Path 3: continuous CDC

Debezium reads the logical log (pgoutput on PostgreSQL, the row-format binlog on MySQL), performs an initial snapshot and then streams changes to one Kafka topic per table. ClickHouse consumes with the Kafka engine or the official Connect sink; each event’s source position becomes _version and deletes become rows with _deleted = 1. The full connector configuration, the Kafka engine DDL and the dead-letter handling are on the ChistaDATA Fabric archival page; the archive table above is the one they write into.

Verification

Proving the archive before a source row is removed

Archiving data to ClickHouse only earns the purge of the source once the copy is proven, and proof is a protocol, not a feeling. Per partition, in order: freeze check, source fingerprint, archive fingerprint, comparison, sampled row diff, a dual-read window, detach, and only then a drop behind a retention hold and a written confirmation gate.

Reconciliation and cutover protocol for archiving data to ClickHouse: freeze check, source and archive fingerprints, comparison, sampled diff, dual-read window, detach and gated drop
Fig. 3 — The reconciliation and cutover protocol. Steps 1 to 6 cost nothing to reverse, step 7 is reversed by ATTACH, and only step 8 destroys data.
-- Archiving data to ClickHouse, proof step 2: source fingerprint per day (PostgreSQL)
SELECT date_trunc('day', created_at)::date                      AS d,
       COUNT(*)                                                  AS n,
       SUM(hashtext(order_id::text || status || total::text))    AS h
FROM orders
WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01'
GROUP BY 1 ORDER BY 1;

-- Archive fingerprint per day (ClickHouse), deduplicated view of the same month
SELECT toDate(created_at)                                       AS d,
       count()                                                   AS n,
       sum(cityHash64(toString(order_id), status, toString(total))) AS h
FROM archive.orders FINAL
WHERE toYYYYMM(created_at) = 202501 AND _deleted = 0
GROUP BY d ORDER BY d;

-- Sampled row diff: 1,000 random keys pulled from both sides and compared column by column
SELECT a.order_id, a.status = s.status AS status_ok, a.total = s.total AS total_ok,
       a.created_at = s.created_at AS created_ok
FROM archive.orders FINAL AS a
INNER JOIN postgresql(shop_pg, table = 'orders') AS s USING (order_id)
WHERE a.order_id IN (SELECT order_id FROM archive.orders WHERE toYYYYMM(created_at) = 202501 ORDER BY rand() LIMIT 1000)
  AND NOT (status_ok AND total_ok AND created_ok);   -- must return zero rows

The day-bucketed counts must match exactly; the hashes are compared within each side across runs to detect drift, since a byte-identical cross-engine hash is rarely worth the type-normalisation effort. The sampled diff catches what hashes hide: precision loss on decimals, time-zone shifts on timestamps, trailing-space differences on fixed-width strings. The dual-read window routes production reads for the range to the archive for seven days, through ChistaDATA Fabric or the application, and compares p95 and error rate against the baseline.

-- Step 7 on PostgreSQL 16+: verification BEFORE, detach, validation AFTER
SELECT COUNT(*) AS rows_in_partition, MAX(updated_at) AS last_write FROM orders_2025_01;
ALTER TABLE orders DETACH PARTITION orders_2025_01 CONCURRENTLY;
SELECT COUNT(*) FROM orders WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01';  -- expect 0
SELECT COUNT(*) FROM orders_2025_01;                                                             -- expect original count

-- Step 8, after the retention hold and an explicit confirmation gate:
-- DROP TABLE orders_2025_01;   -- CONFIRM: reconciled, sampled, dual-read clean, hold expired
-- VACUUM (VERBOSE) orders;      -- or pg_repack if the table was bloated before archival

On MySQL 8.4 the equivalent is EXCHANGE PARTITION into a holding table, the same before and after counts, and DROP TABLE of the holding table after the hold. The reconciliation table, one row per partition with source hash, archive hash, timestamps and result, is the audit evidence that the archive is complete, and it is what a regulator or an auditor is shown.

Retention

Retention after archiving data to ClickHouse: what happens over the data’s lifetime

The archive is not the end of the data’s life; it is where the retention policy is actually enforced. Fresh partitions land on local NVMe, age to object storage through a TTL move rule, and are deleted by a TTL delete rule when the retention period expires, all inside one table with one SQL surface. Legal holds and erasure requests are handled with the same primitives.

Retention lifecycle of data archived to ClickHouse: hot NVMe tier, cold object-storage tier via TTL move, TTL delete or legal hold, and how seven years of history stays queryable
Fig. 4 — Retention after archiving data to ClickHouse. Hot, cold and expiry tiers governed by TTL, with legal holds and erasure handled per partition or per entity.

A few operational rules keep archiving data to ClickHouse safe over time. TTL changes on a large archive are applied with materialize_ttl_after_modify = 0 so they take effect on background merges rather than as one enormous mutation. Where a partition lives is visible in system.parts.disk_name, and every move and delete is recorded in system.part_log, which is the retention evidence. A legal hold is a partition moved into a separate hold table or a per-partition TTL override; an erasure request is a lightweight DELETE by entity followed by a proof-of-deletion query stored with the ticket. Backups run with clickhouse-backup to a versioned bucket, and the restore is drilled quarterly with a timed RTO.

Serving

Querying seven years of history after archiving data to ClickHouse

The point of archiving data to ClickHouse rather than to a bucket is that the history stays useful. Four mechanisms make that cheap. The sparse primary index over (tenant_id, customer_id, created_at) makes one customer’s full history a contiguous run of granules. Partition pruning means a query bounded by date opens only the monthly partitions in range, and cold partitions outside it are never touched. A projection gives operational reports a second physical order inside the same table, chosen by the planner automatically. And an AggregatingMergeTree materialized view holds monthly totals so that year-over-year reports read thousands of rows rather than billions.

-- After archiving data to ClickHouse: projection for operational reporting by status and time
ALTER TABLE archive.orders ON CLUSTER '{cluster}'
    ADD PROJECTION by_status (SELECT * ORDER BY (status, created_at));
ALTER TABLE archive.orders ON CLUSTER '{cluster}' MATERIALIZE PROJECTION by_status;

-- Monthly revenue per tenant as mergeable states
CREATE MATERIALIZED VIEW archive.orders_monthly_mv ON CLUSTER '{cluster}'
ENGINE = ReplicatedAggregatingMergeTree('/clickhouse/tables/{shard}/archive/orders_monthly', '{replica}')
PARTITION BY toYear(month) ORDER BY (tenant_id, month)
AS SELECT tenant_id, toStartOfMonth(created_at) AS month,
          sumState(total) AS revenue, countState() AS orders, uniqState(customer_id) AS customers
   FROM archive.orders GROUP BY tenant_id, month;

-- Prove the plan reads what you expect
EXPLAIN indexes = 1
SELECT count(), sum(total)
FROM archive.orders
WHERE tenant_id = 42 AND created_at BETWEEN '2021-01-01' AND '2021-12-31';

Every one of these is validated against the real fingerprints from the selection phase, and its benefit recorded as read rows and read bytes in system.query_log before and after. That log is also where the archive’s own SLO lives: p95 per fingerprint, worst first, reviewed weekly.

Reference pattern

Banking: real-time analytics and compliant archive on one ClickHouse estate

Banks are the clearest case for archiving data to ClickHouse because they need both ends at once: sub-second analytics on the last few months of transactions for fraud scoring, risk monitoring and operational dashboards, and a compliant, queryable archive of every transaction for seven years or more. The pattern below is a reference design, not a client claim, and it is sized per institution during the assessment.

Hot tier

Transactions stream in through CDC within seconds; AggregatingMergeTree views keep per-account and per-merchant features current for scoring models; row policies scope every analyst to the legal entity they belong to.

Archive tier

Monthly partitions age to object storage by TTL; the reconciliation table proves every partition matched the core banking system before it was purged there; retention is enforced by TTL and evidenced from system.part_log.

Regulatory reads

An audit request for one account’s decade of activity is a sort-key range read, answered from the archive without restoring anything; column-masking views serve the same rows to research teams with identifiers removed.

Field notes

Mistakes we see when reviewing archiving data to ClickHouse

Mistake when archiving data to ClickHouseWhat goes wrongWhat we do instead
Copying the OLTP primary key as the ClickHouse sort keyEvery analytical query scans the whole table because nothing it filters on is in the keyBuild the sort key from the archive’s query fingerprints; put the PK last
Plain MergeTree for the backfillA retried load doubles a month; nobody notices until a report is wrongReplacingMergeTree with a version column from day one
Nullable everywhereExtra files per part, slower reads, and Nullable keys are not allowed in the sort keyNullable only where reads genuinely test for NULL; empty string or zero otherwise
Floats for moneySums drift by cents; reconciliation against the source never matchesDecimal(18, 2), and compare with exact equality
Dropping the source partition right after the loadNo rollback when a type mapping turns out wrong a week laterDetach, hold for the retention period, drop behind a confirmation gate
Pulling from the primary over the wireArchive job competes with production for I/O and locksPull from a replica with a statement timeout; export to Parquet for large ranges
Applying a new TTL as one mutationA multi-terabyte mutation saturates disks for hoursmaterialize_ttl_after_modify = 0; let merges apply it

FAQ

Archiving data to ClickHouse: questions we hear most often

Is archiving data to ClickHouse an ACID copy?

No. ClickHouse gives atomic inserts per block and eventual consistency across replicas, and ReplacingMergeTree collapses versions on merge; reads use FINAL or an equivalent to see a single version. That is the right guarantee for reporting and compliance reads, and the reason the system of record for writes stays the row store.

How much smaller will the archive be than the source?

It depends on the columns. Timestamps, enumerations and identifiers compress very well under DoubleDelta, LowCardinality and ZSTD; free text compresses less. We measure it on a sample of your own partitions during the assessment rather than quoting a ratio, and system.parts reports the compressed and uncompressed bytes per column once the data is in.

Can archived rows still be updated?

Yes, through the CDC path: an update on the source becomes a new version in ReplacingMergeTree and the old one is collapsed on merge. For one-time or scheduled archival, a table whose old rows still change is not a candidate; widen the hot window until it is.

Do we need ChistaDATA Fabric for archiving data to ClickHouse?

No. Everything on this page runs with open-source ClickHouse and standard tooling. Fabric adds transparent read routing, so applications keep one endpoint while historical reads move to the archive; it is the natural next step once the archive is proven, not a prerequisite.

What does ChistaDATA operate after the archive is live?

Whatever the customer chooses: 24×7 consultative support with a 15-minute Severity 1 response while your team runs it, or managed services where ChistaDATA operates upgrades, capacity, backups, DR drills and the monthly archival job end to end.

Next step

Start with the catalog queries, not the cluster

An archival assessment from ChistaDATA runs the selection queries above on your PostgreSQL or MySQL estate, ranks the candidates by bytes and staleness, designs the archive DDL against your real query fingerprints, and sets the reconciliation and purge gates in writing. If the numbers say archiving data to ClickHouse is the right move, the same engineers build it, prove it and stay on as 24×7 support or the operating team, wherever your data lives.

Further reading: postgresql() table function · ReplacingMergeTree · MergeTree TTL · PostgreSQL partitioning · ChistaDATA University

About Shiv Iyer 262 Articles
Open Source Database Systems Engineer with a deep understanding of Optimizer Internals, Performance Engineering, Scalability and Data SRE. Shiv currently is the Founder, Investor, Board Member and CEO of multiple Database Systems Infrastructure Operations companies in the Transaction Processing Computing and ColumnStores ecosystem. He is also a frequent speaker in open source software conferences globally.