Short answer: ClickHouse market data tables work best as one table per dataset, sorted by exchange, symbol and exchange time, because most queries start from a symbol and a time range. In a local test on 6 November 2026, 30 million synthetic trades took 218 MB on disk (7.27 bytes a row), and one symbol's hour read in 9.6 ms. Kafka earns its cost when several consumers or replays need the same feed; one bot writing one table does not need it.
Files stop being enough when several programs need the same history or one question scans a whole day. Replication, clusters and live latency were not tested.
Figures use synthetic data shaped like a public trade file, because Binance's dataset terms limit its files to non-commercial uses; real feeds compress differently. This guide gives no trading signals and recommends no vendor. Rules depend on your jurisdiction and each source's terms. For readers in Turkey, Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized.
ClickHouse for market data: test setup and results
One local server stored a synthetic day of 30 million trades in 218 MB and answered single-symbol queries in milliseconds.
| Item | Value |
|---|---|
| Server | ClickHouse 26.8.36.3, one node in Docker, default settings |
| Host | Docker Desktop on WSL2, AMD Ryzen 9 5900HX (8 cores), 7.5 GiB for containers, NVMe SSD |
| Data | 29,999,852 synthetic trades, 50 symbols, one UTC day; busiest symbol 5,584,298 rows |
| Load | 218 MB Parquet file over HTTP, candle view attached: 97 seconds with other jobs on the host |
| Merge to one part | 18 seconds |
| On disk | 218.2 MB from 1,290 MB uncompressed (ratio 5.92), 7.27 bytes a row |
A seeded generator copied the shape of a public trade file: sequential IDs, microsecond times, fills of one order sharing a timestamp, plus a receive time. Times are medians of 7 warm runs on a shared host; read them as relative.
Hypothetical budget. 30 million trades a day at 7.27 bytes a row is about 80 GB a year, before quotes, depth, replicas and backups.
Tables for trades, quotes, depth and candles
Use one table per dataset with the same leading sort key, and store both the exchange's time and your receive time.
CREATE TABLE trades
(
exchange LowCardinality(String),
symbol LowCardinality(String),
trade_id UInt64 CODEC(Delta, ZSTD(1)),
ts_exchange DateTime64(6, 'UTC') CODEC(Delta, ZSTD(1)),
ts_receive DateTime64(6, 'UTC') CODEC(Delta, ZSTD(1)),
price Decimal64(8) CODEC(Delta, ZSTD(1)),
qty Decimal64(8) CODEC(ZSTD(1)),
buyer_maker Bool CODEC(ZSTD(1))
)
ENGINE = ReplacingMergeTree
PARTITION BY toYYYYMM(ts_exchange)
ORDER BY (exchange, symbol, ts_exchange, trade_id);- Two clocks.
ts_exchangeorders events;ts_receiverecords when you could have known. It cost 3.08 of the 7.27 bytes, and 2.42 as anInt32microsecond offset. - One unit. Binance's public data README (opens in a new tab) says Spot timestamps from 1 January 2025 are in microseconds, so convert older millisecond files at load (see the Binance historical data guide).
| Table | Sort key | Notes |
|---|---|---|
trades | exchange, symbol, ts_exchange, trade_id | The ID separates fills sharing a microsecond |
bbo | exchange, symbol, ts_exchange, update_id | One row per top-of-book change |
depth_snapshots | exchange, symbol, ts_receive, last_update_id | Price and quantity arrays per side, from books built as in the WebSocket order book guide |
candles_1m | exchange, symbol, minute | AggregatingMergeTree fed by a materialized view |
Sort key, partitions and LowCardinality
Put the symbol before time unless your main query scans every symbol over short windows.
| Query | Symbol first | Time first |
|---|---|---|
| One symbol, one hour | 9.6 ms; 49,152 rows | 43.3 ms; 1,417,216 rows |
| All symbols, one hour | 54.0 ms; 2,138,112 rows | 34.1 ms; 1,417,216 rows |
| Disk | 218.2 MB | 269.7 MB, 24% more |
ClickHouse's partitioning page (opens in a new tab) says most tables need no partition key and most others nothing finer than a month. Its LowCardinality page (opens in a new tab) favors the type below 10,000 distinct values; each symbol column here cost 0.005 bytes a row.
| Column | LZ4 (default) | ZSTD(1) | Delta, ZSTD(1) | DoubleDelta, ZSTD(1) |
|---|---|---|---|---|
trade_id | 4.005 | Not run | 0.388 | 0.009 |
ts_exchange | 2.138 | 0.947 | 1.181 | 1.858 |
No codec won everywhere: DoubleDelta made sequential IDs almost free, while plain ZSTD won on exchange time because fills repeat a timestamp. Those two changes would save about 0.61 bytes a row; test on your own data first.
Decimal or Float64 for market data
Store Decimal64(8) and widen to Decimal128 before multiplying, because price times quantity on two Decimal64(8) columns overflows.
The product type is Decimal(18, 16): 922 × 1 returned a value and 923 × 1 failed (the 64-bit limit at 16 decimals), so sum(price * qty) on the trade columns stopped with DECIMAL_OVERFLOW. Widening one side works, and nullIf turns an empty window's division-by-zero error into NULL:
SELECT sum(toDecimal128(price, 8) * qty) / nullIf(sum(qty), 0) AS vwap
FROM trades
WHERE exchange = 'venue_a' AND symbol = 'SYM07USDT'
AND ts_exchange >= '2026-01-01 12:00:00' AND ts_exchange < '2026-01-01 13:00:00';| Check | Decimal64(8) | Float64 |
|---|---|---|
price on disk | 0.431 bytes a row (Delta, ZSTD) | 0.695 (Gorilla, ZSTD) |
qty on disk | 2.031 (ZSTD) | 2.597 (ZSTD) |
| Day's notional, query run 7 times | 1 value: 2,612,603,165.0293469 | 2 different values |
The float total moved because threads add in a different order; reconciliation needs one answer. The Decimal page (opens in a new tab) sets Decimal64(S) limits at ±1018-S, so Decimal64(8) stays below 10 billion; very large token quantities need a smaller scale or Decimal128. Decimal128 has no overflow check, the page adds: here an out-of-range product returned a wrong negative number, no error.
Candles from a materialized view, and the replay trap
A materialized view makes one-minute candles cheap to read, but it counts every insert, so a replay inflates volume even after trades drops the duplicates.
CREATE TABLE candles_1m
(
exchange LowCardinality(String),
symbol LowCardinality(String),
minute DateTime('UTC'),
open AggregateFunction(argMin, Decimal64(8), Tuple(DateTime64(6, 'UTC'), UInt64)),
high SimpleAggregateFunction(max, Decimal64(8)),
low SimpleAggregateFunction(min, Decimal64(8)),
close AggregateFunction(argMax, Decimal64(8), Tuple(DateTime64(6, 'UTC'), UInt64)),
volume SimpleAggregateFunction(sum, Decimal128(8)),
trades SimpleAggregateFunction(sum, UInt64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(minute)
ORDER BY (exchange, symbol, minute);
CREATE MATERIALIZED VIEW candles_1m_mv TO candles_1m AS
SELECT exchange, symbol,
toStartOfMinute(ts_exchange) AS minute,
argMinState(price, (ts_exchange, trade_id)) AS open,
max(price) AS high,
min(price) AS low,
argMaxState(price, (ts_exchange, trade_id)) AS close,
sum(qty) AS volume,
count() AS trades
FROM trades
GROUP BY exchange, symbol, minute;| Check | Result |
|---|---|
| One symbol, a day of candles | 13.0 ms from the view; 135.2 ms from trades |
| View against a recalculation | Identical, 1,440 of 1,440 candles; 72,000 stored in 3.6 MB |
| Close chosen by time alone | Differed in 10,197 of 72,000 minutes |
Read it with argMinMerge, argMaxMerge, max, min and sum by minute. The trap: re-sending one hour (1,411,269 rows), as a retry would, left trades FINAL correct at 29,999,852, but that hour's candles counted 2,822,538 trades and double the volume, and stayed doubled after OPTIMIZE cleaned trades. ClickHouse's materialized view page (opens in a new tab) calls a view a trigger on blocks as they are inserted, so it never sees deduplication. What worked: pause ingest, ALTER TABLE candles_1m DROP PARTITION 202601, rebuild from trades FINAL. That took 5.2 seconds and left 0 wrong candles.
ReplacingMergeTree and FINAL: duplicates are eventual
ReplacingMergeTree removes duplicates only when parts merge, at a time you cannot plan, so use FINAL where counts must be exact.
| Query | Plain | FINAL |
|---|---|---|
count(), whole table | 3.2 ms from part metadata; 31,411,121 rows | 1,216 ms; 29,999,852 rows |
| Volume by symbol, full day | 437 ms | 1,640 ms |
| One symbol, the re-sent hour | 9.7 ms | 20.8 ms |
The engine reference (opens in a new tab) says it does not guarantee the absence of duplicates. Its ReplacingMergeTree guide (opens in a new tab) calls the FINAL overhead small and most noticeable without key filters; here it was 3.8 times on the full-day volume query and 2.1 times on the key-filtered hour.
| Design | Rows after FINAL | What happened |
|---|---|---|
| Key (exchange, symbol, ts_exchange, trade_id) | 100,000 | Correct |
Key without trade_id, sent once | 26,582 | Fills sharing a microsecond were deleted on insert |
Key on ts_receive | 200,000 | The copy arrived 5 ms later, so nothing matched |
| Partition by receive date | 100,000, but 200,000 without FINAL even after OPTIMIZE | Copies in different partitions are never merged |
Merges happen within a partition, the guide notes, so a row's partition key must never change. Insert in batches: the insert guide (opens in a new tab) recommends at least 1,000 rows, ideally 10,000 to 100,000.
Kafka market data: when it earns its cost
Kafka earns its cost when several consumers need the same feed, when you must replay after a bug, or when collectors must outlive a database outage; one bot writing one table can insert batches directly.
Key messages by symbol: Kafka's introduction (opens in a new tab) says events with the same key go to the same partition and are read in the order written. On 5 November 2026, ClickHouse's Kafka engine wrote 1,680,358 synthetic trades, keyed by symbol, to a 6-partition Apache Kafka 4.4.0 topic. On 6 November each of 50 symbols sat in one partition, with 0 trade IDs out of order.
| Topic | Smallest partition | Largest partition |
|---|---|---|
| Keyed by symbol | 97,923 | 593,126 (35% of messages) |
| No key | 251,249 | 325,312 |
- Mixed clients. Kafka's Java console producer put 40 of 50 keys in different partitions from ClickHouse. The librdkafka configuration page (opens in a new tab) lists a CRC32 hash (
consistent_random) as default andmurmur2_randomas Java-compatible, so give all producers one partitioner. - Latest values. Kafka's design page (opens in a new tab) says compaction keeps at least the last value per key: right for latest quotes, wrong for trade history.
CREATE TABLE trades_queue
(
exchange String, symbol String, trade_id UInt64,
ts_exchange_us Int64, ts_receive_us Int64,
price String, qty String, buyer_maker Bool
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka:9092',
kafka_topic_list = 'trades_keyed',
kafka_group_name = 'ch_trades',
kafka_format = 'JSONEachRow',
kafka_num_consumers = 6;
CREATE MATERIALIZED VIEW trades_queue_mv TO trades AS
SELECT exchange, symbol, trade_id,
fromUnixTimestamp64Micro(ts_exchange_us, 'UTC') AS ts_exchange,
fromUnixTimestamp64Micro(ts_receive_us, 'UTC') AS ts_receive,
toDecimal64(price, 8) AS price,
toDecimal64(qty, 8) AS qty,
buyer_maker
FROM trades_queue;Re-run on 6 November with the broker and group renamed, this DDL delivered all 1,680,358 rows, none differing from the source. With bad input, one unparsable price blocked ingestion, nothing committed; two same-microsecond fills lacking trade_id became one; 1.2e-9 loaded as 0. The Kafka engine page (opens in a new tab) says to use one group name everywhere if you do not want duplicates; replays still duplicate rows, hence a ReplacingMergeTree target. It also warns that consumed rows can vanish silently if the host loses its page cache before parts reach disk, and recommends fsync_after_insert and fsync_part_directory on target tables.
Time series database open source options and data rights
The choice turns on license, query language and what you already run; only ClickHouse was tested, so this is not a ranking.
| System | License | Fit, as documented |
|---|---|---|
| ClickHouse | Apache 2.0 (opens in a new tab) | Columnar SQL server; the tables above |
| kdb+ | All use needs a license; KDB-X Community Edition is free, with usage restrictions (KX licensing (opens in a new tab)) | The q language, in memory, per KX (opens in a new tab) |
| QuestDB | Apache 2.0 (opens in a new tab) | SQL on a designated timestamp (opens in a new tab); out-of-order rows cost write amplification; deduplication (opens in a new tab) by UPSERT KEYS |
| TimescaleDB | Apache 2 Edition, or Community Edition under the Tiger Data License, which may not be sold as a service (editions (opens in a new tab)) | A PostgreSQL extension; columnstore needs Community Edition |
A stored copy does not change your rights in the data. Binance's Vision Dataset Terms (opens in a new tab) (updated 26 August 2026) bind you on download, caching or querying, allow only non-commercial uses such as personal, non-production backtesting, and bar hosting the files or derived feeds. OKX's API Agreement (opens in a new tab) (section 9.4, updated 28 July 2026) allows market data only for your own personal, non-commercial trading and account management, public endpoints included; the crypto price API guide compares both. For stocks see the stock market data API guide and the Borsa Istanbul API guide. For integration work in general, see Fincode's market data pipelines page; naming Binance, OKX, ClickHouse or Kafka here does not mean Fincode integrates them or has built on them.
Frequently asked questions
Is ClickHouse good for market data?
It suits append-only trades, quotes and candles queried by symbol and time.
Do I need Kafka in front of ClickHouse?
Not for one collector writing one table. Kafka pays off with several consumers, replays or collectors outliving a database outage.
Should prices be Decimal or Float64 in ClickHouse?
Decimal64(8) for storage, widened to Decimal128 before multiplying. Float64 used more disk here and gave two different totals over seven identical runs.
Can I load downloaded exchange data into my own database?
Storing it does not change the license. Binance's Vision terms limit the files to non-commercial use and bar hosting them, so check each source's terms first.
Sources
- ClickHouse: ReplacingMergeTree table engine (opens in a new tab)
- ClickHouse: Working with the ReplacingMergeTree engine (FINAL performance, partitions) (opens in a new tab)
- ClickHouse: Incremental materialized view (opens in a new tab)
- ClickHouse: Decimal data types (scale rules, ranges, overflow checks) (opens in a new tab)
- ClickHouse: Custom partitioning key (opens in a new tab)
- ClickHouse: Selecting an insert strategy (opens in a new tab)
- ClickHouse: Kafka table engine (opens in a new tab)
- Apache Kafka 4.4 documentation: Introduction (partitions and ordering) (opens in a new tab)
- Apache Kafka 4.4 documentation: Design (log compaction) (opens in a new tab)
- librdkafka: configuration properties (partitioner) (opens in a new tab)
- Binance public data: README (Spot timestamps in microseconds from 1 January 2025) (opens in a new tab)
- Binance Vision Dataset Terms, last updated 26 August 2026 (opens in a new tab)
- Tiger Data docs: Compare TimescaleDB editions (opens in a new tab)
- KX: Licensing kdb+ (opens in a new tab)
- OKX API Agreement, sections 9.3 and 9.4 (opens in a new tab)
This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the editorial policy.