---
title: "Druid vs ClickHouse®: Segments or Parts"
excerpt: "What you rebuild when product adds a dimension: ingest-time cube versus query-time sort key."
authors: "Tinybird"
categories: "AI Resources"
createdOn: "2026-09-30 00:00:00"
publishedOn: "2026-09-30 00:00:00"
updatedOn: "2026-09-30 00:00:00"
status: "published"
---

The architecture comparison already exists: [ClickHouse® vs Druid](https://www.tinybird.co/blog/clickhouse-vs-druid) covers node types, Kafka engines, and scaling. This page is what happens to the query when product adds `campaign` next quarter.

```sql
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:

```json
{
  "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:

```sql
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](https://www.tinybird.co/blog/clickhouse-time-series-data) 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](https://clickhouse.com/blog/supercharge-your-clickhouse-data-loads-part2) 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](https://www.tinybird.co/blog/clickhouse-vs-databricks) is that split.

[Apache Druid alternatives](https://www.tinybird.co/blog/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](https://www.tinybird.co/docs/forward/ingest-data/connectors/kafka) reads the topic. The [Events API](https://www.tinybird.co/docs/forward/ingest-data/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:

```tinybird
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:

```tinybird
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:

```tinybird
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](https://www.tinybird.co/pricing), [Developer plan sizes](https://www.tinybird.co/blog/new-developer-plan-pricing)). 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](https://www.tinybird.co/blog/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](https://www.tinybird.co/blog/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.
