The architecture comparison already exists: ClickHouse® vs Druid covers node types, Kafka engines, and scaling. This page is what happens to the query when product adds campaign next quarter.
SELECT
toStartOfMinute(ts) AS minute,
country,
count() AS events,
sum(revenue) AS revenue
FROM page_views
WHERE ts >= now() - INTERVAL 1 HOUR
GROUP BY minute, country
In Druid that SQL is honest only if country was a dimension at ingest and revenue was a metric. Rollup already collapsed rows that shared __time plus those dimensions. The Broker scatter/gathers segments that already look like the answer. Adding campaign is a new cube. History is a reindex, or you run two schemas until the batch job finishes.
In ClickHouse that SQL is honest if country and ts are columns and the table's ORDER BY can skip granules. Aggregation happens at query time unless you built an AggregatingMergeTree yourself. Adding campaign is a column.
Self-hosted ClickHouse still assumes a SQL port. Tinybird is the same dialect with Kafka or Events API ingest and the GROUP BY published as a pipe.
Rollup at ingest is a lock
Classic Druid ingest rollup is a contract. queryGranularity plus dimensionsSpec plus metricsSpec are the cube:
{
"granularitySpec": {
"segmentGranularity": "HOUR",
"queryGranularity": "MINUTE",
"rollup": true
},
"dimensionsSpec": { "dimensions": ["country", "path"] },
"metricsSpec": [
{ "type": "count", "name": "events" },
{ "type": "doubleSum", "name": "revenue", "fieldName": "revenue" }
]
}
A query that groups by a JSON key that was not a dimension either scans raw (if you stored it, which you often did not) or misses. Turn rollup off and Druid stores more rows. You still pay bitmap indexes on every dimension column. High-cardinality user_id as a dimension is how segments get fat. The usual advice is: do not.
Store the event. Sort it. Aggregate when asked
ClickHouse default is the opposite contract:
CREATE TABLE page_views
(
ts DateTime64(3),
country LowCardinality(String),
path String,
revenue Float64,
user_id String
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(ts)
ORDER BY (country, ts);
ORDER BY (country, ts) is a sparse primary index, one mark per granule, not a uniqueness constraint. Filters on country then time skip granules. user_id can sit in the table and stay out of the hot GROUP BY. That is the cardinality move Druid cannot make once user_id is a dimension.
Optional rollup is a materialized view into AggregatingMergeTree with countState / sumState. You add it when the tile is stable. You do not have to declare it on day 1. ClickHouse for time series is that pattern in full.
Bitmaps on every dimension vs marks on the sort key
Druid builds bitmap indexes on dimension columns inside each segment. country IN ('US','DE') is a bitmap AND. A closed cube with a few dozen dimensions feels instant under high concurrency. An unbounded dimension set is a bad idea: every new dimension is another index to write at ingest.
ClickHouse sparse marks skip ranges of the sort key. Secondary indexes (bloom, token, set) exist. They are opt-in. Most serving queries should hit ORDER BY, not a forest of skip indexes. If the filter is path and you sorted by country, ts, you will read too much. Fix the sort key or add a projection. Do not hope the engine invents a bitmap for every column.
Known slice-and-dice columns, high QPS, ingest-time cube: Druid bitmaps earn their keep. Columns product has not named yet, or high-cardinality IDs that must not explode the cube: ClickHouse columns plus a sort key that matches the API filter.
Reindex vs ALTER. Handoff vs parts
Druid pulls Kafka with a supervisor. maxRowsPerSegment defaults to 5,000,000 post-aggregation rows. Kafka handoffConditionTimeout defaults to 15 minutes. Realtime tasks serve the in-flight interval. Handoff publishes immutable segments. Historicals load them. If handoff stalls, the last hour has a hole. Segments do not update in place. Corrections are a rebuild or a lookup at query time.
ClickHouse appends. Kafka table engine, HTTP INSERT, or a connector. Parts merge in the background. There is no handoff ceremony. Late events are more parts for the same partition. toStartOfMinute(ts) uses event time, so an 8-minute retry still belongs to the minute it happened, the same rule Druid's __time gives you when timestampSpec is right.
ALTER UPDATE / DELETE are background merges. If the workload is append-only events, both engines are fine. If the workload is "fix yesterday's revenue flag," ClickHouse is less of a ceremony.
Batch size matters. Single-row inserts at thousands of events/sec create merge backlog. ClickHouse documents bulk inserts and large insert blocks, not one row per INSERT. ClickHouse load guidance is about block size and parallelism, not a 10,000-row product default. Druid hides that behind the indexing service. You pay it in MiddleManagers instead.
Open JSON, or a 40-table join
Product wants "group by any property in the JSON." Druid rollup-at-ingest says no, or you store the JSON as a string and lose the cube. ClickHouse JSON / Map / extracted columns plus a deploy. Query cost grows. The query is still legal.
Join the last hour of events to a 40-table warehouse mart. ClickHouse can join if you denormalize or keep the dimension table small and in memory. Druid lookups cover the small-dimension case. Neither is Photon over a lake. If the join is the product, you wanted a warehouse. ClickHouse vs Databricks is that split.
Apache Druid alternatives is the listicle when the process graph is the complaint. This page is what changes in the SQL when you leave.
Schema change is a deploy
ClickHouse answers the SQL. The app still needs a way to call it without a SQL port. Tinybird is managed ClickHouse: the table is a datasource, the GROUP BY is a pipe, schema changes go through git.
The Kafka connector reads the topic. The Events API accepts JSON over HTTP (documented default: 100 requests per second per datasource; per-request size on that page, 10 MB on Free). Acknowledgements are typically under 2 seconds.
Day 1, sort key matches the tile filter:
DESCRIPTION >
Raw page views. Sort key matches country then time.
SCHEMA >
`ts` DateTime64(3) `json:$.ts`,
`country` LowCardinality(String) `json:$.country`,
`path` String `json:$.path`,
`revenue` Float64 `json:$.revenue`,
`user_id` String `json:$.user_id`
ENGINE "MergeTree"
ENGINE_PARTITION_KEY "toYYYYMMDD(ts)"
ENGINE_SORTING_KEY "country, ts"
Next quarter, product wants campaign. Existing rows need a default until the next ingest carries the field. FORWARD_QUERY is a SELECT list only (no FROM/WHERE). It runs over existing data at read time until deploy compacts it:
SCHEMA >
`ts` DateTime64(3) `json:$.ts`,
`country` LowCardinality(String) `json:$.country`,
`path` String `json:$.path`,
`campaign` String `json:$.campaign`,
`revenue` Float64 `json:$.revenue`,
`user_id` String `json:$.user_id`
ENGINE "MergeTree"
ENGINE_PARTITION_KEY "toYYYYMMDD(ts)"
ENGINE_SORTING_KEY "country, ts"
FORWARD_QUERY >
SELECT *, '' AS campaign
The endpoint can group by the new column without a weekend reindex:
DESCRIPTION >
Last-hour events by country and campaign.
NODE by_campaign
SQL >
SELECT
country,
campaign,
count() AS events,
sum(revenue) AS revenue
FROM page_views
WHERE ts >= now() - INTERVAL 1 HOUR
GROUP BY country, campaign
ORDER BY events DESC
TYPE ENDPOINT
npx tinybird preview / npx tinybird deploy. Developer plans are published from $25/month (0.25 vCPU) to $799/month (8 vCPU), plus extra compute at $0.0002 per vCPU-second (Tinybird pricing, Developer plan sizes). The meter grows with vCPU-seconds. It does not grow with a Broker pool left on 24/7.
You do not get Druid ingest-time bitmaps, or a cluster you SSH into at 2 a.m. Wrong buy if the dimension set is frozen and a Druid team already runs supervisors. Right buy when the GROUP BY keeps changing and the consumer is an app. Self-managed ClickHouse is the path when you want the dialect and will own the HTTP layer. Best database for OLAP is the managed-engine companion.
Use this page for the contract. Use the other page for the cluster
Use Druid when a dedicated OLAP team already knows supervisors and the query set is a closed cube. Use ClickHouse when the schema will grow and the hot filter is a sort key. Use Tinybird when that ClickHouse SQL has to be an HTTP endpoint with a token.
Frequently Asked Questions (FAQs)
Is this the same as ClickHouse vs Druid?
ClickHouse vs Druid is architecture: processes, Kafka engines, scaling. This page is the query contract: when the GROUP BY is allowed to change, and what you rebuild when a dimension lands.
Can ClickHouse do ingest-time rollup?
Yes. A materialized view into AggregatingMergeTree is optional, not mandatory. Druid classic rollup is mandatory if you want the cube economics. That is the contract difference.
