One INSERT, 2.8M rows, no ETL
We copied one tenant's data from a MySQL read replica into ClickHouse with a single INSERT ... SELECT through the mysql() table function. The hard parts were a mistyped column and ClickHouse having no unique keys.
- Date
- 2026-09-15
- Type
- build
- Impact
- 2,794,333 contact rows for one tenant were exported into ClickHouse, and a second table was re-synced to close a 15,032-row gap, with no intermediate files or pipeline code.
We needed one tenant’s HubSpot contact data in ClickHouse for migration testing: 2,794,333 rows. The obvious plan was a Python script that dumps to CSV, chunks the file and loads it. We didn’t need any of that.
What we built
A repeatable export that’s one SQL statement plus a runbook. ClickHouse can read a
MySQL table directly with the mysql() table function. The ClickHouse EC2 instance
could already reach the RDS read replica over the network, so the whole copy runs
inside ClickHouse:
INSERT INTO hs_contact_split (org_id, segment, overwritten, is_marketing, ...)
SELECT toString(org_id), toString(segment),
ifNull(overwritten, 0), ifNull(is_marketing, 0), ...
FROM mysql('<replica>:3306', '<db>', 'hs_contact_split', '<ro_user>', '<password>')
WHERE org_id = <tenant>
How it works
- Tunnel. An SSM port-forward to the ClickHouse HTTP interface. No bastion, and no public port.
- Find the table. Most tenant tables are in the central database on the replica. Some tenant-specific databases aren’t replicated at all.
- Diff the schemas.
SHOW COLUMNSon MySQL,DESCRIBE TABLEon ClickHouse. The target table already existed from earlier work and wasn’t a mirror:org_idwasNullable(String), not a 64-bit int, soWHERE org_id = <number>failed with error 386 (There is no supertype for types String, UInt32).segmentwas also a string, two UInt8 columns were non-nullable where MySQL allows NULL, and a few columns existed on only one side. Hence the casts above. Only useSELECT *when the columns match 1:1. - Run it. As a background job with a padded
max_execution_time. Observed rate: about 2 minutes per 250K rows. - Verify with counts only.
count()on both sides. We never pull rows to “check”. - Refresh by delete-then-reinsert. MergeTree doesn’t enforce uniqueness, so
re-running the INSERT silently duplicates every row. For the web-tracking table that
meant
ALTER TABLE ... DELETE WHERE org_id = X SETTINGS mutations_sync = 1, then the full insert again, which closed a 15,032-row gap since the previous export.
The runbook also keeps a table of which tables and tenants have been exported, so nobody has to piece it together from chat history again.
What didn’t work
Trusting the app’s config. .env pointed at a migration-test database, but the data
was actually in default. We also assumed the ClickHouse table matched MySQL because
it had the same name. It didn’t.
Try the table function before writing ETL. For a one-off copy, it’s often all you need.
← all writeups