OpenObserve vs ClickHouse: One Billion Log Records


Getting Started with OpenObserve


Try OpenObserve Cloud today for more efficient and performant observability.

TLDR
_timestamp sort key lets it read the tail of the table and stop, and a two-term intersection. It ties two more at the millisecond floor. On the other ID and term lookups OpenObserve is 1.1x to 5x faster on Parquet and 1.4x to 6x on Vortex, and on the three queries that have to touch most of the billion rows, a high-cardinality count, a common-token count, and an hourly histogram, it is 25x to 109x slower.Every generation of log storage has been a general-purpose tool pointed at logs. Lucene was a text search library; Elasticsearch wrapped it and became the log store of the 2010s, until the memory and the operational weight of running a search engine as a database stopped being worth it at scale. Columnar OLAP is the next generation, and ClickHouse is its best-known engine: fast, mature, and the engine under a lot of serious logging stacks. It is a much better fit than Elasticsearch was.
It is still a general-purpose engine, and that is the question this benchmark asks. A system that serves every analytical workload has to leave the choices to you: the sort key, the index types, the schema, the product around it. Each choice is a trade, and a trade that is right for one query shape is wrong for another. A system built for one workload can make those choices once, and can add structures that make no sense for a general database, because "find every line from this pod" is its whole job rather than an edge case. Whether that is worth anything is an empirical question, so we measured it.
The two systems get there differently. ClickHouse is a general-purpose analytical database: you design the schema, choose the sort key, choose the skip indexes, and build collection, dashboards, and alerting around it. OpenObserve is a single Rust binary that is the whole observability stack, logs, metrics, traces, dashboards, alerts, RUM, synthetics, and SLOs, with schema inference on ingest and object storage as the durable layer. Both store data in columnar files. ClickHouse writes MergeTree parts to local disk; OpenObserve writes Parquet or Vortex, both open formats that Spark, DuckDB, and Pandas can read directly.
We have compared the two before, on a smaller and differently shaped run, in OpenObserve vs ClickHouse: which is better for logs and on the economics in the cost of self-hosting observability on ClickHouse. This run is a rebuild of the query benchmark at a billion records with much tighter controls, and it produced a result we did not expect, which is most of why it is worth reading.
The methodology decides a benchmark like this more than any single number, so here it is in full. The generator, the schemas, the query templates, the runner, and every raw sample are in the benchmark repository.
Three single-node deployments, one per dedicated EC2 i8g.2xlarge with data on that node's local NVMe. The nodes run concurrently and share no hardware, so CPU, memory, disk bandwidth, and page cache are never contended between engines. Only the measurement is serialized, one backend at a time, so no engine is answering queries while another is being timed.
26.7.3.19-stable, MergeTree ORDER BY (_timestamp)v0.92.2 with ZO_FILE_FORMAT=parquetZO_FILE_FORMAT=vortexA fourth node, a c7g.2xlarge not under test, runs the data generator and the benchmark client.
1,000,000,000 records, 2,199 GB of raw NDJSON, 27 columns of Kubernetes-shaped log lines spanning about 4h13m of event time.
Ingestion is a single fan-out pass: the Rust generator serializes each record once and posts the same bytes to /_multi on both OpenObserve instances and JSONEachRow on ClickHouse. A batch counts as sent only when every backend accepted it. That took 16,666 seconds (4h37m) of wall clock at 60,002 rec/s and 128.1 MB/s, and then it stopped. Three independently generated datasets would not be comparable, so there is only one.
19 queries, each expressed natively per engine over an identical, absolutely pinned time window:
count() queriesSELECT * … ORDER BY _timestamp DESC LIMIT 100 row fetchesEach runs 5 times. The OS page cache is dropped on the backend node itself (sync; echo 3 > /proc/sys/vm/drop_caches) immediately before that backend's pass, so run 1 is cold and hot is min(run 2, run 3), the convention the repository's interactive report uses; all five runs are in the repository. The dataset is 674 GB to 1 TB against 64 GiB of RAM, so the cache can hold only a small fraction of it in any case.
A log benchmark is decided by its indexes, so both engines get the same ones:
| Column | ClickHouse | OpenObserve (both formats) |
|---|---|---|
message |
text(tokenizer = splitByNonAlpha) inverted index |
Tantivy full-text index |
trace_id |
bloom_filter(0.01) |
Tantivy secondary index plus external bloom, FPP 0.01 |
span_id |
bloom_filter(0.01) |
Tantivy secondary index |
kubernetes_pod_name |
bloom_filter(0.01) |
Tantivy secondary index |
kubernetes_container_name |
none | none |
kubernetes_container_name is left unindexed on purpose. It has 8 distinct values, so each one matches about an eighth of the dataset and appears in nearly every file: a bloom filter rejects no granules and a secondary index returns a posting list too large to be worth merging. Four of the 19 queries filter on it, and giving the index to one engine and not the other would make those four results measure the setup rather than the engine. Parquet's embedded row-group bloom filter is disabled (ZO_BLOOM_FILTER_PARQUET_ENABLED=false) so it cannot become a Parquet-only advantage over Vortex. ClickHouse is ordered by _timestamp alone, so no structured column gets a sort-key advantage either.
We kept the effort symmetric across both the engines. ClickHouse got one straightforward schema with matched indexes and no further work. OpenObserve got its defaults and, in round 2, one setting. We show both rounds so the effect of that one line is visible rather than baked in. Both engines have more headroom than this, and on both sides it comes with trades. A ClickHouse engineer would start with a pod-first sort key and column codecs: the sort key would take the 109x pod_name count off the board and likely flip the pod_name row fetch, and it would cost ClickHouse the newest-rows-on-a-common-term query it currently wins, because that win depends on _timestamp being the only key. An OpenObserve engineer would start with the compaction target, which is why it is in the post, along with the one query it made slower. We think this level of effort is closer to what a team evaluating the two would actually run than a specialist-tuned shootout, and the repository is there for anyone who wants to run that one.
enable_filesystem_cache=0; OpenObserve with use_cache=false as a URL parameter, which is the only place that flag is honoured.statistics.elapsed and OpenObserve's took are both server-side, so neither engine is charged for HTTP or client overhead the other avoids, and the network hop between driver and backend is not charged to either. That only works because the driver is off the measured nodes; the two choices go together.took in whole milliseconds. Below about 30 ms that quantization is a real part of the spread, and a two or three millisecond gap in the tables below is a tie, not a win.And the disclosure that belongs in every vendor benchmark: we make OpenObserve. The repository exists so you can rerun everything on your own account and tell us where we are wrong.
rows_read (ClickHouse) and scan_records (OpenObserve) on every request but does not persist them, so the recorded results carry latency without the work behind it. The row check that is recorded sits at storage level, system.parts against file_list, and confirms all three backends hold the same 1,000,000,000 rows. Persisting the per-query counts is the first thing to add before the next run.Taken from each engine's own metastore: system.parts plus system.data_skipping_indices for ClickHouse, the stream-stats API backed by file_list for OpenObserve. Both figures are compressed data plus indexes, measured at the round-1 setting.
| System | On disk | Compression vs raw | vs ClickHouse |
|---|---|---|---|
| ClickHouse | 1,026.5 GB | 2.14x | 1.00x |
| O2 · Parquet | 673.5 GB | 3.26x | 0.66x |
| O2 · Vortex | 710.7 GB | 3.09x | 0.69x |
OpenObserve stores the same billion records in a third less space than ClickHouse, while carrying a Tantivy full-text index that ClickHouse's text() index is the counterpart to. Raising the compaction target to 10 GB grew both OpenObserve footprints by 0.49 percent, since larger merge units produce marginally larger output. That is noise next to what it does to query latency.
This suite total favors OpenObserve:
11,578 ms for ClickHouse against 4,663 ms for Parquet and 4,106 ms for Vortex.
That total is concentrated in four queries where ClickHouse has to read most of the billion rows: the pod_name count, where its bloom filter rejects almost no granules for a pod present everywhere; the common-token count, where the text index finds the term everywhere and then counts; and the two histograms, which are full scans by design. OpenObserve answers the first two out of its index. Remove those four, the four widest gaps in the suite, and the remaining 15 are roughly even:
| Round 1, hot, ms | ClickHouse | O2 · Parquet | ratio | O2 · Vortex | ratio |
|---|---|---|---|---|---|
| All 19 queries | 11,578 | 4,663 | 2.48x | 4,106 | 2.82x |
| Minus the two widest gaps | 6,804 | 4,467 | 1.52x | 3,908 | 1.74x |
| Minus the four widest gaps | 2,987 | 3,321 | 0.90x | 2,762 | 1.08x |
Per query, OpenObserve wins 10 of 19 on Vortex and 9 of 19 on Parquet, and ClickHouse takes most of the single-index lookups, because at this setting every OpenObserve lookup pays a fixed 96 to 100 ms before it starts. Round 1 is a win on the four widest-gap queries, and ClickHouse takes most of the small ones by the width of that fixed per-file cost, which is what round 2 removes.
Buried in the round-1 numbers is the most informative result in the whole benchmark, a number that refuses to vary:
| Query (round 1, O2 · Parquet, hot) | Index used | ms |
|---|---|---|
Single trace_id lookup |
secondary index plus bloom | 97 |
Single span_id lookup |
secondary index | 96 |
Rare token in message |
full-text | 96 |
Common token in message |
full-text | 96 |
| Container + rare token | full-text, then filter | 97 |
High-cardinality pod_name |
secondary index | 100 |
Six queries with completely different selectivity, hitting three different index types: a bloom-backed ID lookup, a full-text term that matches almost nothing, a full-text term that matches a large fraction of the dataset, and a secondary index on a high-cardinality column. They all answer in 96 to 100 ms.
That is not six query costs. It is one fixed cost, paid before any of them start, with the actual query work disappearing underneath it.
Same dataset, same queries, same machines. OpenObserve's compaction target file size raised from its 2 GB default to 10 GB, which is roughly a fifth as many data files, each five times larger. ClickHouse was left untouched and re-measured, which makes it the control.
| ClickHouse, unchanged between rounds | Drift |
|---|---|
| Total across 19 queries | 11,578 ms to 11,694 ms (+1.0%) |
| Median per-query drift | 1.2% |
| Largest per-query drift | 17.5%, on a 22 ms query |
That is the run-to-run noise floor of the whole harness. Every OpenObserve change below sits far outside it.
The six queries pinned at 96 to 100 ms land at 24 to 28 ms, a 3.8x drop that is close to the roughly 5x reduction in file count. That is the signature of a per-file fixed cost: opening files, reading their metadata, loading their index segments, merging their results. It scales with how many files a query touches, not with how much data it reads, which is exactly why it was identical across six queries doing entirely different work. Fewer, larger files pay it fewer times.
It also explains the shape of the rest of the suite. Queries already dominated by real work barely move: top-N namespaces, a full scan and group-by over a billion rows where per-file overhead is a rounding error, improves only 1.07x on Parquet and 1.14x on Vortex.
Hot, ms. Δ is round 1 divided by round 2, so above 1.00x is faster after the change. Bold marks a change of 1.3x or more in either direction.
| Query | Parquet 2 GB | Parquet 10 GB | Δ | Vortex 2 GB | Vortex 10 GB | Δ |
|---|---|---|---|---|---|---|
Single trace_id lookup |
97 | 26 | 3.73x | 99 | 27 | 3.67x |
Single span_id lookup |
96 | 25 | 3.84x | 94 | 26 | 3.62x |
Rare token in message |
96 | 24 | 4.00x | 95 | 24 | 3.96x |
Common token in message |
96 | 25 | 3.84x | 95 | 27 | 3.52x |
Container + trace_id |
31 | 19 | 1.63x | 36 | 24 | 1.50x |
| Container + rare token | 97 | 28 | 3.46x | 104 | 32 | 3.25x |
| Two tokens (common AND rare) | 132 | 41 | 3.22x | 133 | 45 | 2.96x |
High-cardinality pod_name |
100 | 27 | 3.70x | 103 | 27 | 3.81x |
| Histogram, 1h buckets | 169 | 63 | 2.68x | 172 | 63 | 2.73x |
| Top-N namespaces | 1,607 | 1,500 | 1.07x | 1,371 | 1,204 | 1.14x |
| Filtered histogram (token) | 977 | 595 | 1.64x | 974 | 598 | 1.63x |
trace_id to 100 rows |
102 | 77 | 1.32x | 63 | 44 | 1.43x |
span_id to 100 rows |
141 | 73 | 1.93x | 135 | 53 | 2.55x |
| Rare token to 100 rows | 142 | 66 | 2.15x | 135 | 52 | 2.60x |
| Common token to 100 rows | 68 | 52 | 1.31x | 50 | 47 | 1.06x |
Container + trace_id to 100 rows |
77 | 56 | 1.38x | 51 | 32 | 1.59x |
| Container + rare token to 100 rows | 128 | 55 | 2.33x | 109 | 38 | 2.87x |
| Two tokens to 100 rows | 186 | 81 | 2.30x | 173 | 67 | 2.58x |
pod_name to 100 rows |
321 | 654 | 0.49x | 114 | 103 | 1.11x |
| Parquet | Vortex | |
|---|---|---|
| Total, 19 queries | 4,663 to 3,487 ms | 4,106 to 2,533 ms |
| Geometric mean per query | 141.6 to 66.3 ms | 124.3 to 54.3 ms |
| Geometric-mean speedup | 2.14x | 2.29x |
| Queries improved | 18 of 19 | 19 of 19 |
Fetching 100 rows by kubernetes_pod_name got 2.0x slower on Parquet: 321 ms to 654 ms hot, and 395 ms to 857 ms cold. It is the only regression in 38 measurements, and it is worth being precise about what the data actually supports:
pod_name count() query uses the same secondary index over the same rows and improved 3.70x. Only materializing the rows regressed.The likely mechanism, and this is a hypothesis rather than something this run profiled, is that a top-K row fetch has to materialize candidate rows out of whichever files the index points at, and a 10 GB Parquet file makes that unit of work five times larger: a footer five times bigger to deserialize, five times the row groups to seek through, larger column chunks to decompress, all to return 100 rows. Vortex's layout is lazily addressable, so the same lookup does not pay in proportion to file size.
The practical reading: if your workload is dominated by SELECT * … LIMIT n on a secondary-indexed high-cardinality column and you run Parquet, measure before raising this knob. On Vortex it is a straight win.
Hot, in milliseconds, all three systems at their round-2 settings. The 19 queries are numbered Q1 to Q19 in the order they appear here, and the rest of the post refers to them by number. Bold is the fastest in the row, and every cell within 3 ms of it where the fastest is under 30 ms, per the tie rule above. The wins / ties / losses counts further down apply the same rule against ClickHouse specifically.
count()| # | Query | ClickHouse | O2 · Parquet | O2 · Vortex |
|---|---|---|---|---|
| Q1 | Single trace_id lookup |
87 | 26 | 27 |
| Q2 | Single span_id lookup |
80 | 25 | 26 |
| Q3 | Rare token in message |
24 | 24 | 24 |
| Q4 | Common token in message |
1,891 | 25 | 27 |
| Q5 | Container + trace_id |
93 | 19 | 24 |
| Q6 | Container + rare token | 29 | 28 | 32 |
| Q7 | Two tokens (common AND rare) | 25 | 41 | 45 |
| Q8 | High-cardinality pod_name |
2,949 | 27 | 27 |
| # | Query | ClickHouse | O2 · Parquet | O2 · Vortex |
|---|---|---|---|---|
| Q9 | Histogram, 1h buckets | 1,591 | 63 | 63 |
| Q10 | Top-N namespaces | 1,639 | 1,500 | 1,204 |
| Q11 | Filtered histogram (token) | 2,248 | 595 | 598 |
SELECT * … ORDER BY _timestamp DESC LIMIT 100| # | Query | ClickHouse | O2 · Parquet | O2 · Vortex |
|---|---|---|---|---|
| Q12 | trace_id to 100 rows |
190 | 77 | 44 |
| Q13 | span_id to 100 rows |
158 | 73 | 53 |
| Q14 | Rare token to 100 rows | 86 | 66 | 52 |
| Q15 | Common token to 100 rows | 26 | 52 | 47 |
| Q16 | Container + trace_id to 100 rows |
187 | 56 | 32 |
| Q17 | Container + rare token to 100 rows | 104 | 55 | 38 |
| Q18 | Two tokens to 100 rows | 92 | 81 | 67 |
| Q19 | pod_name to 100 rows |
195 | 654 | 103 |
| ClickHouse | O2 · Parquet | O2 · Vortex | |
|---|---|---|---|
| Geometric mean per query | 181.9 ms | 66.3 ms | 54.3 ms |
| Per-query speedup vs ClickHouse (geometric mean) | 1.00x | 2.74x | 3.35x |
| Total, 19 queries (hot) | 11,694 ms | 3,487 ms | 2,533 ms |
| Total-time speedup vs ClickHouse | 1.00x | 3.35x | 4.62x |
| Wins / ties / losses vs ClickHouse | n/a | 14 / 2 / 3 | 15 / 2 / 2 |
The geometric mean is the number to quote for "how much faster is a typical query," because it is not swayed by which queries happen to be slowest. The total is the number for "how long does this whole batch take," and it is dominated by the four queries with the widest gaps. Both are in the table; the geometric mean comes first because most log queries are issued one at a time by a person who is waiting.
ClickHouse is outright fastest on 2 of the 19 and tied on two more at the millisecond resolution floor. The compaction change takes OpenObserve to 14 wins on Parquet and 15 on Vortex, with the geometric-mean advantage going from 1.26x to 2.74x on Parquet and from 1.44x to 3.35x on Vortex. The same slice test as round 1 now holds at every cut:
| Round 2, hot, ms | ClickHouse | O2 · Parquet | ratio | O2 · Vortex | ratio |
|---|---|---|---|---|---|
| All 19 queries | 11,694 | 3,487 | 3.35x | 2,533 | 4.62x |
| Minus the two widest gaps | 6,854 | 3,435 | 2.00x | 2,479 | 2.76x |
| Minus the four widest gaps | 3,015 | 2,777 | 1.09x | 1,818 | 1.66x |
Parquet's margin on the last row is thin because of one query, the pod_name row fetch (Q19) at 654 ms, covered above. Remove that as well and Parquet is 1.33x on the remaining 14.
| Class | ClickHouse | O2 · Parquet | O2 · Vortex |
|---|---|---|---|
Indexed count() (8 queries) |
5,178 ms | 215 ms | 232 ms |
| Full-scan aggregation (3) | 5,478 ms | 2,158 ms | 1,865 ms |
Row fetch, LIMIT 100 (8) |
1,038 ms | 1,114 ms | 436 ms |
Indexed counts are a 22x to 24x win on the class total: 3x to 5x on the ID and compound lookups, two ties at the floor, one loss on the two-token intersection, and two very large wins where ClickHouse's index cannot produce a count without reading the table. Aggregations are 2.5x to 2.9x on the class total. Row fetch is where the two formats part: Vortex is 2.4x faster than ClickHouse on the class total and wins 7 of 8. Parquet wins 6 of 8; its class total, 1,114 ms against 1,038, is carried entirely by the 654 ms pod_name fetch (Q19), and on the other seven it is 460 ms against 843.
| # | Query | ClickHouse | O2 · Vortex | Ratio |
|---|---|---|---|---|
| Q8 | High-cardinality pod_name count() |
2,949 | 27 | 109x |
| Q4 | Common token count() |
1,891 | 27 | 70x |
| Q9 | Histogram, 1h buckets | 1,591 | 63 | 25x |
| Q11 | Filtered histogram (token) | 2,248 | 598 | 3.8x |
kubernetes_pod_name has a bloom_filter(0.01) skip index on the ClickHouse side and it still takes 2.9 seconds, because a bloom filter can only reject granules, and for a pod that appears throughout a billion rows very few granules can be rejected. OpenObserve's Tantivy secondary index returns the matching row set directly. The same asymmetry drives the common-token count: ClickHouse's text() index finds the term everywhere and then counts, while OpenObserve reads the count out of the index.
The hourly histogram is the same kind of win, and it is worth being plain that it is not a scan either. OpenObserve's planner recognises the shape, GROUP BY histogram(_timestamp) with only a count(*) and no other predicate, and answers it from the index files without opening the Parquet or Vortex data at all, which is why the two formats tie at exactly 63 ms. Each index file stores _timestamp as a sorted column, so the count in a bucket is the difference between two binary searches for the bucket edges: a handful of probes per file, not a visit to any row. ClickHouse has no equivalent shortcut, because its primary index is sparse, one mark per 8,192-row granule, so it can prune granules but cannot count rows between two arbitrary timestamps without reading them, and toStartOfHour plus a hash GROUP BY over a billion timestamps is what 1.6 seconds buys. Two caveats. The shortcut is specific to that query shape: add a second aggregate such as avg(http_latency_ms) and the query goes back to a full columnar scan. And top-N namespaces (Q10), at 1,639 ms against 1,500 and 1,204, is the one query in this suite where both engines genuinely scan a billion rows, so it is the honest measure of scan speed; the 25x on the histogram is an index-structure win, not a scan-speed win.
| # | Query | ClickHouse | Parquet | Vortex | Why |
|---|---|---|---|---|---|
| Q3 | Rare token in message |
24 | 24 | 24 | a tie at the measurement floor |
| Q6 | Container + rare token | 29 | 28 | 32 | a tie at the measurement floor: Parquet 1 ms ahead, Vortex 3 ms behind |
| Q7 | Two tokens (common AND rare) | 25 | 41 | 45 | ClickHouse intersects two posting lists cheaply |
| Q15 | Common token to 100 rows | 26 | 52 | 47 | top-100 by _timestamp on the sort key, on a term that matches everywhere: ClickHouse reads the newest granules and stops |
| Q19 | pod_name to 100 rows |
195 | 654 | 103 | Parquet only, see the regression above. Vortex wins this row |
Only two of those are outright ClickHouse wins over both formats. Two are ties at the floor, and Vortex takes the pod_name row fetch. There is a clear pattern: ClickHouse wins where its ORDER BY (_timestamp) sort key lets it answer a top-K query by reading the tail of the table, and where intersecting two text terms lets the text index do almost all the work. It loses by 25x, 70x, and 109x on the three queries that have to touch most of the table: the hourly histogram (Q9), the common-token count (Q4), and the high-cardinality pod_name count (Q8).
The two OpenObserve instances share a binary, a dataset, an index configuration, and every environment variable except ZO_FILE_FORMAT.
| Parquet | Vortex | |
|---|---|---|
| Total, 19 queries (round 2, hot) | 3,487 ms | 2,533 ms |
| Storage | 673.5 GB | 710.7 GB |
count() group (8 queries) |
215 ms | 232 ms |
| Aggregation group (3 queries) | 2,158 ms | 1,865 ms |
| Row-fetch group (8 queries) | 1,114 ms | 436 ms |
They are level on indexed count(), because both are answering out of the same Tantivy index and the format barely participates. The gap is entirely in the two groups that read data:
pod_name row fetch.For a log-search workload, find the matching lines and show me the lines, Vortex is the better default, and the storage difference is not close to paying for it.
An honest benchmark reports its losses, and ClickHouse takes real ones here.
ORDER BY (_timestamp) lets ClickHouse answer "newest 100 rows matching a common term" by reading the tail of the table and stopping. It does that in 26 ms against OpenObserve's 47 to 52 ms.Every number above was measured with all three systems on local NVMe, because the benchmark is about the engines and storage was deliberately not a variable. Two things that are constant in the benchmark are not constant in production, and both favour OpenObserve by more than any query result.
Where the data lives. OpenObserve is object-storage native: the durable copy of every file is in S3 (or GCS, Azure Blob, MinIO), and local NVMe holds only a cache. ClickHouse does support S3 as a disk, and a reader who knows that will object here, so the distinction matters. Open-source ClickHouse can put MergeTree parts on an S3-backed disk, but MergeTree was built around local storage: S3-backed parts are slower to query, merges rewrite data through the object API, and every replica still owns its own copy. The engine that genuinely separates compute from storage, where replicas share one copy in object storage and local disk is only a cache, is SharedMergeTree, and SharedMergeTree is proprietary to ClickHouse Cloud. It is not available to a self-hosted deployment at any price. So a self-hosted ClickHouse keeps its working data on attached disk, and high availability means two or three replicas each holding a full copy.
The arithmetic at list prices: EC2 instance-store NVMe works out to roughly $0.14 per GB-month; S3 Standard is $0.023. Two replicas on NVMe is about 12x the per-GB cost of a single copy in S3, three replicas about 19x, before S3 request costs and before the NVMe cache OpenObserve keeps in front of it. Combined with the 0.66x to 0.69x on-disk footprint measured above, the durable-storage bill for the same billion records is an order of magnitude apart. The full cost model, including compute and operations, is in the cost of self-hosting observability on ClickHouse.
How it scales. Because the data is in the bucket, OpenObserve's ingesters, queriers and compactors are stateless: scaling out is adding nodes in front of the same object store, and a node that dies loses nothing. Self-hosted ClickHouse scales by shards and replicas coordinated through Keeper, each replica owning its data, so growth means resharding a stateful cluster, and adding replicas multiplies the disk bill above. The stateless model exists for ClickHouse too, and it is the same answer: SharedMergeTree, in ClickHouse Cloud only. Neither of these shows up in a single-node benchmark. Both show up in the second year.
Tune the file size before you tune the format. The most useful result in this benchmark for anyone running OpenObserve is not the ClickHouse comparison, it is what one setting did to OpenObserve itself. One compaction setting was worth 2.1x to 2.3x geometric mean, more than the difference between Parquet and Vortex on most of the suite, and it is the difference between a 1.3x to 1.4x per-query advantage over ClickHouse and a 2.7x to 3.4x one. If you run OpenObserve on logs at this scale, ZO_COMPACT_MAX_FILE_SIZE is the first thing to look at.
Two caveats on that. It is a compaction-time setting, so it changes the files compaction produces and does nothing to files that already exist: set it before ingesting, or force a recompaction pass over existing data and wait for it to finish. And on Parquet it has one sharp edge, the 2x regression on SELECT * … LIMIT n by a high-cardinality column, which Vortex does not have.
Then pick Vortex. For log search it is faster where it counts, 2.6x on row fetch, and it is the format that did not regress.
On ClickHouse. If your log queries are almost all newest-N-by-time on a term that matches most rows, ClickHouse wins that shape and this benchmark says so. If they include dashboards, histograms, group-bys, or counting anything across a high-cardinality dimension, which is the usual shape of log analytics, the gap on the heaviest of them is one to two orders of magnitude, and it is the gap between a panel that loads and a panel you stop opening.
That is the general-tool cost in one table: ORDER BY (_timestamp) is the right choice for top-K-by-time and the wrong one for everything that filters on a high-cardinality column, and a general-purpose engine cannot make that choice for you.
Nothing here is a screenshot of a number we ask you to trust. The entire benchmark, data generator, ClickHouse schema and index definitions, the matched query templates for both engines, the isolated runner, the report builder, and the raw per-run samples behind every figure in this post, is in one public repository:
github.com/openobserve/openobserve-clickhouse-benchmark
That includes the parts that make a benchmark arguable rather than merely quotable: which columns are indexed on each side and why, the exact SQL each engine was asked to run, and all five raw samples for every query, engine, and round. Any table here can be recomputed, and any choice we made can be changed and rerun.
Two things to watch if you do. The page-cache drop has to happen on the backend node, not on the driver, or you are measuring a warm cache and the harness will still report success. And verify the row counts match on all three backends before measuring anything, after waiting for compaction to finish.
If you reproduce it and get a different answer, that is a useful result. Open an issue with your numbers.
If a log store that answers a high-cardinality count in 27 ms at a billion records on a third less disk sounds useful, the fastest way to feel the difference is to point some real logs at it. OpenObserve Cloud gives you ingestion, full-text search, SQL, dashboards, and alerting without running the binary yourself, and the same engine is open source if you prefer to self-host.