>GLB_ // tools · ClickHouse
Product analytics looks simple: user did X at time T with properties P. Then the property list grows to 300 keys, someone sends the same event three times, and the funnel query that took 200 ms now takes 40 s.
This post is the schema I’d start with today, and the reasoning behind each decision. It builds on the previous post on MergeTree internals; if ORDER BY, granules or merges are unfamiliar, read that one first.
The shape of an event
{
"event_id": "0192f7a0-7c1e-7d2a-9a41-3b5e1f0c8d11",
"project_id": 12,
"event_name": "checkout_completed",
"user_id": "u_83421",
"anonymous_id": "a_f19c...",
"session_id": "s_77ab...",
"timestamp": "2026-10-08T14:03:22.381Z",
"context": { "platform": "web", "country": "AR", "app_version": "4.2.0" },
"properties": { "plan": "pro", "amount": "49.00", "coupon": "LAUNCH" }
}
Three groups of fields:
- Identity and time: always present, always filtered on.
- Context: small, stable set (platform, country, version). Present on every event.
- Properties: event-specific, open-ended, schema changes weekly.
The schema follows that split.
Option 1: everything as columns (wide table)
One column per property. Fast and well compressed, but every new property is an ALTER TABLE, and a table with hundreds of mostly empty columns is painful to operate. Works when you control every producer.
Option 2: Map(String, String)
properties Map(LowCardinality(String), String)
Flexible, no migrations. Downside: accessing properties['plan'] reads the entire map column (all keys and values) for every row scanned. Values are strings, so numeric filters need casts.
Option 3: the JSON type
The JSON data type stores each discovered path as its own subcolumn, so properties.plan reads only that path. It’s production-ready in recent releases (GA since 25.3); check your version before relying on it.
properties JSON(max_dynamic_paths = 256)
max_dynamic_paths caps how many paths get dedicated subcolumns; the rest go to a shared storage. Paths can also be typed up front:
properties JSON(amount Decimal(12, 2), plan LowCardinality(String))
Recommendation: hybrid
- Identity, time and context → typed columns. (Context includes properties like the hostname or site section when one project collects events from several subdomains; see PostHog across subdomains and projects.)
- Hot properties you filter or group by constantly → promoted columns (materialized from properties).
- Everything else → JSON (or
Mapif you’re on an older version).
The schema
CREATE TABLE events
(
-- identity
event_id UUID,
project_id UInt32,
event_name LowCardinality(String),
user_id String,
anonymous_id String,
session_id String,
-- time
timestamp DateTime64(3, 'UTC') CODEC(Delta, ZSTD(1)),
received_at DateTime64(3, 'UTC') DEFAULT now64(3),
-- context
platform LowCardinality(String),
country LowCardinality(FixedString(2)),
app_version LowCardinality(String),
-- open-ended payload
properties JSON(max_dynamic_paths = 256),
-- promoted hot property
plan LowCardinality(String) MATERIALIZED properties.plan::String,
INDEX idx_user user_id TYPE bloom_filter(0.01) GRANULARITY 4
)
ENGINE = ReplacingMergeTree(received_at)
PARTITION BY toYYYYMM(timestamp)
ORDER BY (project_id, event_name, toDate(timestamp), user_id, event_id)
TTL toDateTime(timestamp) + INTERVAL 13 MONTH DELETE;
Every line is a decision. Going through them.
LowCardinality everywhere it fits
event_name, platform, country, app_version, plan: tens to thousands of distinct values. Dictionary encoding cuts storage and speeds up GROUP BY. Rule of thumb from the docs: effective below ~10k distinct values. user_id is high cardinality, so plain String.
No Nullable
Nullable adds a separate null-map file per column and blocks some optimizations. Use empty strings or sentinel values ('', 0) for “missing”. anonymous_id is '' once the user is identified, not NULL.
DateTime64(3, 'UTC') + Delta codec
Millisecond precision, timezone fixed at storage. Timestamps are monotonic-ish inside a sorted part, so Delta before ZSTD compresses them well.
timestamp (client time) and received_at (server time) are separate on purpose: client clocks lie, and you need server time for dedup versioning and late-arrival debugging.
ORDER BY: designed for the queries, not the data
Typical product analytics queries:
- events of type X, in project P, in date range D (most dashboards)
- funnel/retention per user across event types (needs user grouping)
- everything a given user did (debugging, support)
(project_id, event_name, toDate(timestamp), user_id, event_id):
project_idfirst: every query is tenant-scoped. Prunes everything else immediately.event_namenext: dashboards filter on event type almost always. Low cardinality, compresses well.toDate(timestamp): date ranges prune granules. Using the date (not the full timestamp) keepsuser_iduseful in the next position.user_id: within a day and event type, rows for the same user are adjacent, which helpsuniqand per-user aggregations.event_idlast: makes the key unique forReplacingMergeTree.
“Everything a given user did” doesn’t use the sorting key well (user_id is 4th). That’s what the bloom_filter skip index is for. If per-user timelines are a primary use case, consider a projection or a second table ordered by (project_id, user_id, timestamp) fed by a materialized view.
Partitioning: monthly
Monthly partitions make TTL and DROP PARTITION cheap. Do not partition by project_id: with many tenants that’s thousands of partitions and the “too many parts” error.
Dedup with ReplacingMergeTree
SDKs retry. Queues redeliver. You will get duplicates.
ReplacingMergeTree(received_at) keeps, for rows with the same sorting key, the one with the highest received_at. But dedup only happens during merges, which run eventually. At query time you can still see duplicates. Options:
-- exact, slower: dedupes at read time
SELECT count() FROM events FINAL
WHERE project_id = 12 AND event_name = 'checkout_completed';
-- usually fine for analytics: count distinct ids
SELECT uniqExact(event_id) FROM events
WHERE project_id = 12 AND event_name = 'checkout_completed';
FINAL performance has improved significantly in recent versions and is reasonable when the query is already narrowed by the sorting key. Measure it on your data.
Generate event_id client-side (UUIDv7 is a good fit: time-ordered) so retries carry the same id.
Promoted columns with MATERIALIZED
plan is computed from properties at insert time and stored as a real LowCardinality column. Queries use plan directly, no JSON path lookup. It’s not returned by SELECT *. Adding one later:
ALTER TABLE events
ADD COLUMN coupon LowCardinality(String)
MATERIALIZED properties.coupon::String;
ALTER TABLE events MATERIALIZE COLUMN coupon; -- backfill existing parts
TTL
TTL ... + INTERVAL 13 MONTH DELETE drops raw data after 13 months (one year + one month for YoY comparisons). With ttl_only_drop_parts = 1 and monthly partitions, whole parts get dropped instead of rewritten.
Pre-aggregation with materialized views
Raw events answer everything; pre-aggregated tables answer the dashboard in milliseconds. Daily active users per project:
CREATE TABLE dau
(
project_id UInt32,
day Date,
platform LowCardinality(String),
users AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree
ORDER BY (project_id, day, platform);
CREATE MATERIALIZED VIEW mv_dau TO dau AS
SELECT
project_id,
toDate(timestamp) AS day,
platform,
uniqState(user_id) AS users
FROM events
WHERE user_id != ''
GROUP BY project_id, day, platform;
Query with the -Merge combinator:
SELECT day, uniqMerge(users) AS dau
FROM dau
WHERE project_id = 12 AND day >= today() - 30
GROUP BY day
ORDER BY day;
Notes:
- The MV is an insert trigger: it only sees new rows. Backfill with
INSERT INTO dau SELECT ...fromevents. - It sees rows before
ReplacingMergeTreededup.uniqis idempotent to duplicates, so DAU is fine. Asum()of revenue would not be: dedupe upstream or aggregate fromFINAL. uniqStatemerges across days correctly: weekly actives =uniqMergeover 7 days, no double counting.
Funnels and retention
ClickHouse has built-in parametric aggregates for both.
Funnel: signup → activation → purchase within 7 days:
SELECT
level,
count() AS users
FROM
(
SELECT
user_id,
windowFunnel(604800)(
toDateTime(timestamp),
event_name = 'signed_up',
event_name = 'project_created',
event_name = 'checkout_completed'
) AS level
FROM events
WHERE project_id = 12
AND toDate(timestamp) BETWEEN '2026-09-01' AND '2026-09-30'
AND event_name IN ('signed_up', 'project_created', 'checkout_completed')
GROUP BY user_id
)
GROUP BY level
ORDER BY level;
The window is in the unit of the timestamp argument, hence the toDateTime() cast to keep it in seconds. The event_name IN (...) filter matters: it’s the second key column, so it prunes granules before the funnel runs.
Retention: users who signed up on day 0 and came back on day 1 and day 7:
SELECT
sum(r[1]) AS signed_up,
sum(r[2]) AS day_1,
sum(r[3]) AS day_7
FROM
(
SELECT
user_id,
retention(
toDate(timestamp) = '2026-09-01' AND event_name = 'signed_up',
toDate(timestamp) = '2026-09-02',
toDate(timestamp) = '2026-09-08'
) AS r
FROM events
WHERE project_id = 12
AND toDate(timestamp) IN ('2026-09-01', '2026-09-02', '2026-09-08')
GROUP BY user_id
);
Ingestion
- Batch inserts (thousands+ rows) or enable
async_insert = 1. Per-event inserts will create too many parts. - Common pipelines: SDK → HTTP collector → Kafka → ClickHouse (Kafka table engine, ClickPipes, or a consumer that batches).
- Insert
propertiesas a JSON string; ClickHouse parses it into theJSONcolumn.
curl 'http://localhost:8123/?query=INSERT%20INTO%20events%20FORMAT%20JSONEachRow' \
--data-binary @events.ndjson
Anti-patterns
| Pattern | Problem | Instead |
|---|---|---|
String for every column | Larger storage, slower GROUP BY | LowCardinality, numeric types, UUID |
Nullable by default | Extra files, fewer optimizations | Sentinels ('', 0) |
ORDER BY (timestamp) | No pruning by project or event | Tenant and event type first |
PARTITION BY project_id | Thousands of parts | Monthly partition |
| One insert per event | Too many parts | Batch or async_insert |
| Dashboards on raw events | Re-scanning history every refresh | AggregatingMergeTree + MV |
Trusting ReplacingMergeTree for exact counts | Dedup is eventual | FINAL or uniqExact(event_id) |
Checklist
- Identity, time and context are typed columns.
- Open-ended properties in
JSON(orMap), hot ones promoted withMATERIALIZED. -
ORDER BYstarts with tenant, then event type, then date. -
event_idgenerated client-side, dedup strategy decided. - Monthly partitions + TTL.
- Dashboards read from pre-aggregated tables.
- Every schema change validated with
EXPLAIN indexes = 1on real queries.
References
- MergeTree table engine
- ReplacingMergeTree
- AggregatingMergeTree
- Incremental materialized views
- JSON data type
- Map data type
- LowCardinality data type
- Avoid Nullable columns
- Parametric aggregate functions: windowFunnel, retention
- Aggregate function combinators: -State, -Merge
- Data skipping indexes
- Projections
- TTL for columns and tables
- Asynchronous inserts
- Choosing a partitioning key