From Postgres to ClickHouse without changing a query

Every analytics product eventually meets the same table. Pageviews, clicks, scroll depth, vitals and errors arrive at a rate of dozens per session, they are never updated, and every chart scans a month of them. Sessions, people, dashboards, reports and the AI’s conversations are small by comparison. The architecture question is almost entirely about where that one table lives.

InsightClickIQ’s answer is: in Postgres for as long as Postgres is comfortable, and in ClickHouse after that — with the move done by setting environment variables rather than by rewriting the product. This post covers both halves: what makes Postgres last, how the ClickHouse store is wired, and what happened when we ran the same queries against both.

How big is the table, really?

Measured on the demo data: an event costs about 0.9 KB in Postgres with its indexes (271k events took 246 MB), and sessions and people add roughly another 10%. Replay bodies, the largest bytes in the system, never touch the database — they are gzipped rrweb JSON in an object store. That makes sizing a matter of arithmetic.

TrafficEvents / monthPostgres growth13 months keptWhat fits
Internal app, demo, small site0.1 – 2 M0.1 – 2 GB1 – 25 GBOne small Postgres (2 vCPU / 8 GB)
Mid-size product or store2 – 30 M2 – 27 GB25 – 350 GBPostgres with monthly partitions and a read replica (4–8 vCPU / 32 GB)
Large site, many tenants30 – 300 M27 – 270 GB0.3 – 3.5 TBPostgres works to about 100 M a month; beyond that 30-day scans get slow — ClickHouse for events
Very large, enterprise multi-tenant> 300 M> 270 GBmulti-TBClickHouse for events; Postgres for sessions, people and content

Most deployments sit in the first two rows and should stay on Postgres. The rest of this post explains why that is a comfortable place to be, and what the exit looks like when it is not.

Making Postgres last

Monthly partitions

The event table is partitioned by month on its timestamp: event_2026_09 covers September in UTC, event_2026_10 October, and a default partition catches a timestamp outside every month. A daily background round creates partitions two months ahead, so ingest never waits on DDL. A 30-day chart touches one or two partitions; vacuum stays cheap because each partition carries its own autovacuum setting (scale factor 0.02).

The partitioning came with an index diet. The ten indexes the table used to carry cost about 45% of its bytes; it now keeps the ones every query uses — project and time, project and session, project and event name, project and user, project and anonymous id, a BRIN on time — and makes the page-type index and the error-fingerprint index partial, so they only index the rows they serve.

Retention that drops, rather than deletes

Each project has a retention_days. A daily round, at a quiet local hour (3 a.m. by default), works out each project’s cutoff. A month partition no project still needs a row from is dropped — instant, and no bloat. Anything else is deleted in 50,000-row batches, one short transaction each. The same round removes the replay chunks and their objects, sessions that ended before the cutoff, people not seen since, insight-cache rows older than 30 days and dismissed signals older than 90.

Erasure uses the same machinery: DELETE /api/people/{id} removes a person, every id they were seen under, their sessions, events and replay objects.

A read replica for the console

When ingest and dashboards start competing, set CLICKIQ_READ_DATABASE_URL. The read-heavy routes — queries, the overview, journeys, heatmaps, forms, errors, performance, events — move to the replica, pinned to the caller’s organization exactly as on the primary. Unset, the replica is simply the primary. The role defaults matter too: a 30-second statement timeout and 64 MB of work_mem for the API role, and PgBouncer in transaction mode is safe because the tenant is set per transaction.

The switch: one query module, two dialects

Two decisions made early are what make the move cheap. First, nothing in the product writes SQL against the event table except one module. Every chart, widget, alert, AI answer and MCP tool compiles a QuerySpec through app/services/query. Second, that module writes each statement once and asks a dialect for the parts that differ between engines.

The configurable event store Ingest inserts every batch into Postgres, folds sessions and people, commits, and then hands the same rows with the same ids to ClickHouse through a bounded queue. The query module compiles each QuerySpec once and asks a dialect for the parts that differ. CLICKIQ_EVENT_STORE decides which store the ported reads use; Postgres-only reads stay on Postgres. Below, the five steps of a migration. Ingest POST /api/ingest/batch web and native SDKs ≤ 500 events per batch Postgres — always there event, partitioned by month event_2026_09 · event_2026_10 · … sessions and people folded row-level security per organization retention: drop whole partitions, else delete in 50k-row batches optional read replica for the console same rows, same ids queue 10,000 rows oldest drop never fails ClickHouse clickiq_event ReplacingMergeTree ORDER BY (project_id, ts, id) PARTITION toYYYYMM TTL 400 days no RLS: org bound on every statement One query module, two dialects Builders write each statement once and ask the dialect for what differs: count_distinct → COUNT(DISTINCT) | uniqExact percentile → percentile_cont | quantileExactInclusive json_text → props #>> | JSONExtractString bucket → date_trunc | toStartOfInterval CLICKIQ_EVENT_STORE postgres (default) or clickhouse a statement with no ClickHouse text: NotPorted A migration, in order — configuration, not code 1 Run ClickHouse Cloud, or the compose profile; set URL and secret ref 2 Shadow-write DUAL_WRITE=true on the Postgres backend 3 Backfill id order, skips ids already there; re-runnable 4 Flip the store EVENT_STORE= clickhouse 5 Keep dual-write on sessions, people, replay and Postgres-only reads still need the rows
Postgres stays in the picture whichever store serves the charts: it folds sessions and people and answers the reads that have not been ported. ClickHouse receives the same rows with the same ids through a bounded queue that never fails an ingest request.

The dialect is a small class with one method per difference: the table name; how to read a JSON property (props #>> … against JSONExtractString); a distinct count (uniqExact); a percentile (percentile_cont against quantileExactInclusive — both interpolate, so a p75 agrees); a filtered aggregate; a time bucket in the project’s time zone; case-insensitive match; regex; an IN list; the first event of a session. Placeholders follow the engine — :p0 for Postgres, server-side {p0:String} for ClickHouse — so no value is ever spliced into a statement on either.

A handful of hand-written statements (quality, forms, signals, the digest, zones) carry one text per engine. A statement with no ClickHouse text raises NotPorted rather than quietly running the wrong SQL. That is the important design choice: a gap in the port is an error you see in testing, not a wrong number on a dashboard.

Runs on the configured storeStays on Postgres (needs dual-write)
Every POST /api/query kind: trends, metric, table, funnel, retention, paths, webProperty and trait discovery in the catalogue (jsonb_each)
The overview; event names and valuesSession timelines; the raw events page; the live feed
AI drivers; insight event mix; signals’ series, sources, frustration, errors, performance, order valueSignals’ impact joins; insights.facts_*
Journeys (patterns, compare, drop-off, top errors)The zones element table (window functions and the session join)
Errors list and counts, performance, every forms statementError samples; heatmap snapshots and replay
Zones scroll depths; heatmap page list and heat aggregatesThe identify back-fill of user_id; per-project counts

Tenancy without row-level security

On Postgres, tenancy has two lines: every route scopes its queries, and row-level security on the organization is the second line that catches a mistake. ClickHouse has no row-level security, so the second line is rebuilt in code. The query builder always binds organization = {org} and the project; every hand-written ClickHouse text does the same; and the ClickHouse dialect’s execute refuses a statement without an organization predicate. A test asserts the predicate on every generated statement. On top of that, give the console a read-only ClickHouse user (GRANT SELECT) and give ingest a separate writer through CLICKIQ_CLICKHOUSE_WRITE_URL.

Person traits on ClickHouse

Breakdowns like user.manager read identify traits, which live in Postgres. The ClickHouse dialect reads the distinct-id → value map for the organization once per statement (cached for a minute) and binds it as two arrays for transform(), so every anonymous id an identified person used maps to the same value. The limit: the 50,000 most recently seen people per trait; others read as empty.

Dual-write and backfill

Ingest inserts each batch into Postgres, folds sessions and people, commits, and then hands the same rows — same ids — to ClickHouse. A ClickHouse failure never fails the request: it is logged and counted, and the rows wait in a bounded in-memory queue of 10,000 (oldest drop first) that the next batch drains ahead of itself. The readiness endpoint shows the counters — inserted, batches, failed batches, queued, dropped, last error — so a lagging store is visible.

The ClickHouse table is a ReplacingMergeTree ordered by (project_id, ts, id), partitioned by month, with a TTL of 400 days by default, so a retried batch or a repeated backfill converges. The backfill script reads Postgres in id order, asks ClickHouse which ids of each batch already exist, and inserts the rest. It is re-runnable and interruptible: locally it copied 271k rows in 10 seconds, and a second run copied none.

A migration
  1. 1
    Run ClickHouse

    ClickHouse Cloud on AWS, Azure or GCP, self-managed on OCI or on-premises, or the compose profile on a laptop. Set the URL and the password as a secret reference.

  2. 2
    Shadow-write

    CLICKIQ_EVENT_DUAL_WRITE=true while the backend is still Postgres. New events now land in both.

  3. 3
    Backfill

    python scripts/backfill_clickhouse.py --project all --since 2026-01-01. Run it twice if you like; the second run copies nothing.

  4. 4
    Flip

    CLICKIQ_EVENT_STORE=clickhouse. /api/health/config shows the backend, URL without password, table, TTL and dual-write.

  5. 5
    Keep dual-write on

    Sessions, people, replay and the Postgres-only reads still need the rows.

The same queries on both engines

The architecture document records that the same specs answer identically on a Postgres-backed and a ClickHouse-backed process, with ClickHouse 1.3–17× faster per statement on 270k rows, and the user.manager breakdown going from 957 ms to 54 ms. For this post we ran the comparison again ourselves: two API processes on one laptop, one on the Postgres store and one on a local ClickHouse 24.8 holding the same 270,893 rows, AI switched off on both, a fixed 30-day window. Figures are the engine’s own sql_ms, median of five runs after a warm-up; the last rows are whole endpoints timed from the client.

Query (Northwind or Insurance 360 demo)PostgresClickHouseSame answer?
Trends: pageviews by device, daily68 ms30 msIdentical
Metric: visitors with the previous period34 ms18 msIdentical
Funnel: product → cart → checkout → order31 ms118 msIdentical
Retention: signed up → order, weekly4 ms29 msIdentical
Paths from /, depth 4126 ms121 msIdentical
Web stat: top pages109 ms22 msIdentical
Table: Module viewed by props.module26 ms24 msIdentical
Table: users by user.manager (person trait)1,793 ms45 msIdentical
Overview endpoint, 30 days828 ms173 msIdentical
Errors / performance / forms endpoints10 / 41 / 18 ms14 / 25 / 13 msIdentical
Journeys: patterns / converted vs dropped~0.5 s / ~0.35 s~0.6 s / ~0.4 sIdentical
Heatmap page list (100 pages)128 ms37 msSame counts; ties at the cut-off ordered differently

Three things are worth reading out of that table, and none of them is “ClickHouse is faster”.

  • Parity held everywhere. Every result was byte-for-byte the same once timings were removed. The only difference was the order of single-view pages tied at the bottom of a 100-row list, where neither engine promises an order.
  • At this size Postgres is not the problem. Almost everything answers in tens of milliseconds on both. The funnel and retention are slower on ClickHouse here, because the port deliberately keeps the same self-join shape on both engines so the answers agree; the ClickHouse-native forms (windowFunnel and friends) are a later optimisation.
  • The person-trait breakdown is the outlier. Joining every event to the person’s traits under row-level security is the one query that hurts on Postgres — 1.8 s here, 2.3 s in the console screenshot below — and the one the trait-map approach on ClickHouse turns into 45 ms. It also grows with rows, which is why it is the first thing a large tenant feels.
Analyze · users by manager
Analyze table view on the Insurance 360 demo: unique users by user manager — Priya Natarajan 25, Elena Vasquez 22, Daniel Okafor 20, Mei-Lin Chen 20, Graham Whitfield 18, Liam O'Connell 15 — ran in 2276 ms
Look at “ran in 2276 ms”. A breakdown by an identify trait on Postgres: correct, and the slowest query in the product at demo scale. On ClickHouse the same spec, same answer, took 45 ms.
Analyze · checkout funnel
Analyze funnel on Northwind: product viewed 4,065, added to cart 1,470 (36.2%), checkout started 825 (56.1% from previous), order completed 524 (63.5% from previous), 12.9% overall, ran in 111 ms
The same funnel spec on either engine. 4,065 → 1,470 → 825 → 524, 12.9% overall. The console, the AI and the MCP tools never know which store answered.
0.9 KBper event in Postgres, with indexes
~100 Mevents a month before Postgres strains
12comparisons, the same numbers on both
1.8 s → 45 msperson-trait breakdown

When to move

Do not move because ClickHouse is fashionable. Move when one of these is true:

  1. You are past about 100 million events a month, or will be within the retention window, and 30-day console queries are scanning more than 100 million rows.
  2. Trait breakdowns are central to how you report — by manager, team, branch, plan — and they are getting slow.
  3. You run many tenants on one deployment and want the event scans off the database that holds everyone’s state.

Before that, the cheaper wins are in Postgres: the partitions and retention already shipped, a read replica, and the daily rollups that are the next storage item on the list. Those are engine-independent and are needed before the ClickHouse step anyway.

WHAT A PLATFORM MIGRATION USUALLY ISA rewrite of every report

New SQL for every chart, months of side-by-side checking, and a cut-over weekend.

WHAT THIS ISFive environment variables and a backfill

The same QuerySpecs compile to the other engine, answers are checked identical, and Postgres keeps everything that is not the event table.

Keep the events where they are cheapest to keep, and keep every question the same wherever they live.

Running it on AWS →

← All posts   Request a demo