We migrated 425M rows across 43 tables from a CPU-saturating QuestDB deployment to ClickHouse in 6.5 days with zero data loss.
425M+
Rows migrated
6.5 days
Migration wall time
0 rows
Data loss confirmed
2,810
Garbage partitions eliminated
CHAPTER 01
On the night of April 23, 2026, we discovered that QuestDB's WAL implementation had accumulated 2,810 garbage partitions in the bars_1m table containing rows with timestamps ranging from year 2027 to year 2262. These were produced by an integer overflow bug in an early version of the Rust downloader that converted millisecond timestamps to nanoseconds without bounds checking. The overflow wrapped negative numbers into astronomically large positive values, which QuestDB obediently assigned to partitions named after the overflow year.
The symptom was 10-core CPU saturation every time QuestDB restarted. The service manager would start QuestDB, it would attempt to compact 2,810+ partitions simultaneously during the WAL recovery phase, and the system would saturate within 30 seconds. Wall-clock time to finish compaction was never observed. The server was effectively unusable for any analytical query.
The deeper problem was a QuestDB WAL constraint we had not accounted for: CTAS followed by DROP TABLE on an active WAL table silently drops partition-level data that has not yet been committed to the immutable part store. This ruled out the naive approach of reading QuestDB, writing a clean version via CTAS, and swapping.
CHAPTER 02
The migration executed in three sequential phases, each with explicit validation before proceeding to the next. Phase 0 was cleanup: stop the QuestDB systemd service, delete the 2,810 garbage partition directories via direct filesystem operations, restart QuestDB and verify load normalized to under 4 CPUs within 60 seconds. The garbage partitions contained no real data; they received rows with year-2047 through year-2262 timestamps produced by the bug. Deleting the directories deleted corrupt metadata, not production rows.
Phase 1 was migration: a Rust binary reads from QuestDB via its REST HTTP API in partition-sized batches, validates each batch, and writes to ClickHouse. QuestDB stays live as a read-only source throughout. Phase 2 was verification and cutover: compare row counts and aggregate checksums per table between QuestDB and ClickHouse. Only after all tables match within a 0.01% tolerance does ClickHouse become primary. QuestDB remains running in read-only mode for 72 hours as a fallback.
The migration binary checkpoints progress to a local SQLite database after each successful batch. Batches are 500,000 rows each. The checkpoint record stores the table name, partition key range, source row count, destination row count, and a SHA-256 of the first 1,000 rows' prices to detect silent data corruption during the copy phase.
ARCHITECTURE OVERVIEW
SOURCES
Rust 1.84
QuestDB 7.3 (REST)
TRANSFORM
ClickHouse 26.3
validate + dedup
STORE
SQLite 3.45 (checkpoint store)
partitioned
QUERY
systemd
+ cache
CHAPTER 03
The first version of the migration read QuestDB via SELECT with LIMIT/OFFSET pagination. This failed on bars_1m at 400M+ rows. The OFFSET approach requires QuestDB to scan from the beginning of the table for each page, making page N take O(N) time. Fetching page 100,000 at a 500-row page size required scanning 50M rows to return 500 rows. The migration would have taken weeks.
The correct approach used partition-key ranges rather than OFFSET. We enumerated all partition keys from the system catalog, then fetched each partition independently using a WHERE clause on the timestamp range. This reduced the largest monthly partition, October 2024 with 47M rows, from an estimated 14-hour OFFSET scan to a 23-minute sequential read.
The validation step applied three rules: timestamp must be within [2010-01-01, now() + 1 day]; OHLCV values must be non-negative and high >= low; volume must be non-negative. Any batch with a failing row was quarantined to a separate ClickHouse error table. Of 425M rows migrated, 1,315 rows triggered the timestamp guard from year-2299 corruption in bars_1d, and 4 rows triggered the OHLCV sanity check from negative volumes in a Deribit options feed.
TECH STACK
CHAPTER 04
The checkpoint mechanism was used in practice. The migration binary crashed twice during the 6.5-day run: once due to a ClickHouse OOM on a particularly dense partition, and once due to a network timeout on the QuestDB connection. Both times the binary restarted from the last committed partition checkpoint, not from the beginning of the table.
The 72-hour QuestDB read-only window was the right call. Post-cutover, three query patterns in the Next.js API layer were hitting QuestDB instead of ClickHouse due to a stale environment variable in the API server. We caught this during the 72-hour window, not via QuestDB queries, but the fallback gave us confidence to investigate deliberately rather than scrambling to restore a shut-down service.
425M+
Rows migrated
6.5 days
Migration wall time
0 rows
Data loss confirmed
2,810
Garbage partitions eliminated
CHAPTER 05
DECISION · 01
Chose partition-range batching over OFFSET pagination. This is not novel advice but it is the single most impactful decision in the migration. OFFSET on a 400M-row table is unusable; partition-range batching on a time-partitioned table is O(1) per partition. Any migration from a time-series database should start by enumerating the partition key space.
DECISION · 02
Chose SQLite as the checkpoint store rather than ClickHouse or Redis. The tradeoff: SQLite is local to the migration host and does not survive a disk failure. The alternative was writing checkpoint state to ClickHouse itself, which introduces a circular dependency: if ClickHouse has a write problem, we cannot write the checkpoint. SQLite on a local SSD is independent of both source and destination.
DECISION · 03
Kept QuestDB running for 72 hours post-cutover. The cost was negligible. The safety was real. With a 425M-row source dataset, re-migrating from scratch after a data discovery issue would have cost another 6 days. The 72-hour window let us validate correctness deliberately.
START A PROJECT
We build fast. Most projects ship in under two weeks. Start with a free 30-minute discovery call.
Start a ProjectWe built a 723M-row market data pipeline ingesting 10 exchanges simultaneously at under 50ms tick-to-storage latency.
723M+ Total rows stored
Read case study →
DataWe migrated 425M rows to ClickHouse and achieved 8x storage compression and 15x faster analytical scans versus our prior QuestDB setup.
723M+ Rows stored
Read case study →
DataWe replaced a Python fan-in that dropped ticks under load with a Rust multi-task aggregator handling 80,000 ticks per second across 10 exchanges at 3.1% CPU.
80K tick/s Peak throughput
Read case study →