Migrating from PostgreSQL to ClickHouse: Offloading Analytics Without Losing Your System of Record
Migrating from PostgreSQL to ClickHouse
This migration is different from the others in this series. Almost nobody replaces PostgreSQL with ClickHouse -- they offload to it. PostgreSQL stays as the transactional system of record; ClickHouse takes over the analytical queries that were melting your read replicas. ClickHouse itself pushes this "Postgres for OLTP, ClickHouse for OLAP" pairing as the default architecture, and it's the honest framing: ClickHouse has no row-level locking for transactional workloads, updates and deletes are asynchronous background mutations, and single-row lookups are not what MergeTree is built for.
So the first question isn't "how" but "should you": if your dashboards run fine on PostgreSQL with good indexes and a few materialized views, stay. Move when aggregation scans over tens of millions of rows dominate query time, when materialized view refreshes can't keep up, or when BI traffic is degrading your OLTP latency. For the underlying engine differences, see PostgreSQL vs ClickHouse.
The Three Realistic Paths
1. One-time copy with the postgresql() table function
Built into ClickHouse, no extra infrastructure. Create the target table, then pull:
CREATE TABLE events
(
id UInt64,
user_id UInt64,
event_type LowCardinality(String),
payload String,
created_at DateTime64(6, 'UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (event_type, created_at);
INSERT INTO events
SELECT id, user_id, event_type, payload, created_at
FROM postgresql('pg-host:5432', 'appdb', 'events', 'readonly_user', 'secret');This streams the full table over one connection. It's the right tool for backfills, snapshots, and datasets that fit an overnight window. For repeated incremental pulls (e.g. WHERE created_at > ... on a schedule), the PostgreSQL table engine gives you a persistent proxy table to select from.
2. Continuous replication with CDC
For a living pipeline -- PostgreSQL keeps taking writes, ClickHouse stays current -- you want change data capture from the WAL:
- ClickPipes Postgres CDC (ClickHouse Cloud): generally available as of 2026. This is PeerDB, which ClickHouse acquired in 2024, integrated into Cloud. Parallel initial snapshot, then continuous WAL streaming. A few clicks if you're on Cloud.
- Self-hosted PeerDB (open source, v0.37.1 as of July 2026): the same engine, run via Docker Compose against your own ClickHouse. Source setup is standard logical replication:
wal_level = logical, a publication, and a replication slot. Mind the slot if the pipeline stops -- an abandoned replication slot makes PostgreSQL retain WAL indefinitely.
Both land rows in ReplacingMergeTree tables with metadata columns (_peerdb_version, _peerdb_is_deleted). That has a query-side consequence covered under Validation below.
3. MaterializedPostgreSQL -- know it exists, don't build on it
ClickHouse ships a MaterializedPostgreSQL database engine that consumes logical replication directly. It is still flagged experimental (requires allow_experimental_database_materialized_postgresql = 1) and has a history of edge-case issues. Fine for a lab; use PeerDB/ClickPipes for anything you'd page someone about.
Data Type Mapping
| PostgreSQL | ClickHouse | Notes |
|---|---|---|
smallint / integer / bigint | Int16 / Int32 / Int64 | Direct. serial is just an integer on the target -- ClickHouse has no auto-increment |
numeric(p,s) | Decimal(p,s) | Up to precision 76 (Decimal256). Unconstrained numeric has no direct equivalent -- pick a precision or use String for exactness |
text / varchar(n) | String | No length limit; add LowCardinality(String) for repetitive values (< ~10k distinct) |
boolean | Bool | Stored as UInt8 |
timestamptz / timestamp | DateTime64(6, 'UTC') | Both are fine: timestamptz is UTC internally in PostgreSQL, so nothing is lost. Session-level rendering differs |
date | Date32 | Plain Date only covers 1970–2149; Date32 covers 1900–2299 |
uuid | UUID | Direct |
jsonb | String or native JSON | Native JSON type is production-ready since 24.8; String + JSONExtract* is the conservative path. See JSON queries in ClickHouse |
text[], integer[] etc. | Array(String), Array(Int32) | A genuine match -- arrays are first-class in both. See ClickHouse arrays |
inet | IPv4 / IPv6 | Direct |
enum type | Enum8 / LowCardinality(String) | LowCardinality is easier to evolve -- adding an Enum value is an ALTER |
Two blanket rules. First, resist Nullable(T) everywhere: it costs a separate bitmap per column and blocks some optimizations. If NULL doesn't carry meaning, use the type's default (empty string, 0) instead. Second, right-size integers -- see choosing data types.
The Schema Redesign Is the Real Work
Copying columns is mechanical. What actually needs thought:
ORDER BYis a sort key, not a primary key. ClickHouse does not enforce uniqueness -- inserting the sameidtwice gives you two rows. DesignORDER BYfor your query filters (low-cardinality first, then time), not for identity. If you need upsert semantics, that'sReplacingMergeTreewith eventual deduplication -- see MergeTree engines.- Foreign keys don't exist. Denormalize into wide tables or use dictionaries for lookup joins.
- Updates and deletes change shape.
ALTER TABLE ... UPDATErewrites whole parts asynchronously. If rows change often in PostgreSQL, replicate via CDC intoReplacingMergeTreerather than trying to mutate -- see updating and deleting data. - Batch your inserts. Per-row inserts create one part each and you will hit
TOO_MANY_PARTSwithin hours. Thousands of rows per insert, orasync_insert = 1.
Query Changes
| PostgreSQL | ClickHouse |
|---|---|
unnest(arr) | arrayJoin(arr) or ARRAY JOIN |
percentile_cont(0.95) WITHIN GROUP | quantile(0.95)(col) |
DISTINCT ON (user_id) ... ORDER BY | LIMIT 1 BY user_id |
string_agg(x, ',') | arrayStringConcat(groupArray(x), ',') |
generate_series(1, 100) | numbers(1, 100) |
count(*) FILTER (WHERE ...) | countIf(...) |
| Correlated subqueries | Mostly unsupported -- rewrite as JOIN or window function |
ILIKE, || concatenation, and CTEs work as expected (CTEs are inlined by default, not materialized -- see ClickHouse CTEs).
Validation
Run the same aggregates on both sides before trusting the new system:
-- Both engines
SELECT count(*), sum(amount), min(created_at), max(created_at) FROM events;One trap specific to CDC pipelines: tables fed by PeerDB/ClickPipes are ReplacingMergeTree, and deduplication happens at merge time, not insert time. A bare count(*) can be higher than PostgreSQL's until merges settle. Compare with FINAL:
SELECT count(*) FROM events FINAL;Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Expecting ORDER BY to enforce uniqueness | Silent duplicate rows | ReplacingMergeTree + FINAL, or dedupe upstream |
| Row-by-row inserts | TOO_MANY_PARTS errors | Batch inserts or async_insert |
Nullable on every column | Wasted storage, slower scans | Defaults for meaningless NULLs |
Counting CDC tables without FINAL | Phantom row-count mismatches | count(*) ... FINAL during validation |
| Abandoned replication slot after a failed pipeline | PostgreSQL disk fills with retained WAL | Drop unused slots promptly |
| Moving the OLTP workload too | Latency regressions, mutation pileups | Keep PostgreSQL as system of record |
Re-pointing dashboards and services: the ClickHouse connection guides cover drivers per language.
Mako connects to PostgreSQL and ClickHouse side-by-side with AI-powered autocomplete -- useful for running validation queries against both ends of a migration. Try it free at mako.ai.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.