Migrating from MySQL to ClickHouse: Analytics Offload, CDC Options, and the MaterializedMySQL Trap
Migrating from MySQL to ClickHouse
Like the PostgreSQL version of this guide, this is an offload story, not a replacement story. MySQL stays as the transactional system of record; ClickHouse takes the analytical queries -- the GROUP BY scans over hundreds of millions of rows that InnoDB's row-oriented storage was never going to be fast at. ClickHouse has no transactional guarantees to speak of, mutations are asynchronous, and single-row lookups by primary key are exactly what it's worst at. Keep MySQL for the app.
The typical trigger: reporting queries that take minutes on MySQL despite proper indexes, read replicas dedicated entirely to BI traffic, or nightly aggregation jobs that no longer fit in the night. If that's not you yet, SUMMARY tables and covering indexes on MySQL are cheaper than a second database. For the engine-level comparison, ClickHouse's columnar storage and vectorized execution are covered in our MergeTree engines guide.
First, the trap: MaterializedMySQL is gone
If you find tutorials recommending the MaterializedMySQL database engine for real-time MySQL replication -- and you will, it was widely blogged about -- know that ClickHouse removed it. The feature was experimental for years, its documentation was pulled in late 2024, and the remaining code was stripped out of ClickHouse in mid-2025. Any guide built on it describes software that no longer exists in current releases. The replacement paths are below.
The Three Realistic Paths
1. One-time copy with the mysql() table function
Built in, zero infrastructure. Create the MergeTree target, then pull:
CREATE TABLE orders
(
id UInt64,
customer_id UInt64,
status LowCardinality(String),
total Decimal(12, 2),
created_at DateTime('UTC')
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (status, created_at);
INSERT INTO orders
SELECT id, customer_id, status, total, created_at
FROM mysql('mysql-host:3306', 'appdb', 'orders', 'readonly_user', 'secret');Right for backfills and snapshot-style reporting where "as of last night" is acceptable. For repeated pulls, the MySQL table engine gives you a persistent proxy table; just remember every SELECT against it hits MySQL live.
2. Continuous replication with CDC
For a pipeline that keeps ClickHouse current while MySQL takes writes, you read the binlog:
- ClickPipes MySQL CDC (ClickHouse Cloud): in public beta as of mid-2026 -- unlike the Postgres connector, which is GA, so weigh that for production. Parallel initial snapshot, then continuous binlog streaming; also works against MariaDB. Requirements are standard CDC hygiene:
binlog_format = ROWand a sufficientbinlog_expire_logs_secondsso a paused pipe doesn't lose its place. - Altinity Sink Connector (open source, v2.9.1 as of April 2026, actively maintained): Debezium-based, self-hosted, replicates MySQL (and Postgres/MongoDB) into ClickHouse. The mature choice if you're not on ClickHouse Cloud.
Both approaches land data in ReplacingMergeTree tables where deletes become flagged versions rather than removals -- which affects how you count rows (see Validation).
3. Dump and load
mysqldump --tab (TSV per table) or SELECT ... INTO OUTFILE, then clickhouse-client --query "INSERT INTO t FORMAT TabSeparated" < t.tsv. Crude, scriptable, and fine for a one-way trip when the mysql() function can't reach the source network.
Data Type Mapping
The one MySQL-specific wrinkle other sources gloss over: unsigned integers, which MySQL has and most targets don't. ClickHouse is actually the friendly case here -- it has native unsigned types.
| MySQL | ClickHouse | Notes |
|---|---|---|
TINYINT / SMALLINT / INT / BIGINT | Int8 / Int16 / Int32 / Int64 | Direct |
... UNSIGNED | UInt8 / UInt16 / UInt32 / UInt64 | A genuine match -- no widening workaround needed, unlike PostgreSQL migrations |
TINYINT(1) (boolean by convention) | Bool | Nothing marks it as boolean in MySQL metadata -- identify these columns yourself |
DECIMAL(p,s) | Decimal(p,s) | Direct, up to precision 76 |
VARCHAR / TEXT | String | LowCardinality(String) for < ~10k distinct values |
DATETIME / TIMESTAMP | DateTime('UTC') / DateTime64 | TIMESTAMP is UTC-converted by MySQL, DATETIME is zone-naive -- decide what zone your DATETIMEs actually hold before, not after |
DATE | Date32 | Date only spans 1970–2149; MySQL zero dates 0000-00-00 must be mapped to NULL or a sentinel first |
ENUM('a','b') | Enum8 / LowCardinality(String) | LowCardinality avoids ALTERs when values are added |
JSON | String or native JSON | Native JSON production-ready since 24.8; see JSON queries |
BLOB / VARBINARY | String | ClickHouse Strings are byte-safe |
SET | Array(String) | No direct equivalent; arrays are the honest model |
Same blanket rules as any ClickHouse target: don't wrap everything in Nullable (bitmap cost, blocked optimizations), and right-size integers -- see choosing data types.
The Schema Redesign Is the Real Work
ORDER BYis a sort key, not a PRIMARY KEY. No uniqueness enforcement -- the sameidinserted twice is two rows. Design the key for query filters (low-cardinality columns first, then time), not identity.AUTO_INCREMENTdoesn't exist. Keep generating ids in MySQL and replicate them; ClickHouse is the destination, not the allocator.- Foreign keys and JOIN-heavy schemas. Denormalize into wide fact tables, or use dictionaries as the lookup-join replacement.
- Frequent updates. InnoDB update patterns don't translate;
ALTER TABLE ... UPDATErewrites whole parts in the background. CDC intoReplacingMergeTreehandles mutable rows; see updating and deleting data. - Insert batching. One part per insert means per-row inserts hit
TOO_MANY_PARTSfast. Batch thousands of rows or enableasync_insert.
Query Changes
| MySQL | ClickHouse |
|---|---|
GROUP_CONCAT(x) | arrayStringConcat(groupArray(x), ',') |
DATE_FORMAT(d, '%Y-%m') | formatDateTime(d, '%Y-%m') or toYYYYMM(d) |
IFNULL(a, b) | ifNull(a, b) (case-sensitive function names) |
LIMIT n OFFSET m | Same, plus LIMIT n BY col for per-group limits |
INSERT ... ON DUPLICATE KEY UPDATE | No upsert -- ReplacingMergeTree versioning instead |
| Implicit string↔number casts | Mostly errors -- cast explicitly |
utf8mb4_general_ci case-insensitive compares | Comparisons are case-sensitive; use lower() or ILIKE |
Window functions, CTEs, and standard aggregates carry over -- see the ClickHouse window functions and CTE guides for the dialect details (RANGE frame defaults and CTE inlining behave differently than you'd guess).
Validation
-- Run on both engines
SELECT count(*), sum(total), min(created_at), max(created_at) FROM orders;CDC caveat: ReplacingMergeTree deduplicates at merge time, so a bare count(*) on ClickHouse can exceed MySQL's until merges settle, and CDC deletes are flag columns, not removals. Validate with FINAL and the connector's delete flag filtered out. CRC32-style row checksums don't transfer across engines -- stick to aggregate comparisons per column.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Building on MaterializedMySQL tutorials | Feature no longer exists in current ClickHouse | ClickPipes or Altinity Sink Connector |
| Expecting PRIMARY KEY uniqueness | Silent duplicates | ReplacingMergeTree + FINAL, or dedupe upstream |
| Row-by-row inserts | TOO_MANY_PARTS | Batch or async_insert |
Ignoring zero dates (0000-00-00) | Insert errors or garbage sentinel values | Clean to NULL before transfer |
TINYINT(1) copied as Int8 | Booleans become opaque numbers | Map to Bool explicitly |
| Short binlog retention with CDC | Paused pipe loses its position, full resync | Size binlog_expire_logs_seconds generously |
Re-pointing dashboards: the ClickHouse connection guides cover drivers per language, and the port-8123-vs-9000 trap that catches everyone.
Mako connects to MySQL 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.