Every quarter we take calls that start the same way: a data platform lead opens their Snowflake invoice, looks at the credit consumption graph, and asks what a Snowflake to ClickHouse migration would save. Sometimes the answer is “a lot.” Sometimes the honest answer is “don’t migrate that part.” This post is the framework we use to tell the difference.
To be clear about what this is not: it is not a claim that Snowflake is a bad product. Snowflake is executing well — it acquired Crunchy Data in mid-2025 and shipped Snowflake Postgres to GA in February 2026, it open-sourced pg_lake, and its platform ambitions are expanding, not shrinking. The problem is narrower and more specific: for certain workload shapes, the warehouse-credit billing model charges you for a resource pattern that ClickHouse serves for a fraction of the compute.
Why teams actually leave
The economic mismatch is structural, not a matter of discounts. We have written before about why Snowflake bills grow the way they do; this section is the short version.
Snowflake bills warehouse compute by credit consumption while a warehouse is running. That model is a fair trade for spiky, scheduled work: a warehouse spins up, crunches a transformation, suspends, and you pay for what you used. It is a bad trade for always-on, latency-sensitive query serving — customer-facing dashboards, in-product analytics, operational monitoring — where something must be awake at all times to answer queries in under a second. Now you are paying credit rates for 24×7 warehouse uptime, and auto-suspend cannot save you because suspending is exactly what you cannot do.
Concurrency makes it worse: the standard remedies for concurrent dashboard load are a larger warehouse size or multi-cluster warehouses, and both remedies are denominated in more credits. Your cost scales with how often people look at the data, not with how much data there is.
ClickHouse inverts the model. You provision compute — cloud instances, or a managed cluster — and it is cheap enough per unit that leaving it on around the clock is unremarkable. A properly designed ClickHouse cluster serves thousands of sub-second aggregation queries per second on the same hardware footprint all day. The cost of one more dashboard viewer is approximately zero. For serving-heavy workloads, you stop renting elasticity you do not use and start owning throughput you use constantly.
The second driver is latency itself. Snowflake is architected for throughput on large scans, not for p99 latency on point aggregations. ClickHouse — with MergeTree sort orders, sparse primary indexes, and materialized views — is built for exactly that, and the 26.7 release added further GROUP BY/ORDER BY/LIMIT speedups and JOIN improvements on top. We compared the two platforms’ architectures in depth in our ClickHouse vs Snowflake analysis.
The cost model, side by side
Before any migration mechanics, be precise about what actually changes economically. This table is the Snowflake to ClickHouse migration case in miniature:
| Dimension | Snowflake (warehouse credits) | ClickHouse (provisioned compute) |
|---|---|---|
| Billing unit | Credits per hour while a warehouse is running, priced by warehouse size | Instances or a managed cluster, priced per hour regardless of query count |
| Always-on serving | 24×7 credit burn; auto-suspend is defeated by the workload itself | Flat cost; the cluster is sized for peak concurrency and left on |
| Concurrency growth | Larger warehouse or multi-cluster warehouses — both denominated in more credits | More replicas; the marginal cost of one more dashboard viewer is close to zero |
| Spiky batch ELT | Excellent fit: spin up, transform, suspend, pay for what ran | Poor use of provisioned compute unless the cluster is shared with serving |
| Primary cost driver | How often people query the data | How much hardware you provision |
Neither column is “wrong.” They are optimized for different workload shapes — which is why the assessment below is done per workload, never per account.
The workload assessment: what fits and what does not
Every successful Snowflake to ClickHouse migration starts the same way: inventory your Snowflake account by workload, not by table. For each, score it on these axes.
Strong ClickHouse fits:
- Customer-facing and embedded analytics. High query rate, sub-second SLOs, repeated query shapes. This is ClickHouse’s home turf and usually the workload bleeding the most credits.
- Time-series and event analytics. Clickstreams, telemetry, logs, metrics. Insert-heavy, append-mostly, aggregated by time. MergeTree was designed for this.
- Operational dashboards with high concurrency. Hundreds of people watching the same 40 queries refresh. Materialized views pre-aggregate; concurrency becomes nearly free.
- Real-time ingestion. Kafka-fed pipelines with seconds-level freshness requirements. Snowflake’s ingestion paths improve, but ClickHouse ingests and serves in the same breath.
Poor or conditional fits — be honest here:
- Heavy ELT/transformation pipelines. If your account is dominated by dbt jobs doing wide multi-way joins, MERGE-heavy restatements, and full-table rebuilds, that is what warehouses are good at. ClickHouse joins have improved materially through 26.x, but a transformation graph written against warehouse semantics — cheap huge joins, easy UPDATE/MERGE — needs redesign, not translation. Mutations in ClickHouse are background rewrites, not cheap transactional updates.
- Mixed BI concurrency over ungoverned ad-hoc SQL. A thousand analysts firing arbitrary hand-written joins from a BI tool will find the queries that need warehouse-style memory spilling. This can absolutely work on ClickHouse — but it requires schema design and query governance, not a connection-string swap.
- Workloads leaning on warehouse platform features. Time Travel semantics, zero-copy cloning workflows, data sharing/marketplace distribution, fine-grained governance policies. ClickHouse has answers to some of these, but if they are load-bearing, they belong late in the migration or not in it at all.
The typical outcome of an honest assessment: 30–60% of credit spend sits in serving-shaped workloads that fit ClickHouse well. Move those. Leave the ELT-heavy remainder where it is, at a smaller warehouse footprint. A partial exit that halves the bill is a better project than a total exit that stalls at month nine. This is exactly how we ran the Snowflake to ClickHouse migration for one of the world’s largest ad-tech platforms — serving first, batch later or never.
The Snowflake to ClickHouse migration framework: five phases
Phase 1 — Measure
Pull query history and attribute credit consumption per workload. Identify the top serving workloads by cost and by SLO pain. Pick one with high spend, simple lineage, and a tolerant owner. Snowflake gives you the evidence natively: QUERY_ATTRIBUTION_HISTORY attributes compute credits per query (tag your workloads with QUERY_TAG first), and WAREHOUSE_METERING_HISTORY exposes what you pay for uptime versus what queries actually consumed.
-- Tag workloads at the session or connection level so attribution has something to group by
ALTER SESSION SET QUERY_TAG = 'dashboards:exec-kpi';
-- BI tools: set this in the connection's initialization SQL so every query arrives tagged-- Credit spend per workload over the last 90 days (ACCOUNT_USAGE latency: up to ~8 h)
SELECT
COALESCE(NULLIF(query_tag, ''), 'untagged') AS workload,
warehouse_name,
COUNT(*) AS queries,
ROUND(SUM(credits_attributed_compute), 2) AS credits_compute
FROM snowflake.account_usage.query_attribution_history
WHERE start_time >= DATEADD('day', -90, CURRENT_TIMESTAMP())
GROUP BY workload, warehouse_name
ORDER BY credits_compute DESC;-- Uptime cost vs attributed query cost: the gap is idle burn your dashboards force you to buy
SELECT
warehouse_name,
ROUND(SUM(credits_used_compute), 2) AS credits_total,
ROUND(SUM(credits_attributed_compute_queries), 2) AS credits_queries,
ROUND(SUM(credits_used_compute)
- SUM(credits_attributed_compute_queries), 2) AS credits_idle
FROM snowflake.account_usage.warehouse_metering_history
WHERE start_time >= DATEADD('day', -90, CURRENT_TIMESTAMP())
GROUP BY warehouse_name
ORDER BY credits_idle DESC;For always-on serving warehouses, credits_idle plus the concurrency-driven multi-cluster spend is the number the migration attacks.
Phase 2 — Model
Design ClickHouse schemas natively. Do not transliterate the warehouse DDL — choose ORDER BY keys from actual query predicates, use LowCardinality and codecs, and pre-aggregate with materialized views where dashboards repeat themselves. This phase determines whether the benchmark in Phase 3 embarrasses you or the incumbent.
CREATE TABLE analytics.events
(
event_date Date,
event_time DateTime64(3, 'UTC') CODEC(Delta, ZSTD(3)),
tenant_id LowCardinality(String),
event_type LowCardinality(String),
user_id UInt64,
properties String CODEC(ZSTD(3)),
revenue_usd Decimal(18, 4),
INDEX idx_user_id user_id TYPE bloom_filter(0.01) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_type, event_time)
SETTINGS index_granularity = 8192;Then take the load off the base table for the dashboards that repeat themselves every few seconds:
CREATE TABLE analytics.events_by_minute
(
tenant_id LowCardinality(String),
event_type LowCardinality(String),
minute DateTime('UTC'),
events UInt64,
revenue_usd Decimal(18, 4)
)
ENGINE = SummingMergeTree((events, revenue_usd))
PARTITION BY toYYYYMM(minute)
ORDER BY (tenant_id, event_type, minute);
CREATE MATERIALIZED VIEW analytics.events_by_minute_mv
TO analytics.events_by_minute
AS
SELECT
tenant_id,
event_type,
toStartOfMinute(event_time) AS minute,
count() AS events,
sum(revenue_usd) AS revenue_usd
FROM analytics.events
GROUP BY tenant_id, event_type, minute;The dashboard query now scans a table three to five orders of magnitude smaller than the event stream, which is where “concurrency becomes nearly free” comes from. As always: test in staging against production-shaped data before applying any schema to a production cluster.
Phase 3 — Dual-run
Feed both systems (CDC or shared object storage), replay production queries against ClickHouse, compare results row-for-row and latencies percentile-by-percentile. Run for weeks, not days — month-end query patterns exist. Compare exact aggregates on both sides rather than engine-specific checksums (hash functions differ between engines):
-- Snowflake side
SELECT
COUNT(*) AS row_count,
SUM(revenue_usd) AS revenue_sum,
COUNT(DISTINCT user_id) AS distinct_users
FROM analytics.events
WHERE TO_DATE(event_time) = DATEADD('day', -1, CURRENT_DATE());
-- ClickHouse side: the three numbers must match exactly
SELECT
count() AS row_count,
sum(revenue_usd) AS revenue_sum,
uniqExact(user_id) AS distinct_users
FROM analytics.events
WHERE toDate(event_time) = yesterday();Latency comparison comes straight out of ClickHouse telemetry — system.query_log keeps per-query durations, so percentiles per query shape are one aggregation away:
-- p50/p95/p99 per normalized query shape over the dual-run window
SELECT
normalized_query_hash,
count() AS executions,
round(quantile(0.50)(query_duration_ms), 1) AS p50_ms,
round(quantile(0.95)(query_duration_ms), 1) AS p95_ms,
round(quantile(0.99)(query_duration_ms), 1) AS p99_ms
FROM system.query_log
WHERE type = 'QueryFinish'
AND event_time >= now() - INTERVAL 7 DAY
GROUP BY normalized_query_hash
ORDER BY p99_ms DESC
LIMIT 20;For the replay harness itself: export the workload’s query stream from QUERY_HISTORY, normalize the literals, and drive it with clickhouse-benchmark or a small custom runner that preserves production concurrency. Replaying at production concurrency matters — a query that is fast alone can queue under fifty concurrent dashboard refreshes, and you want to find that in the dual-run, not in the cutover.
For the mechanical data-movement side of the dual-run feed — exporting Snowflake tables through S3 into ClickHouse — we published a step-by-step Snowflake to ClickHouse migration runbook.
Phase 4 — Cut over serving
Move dashboards and APIs workload by workload. Keep Snowflake read paths alive as fallback until each workload has survived a full business cycle. The rollback path is a connection-string change per workload — which is exactly why cutover happens per workload and not per platform.
Phase 5 — Decommission deliberately
Shrink warehouses as load departs. Some never reach zero. That is fine; that was the assessment working as intended.
Coexistence: Iceberg is the exit ramp
The technical development that changed this playbook: ClickHouse Apache Iceberg writes and INSERTs became production-ready in 26.2, alongside PREWHERE pushdown into Iceberg reads, broader data-lake catalog support, and (in 26.5) a query condition cache that benefits lakehouse queries.
That means a Snowflake to ClickHouse migration no longer requires a hard fork of your data. Land shared datasets in Iceberg on object storage, and let both engines work against them:
-- ClickHouse writing aged or shared data to the lakehouse tier
INSERT INTO iceberg_catalog.analytics.events_archive
SELECT * FROM events WHERE event_date < today() - 90;Snowflake reads Iceberg; so do Spark and Trino. Your transformation jobs can keep producing Iceberg tables that ClickHouse serves from, and ClickHouse can write results back for the warehouse side to consume. Nobody is trapped, and the “what if we need to go back” objection loses its force — the data was never held hostage in either engine.
Common pitfalls in a Snowflake to ClickHouse migration
The failure modes we see repeatedly, in rough order of damage done:
- ORDER BY keys chosen by intuition. The sort key must come from measured query predicates, not from what the warehouse’s clustering keys used to be. Get this wrong and every downstream benchmark is noise.
- Ungoverned BI SQL on day one. Pointing a self-service BI tool with a thousand ad-hoc users at a freshly modeled cluster reintroduces exactly the query chaos the schema design was meant to remove. Govern the query surface first, widen it later.
- Dual-run windows that skip month-end. The queries that break are the ones that only run four times a year. Weeks of dual-running, spanning at least one business-cycle boundary, is the minimum honest window.
- Treating mutations as cheap. UPDATE and DELETE in ClickHouse are background part rewrites. A workload that restates yesterday’s rows every hour needs a ReplacingMergeTree or CollapsingMergeTree design, not a habit carried over from warehouse MERGE.
- Decommissioning on a schedule instead of on evidence. Warehouses shrink when workloads have demonstrably left and survived a full cycle — not when the project plan says so.
Snowflake to ClickHouse migration FAQ
How long does a Snowflake to ClickHouse migration take?
For a single serving workload with clean lineage: the measure/model/dual-run phases typically span one to two quarters, dominated by the dual-run soak time rather than engineering effort. Whole-account timelines are workload-count-dependent — which is the point of migrating per workload instead of per platform.
Do we have to leave Snowflake entirely?
No, and most teams should not. In a typical Snowflake to ClickHouse migration the honest assessment routes 30–60% of credit spend — the always-on serving share — to ClickHouse and leaves batch ELT on a smaller warehouse footprint, with Iceberg as the shared tier between them.
What breaks most often in a Snowflake to ClickHouse migration?
Transliterated schemas. Warehouse DDL copied verbatim — no thought given to ORDER BY keys, no materialized views, JOIN-heavy query shapes preserved — produces a benchmark that flatters the incumbent. Native modeling in Phase 2 is where the project is won or lost.
Should the ClickHouse side be self-managed or managed?
Decide on operational capacity, not ideology. Self-managed ClickHouse on cloud instances gives the lowest unit cost and full control over MergeTree tuning, but the migration now also carries Keeper quorum design, upgrade discipline, backup and restore drills, and 24×7 on-call. A managed platform trades some unit cost for removing that operational surface during exactly the period when the team is busiest — the dual-run window.
A common pattern in a Snowflake to ClickHouse migration is to dual-run on a managed cluster to move fast, then revisit the hosting decision once workloads are stable and the operational profile is understood. Whichever direction you choose, keep the schema and ingestion design portable — 100% open-source ClickHouse, no proprietary extensions — so the hosting decision stays reversible later.
Which tools move the data?
Bulk backfill: COPY INTO object storage as Parquet, then load through ClickHouse’s S3 functions or land it as Iceberg and read in place. Continuous feed: Kafka or Debezium CDC into ClickHouse for the dual-run window. The Iceberg tier increasingly replaces bespoke pipelines for the shared datasets — write once, both engines read.
The bottom line
The Snowflake exit worth doing is rarely total. A well-run Snowflake to ClickHouse migration is the disciplined removal of always-on serving workloads from a billing model designed for bursty batch work — executed in phases, validated by dual-running, and de-risked by an Iceberg coexistence layer that both engines now speak natively. Teams that do the workload assessment honestly get the cost reduction and keep their credibility. Teams that promise a wholesale lift-and-shift usually get neither.
ChistaDATA has run this playbook end to end — workload assessment, schema redesign, dual-run validation, and cutover — backed by full-stack ClickHouse consulting, 24×7 enterprise support, and managed services. If you are scoping a Snowflake to ClickHouse migration, see our migration services or talk to us.