Clickhouse for Developers
by @encryptshawn
Comprehensive ClickHouse skill covering everything you need to work with a ClickHouse analytics database: schema design, query optimization, insert strategie...
clawhub install clickhouse-developerπ About This Skill
name: clickhouse description: > Comprehensive ClickHouse skill covering everything you need to work with a ClickHouse analytics database: schema design, query optimization, insert strategies, CLI usage, table creation and migrations, backend integration (Node.js, Python, Go), Redis caching strategy, cluster vs single-node differences, and how to test/debug data in the database. MUST USE whenever the user mentions ClickHouse, asks about analytics tables, high-volume insert pipelines, MergeTree schemas, ORDER BY / PRIMARY KEY design, materialized views, ClickHouse query performance, connecting to ClickHouse from code, or running ClickHouse CLI commands to inspect data.
ClickHouse Skill
A complete reference for designing, operating, querying, and integrating ClickHouse from backend services. ClickHouse is a columnar, append-optimized analytics database β it is NOT a transactional database. Design everything around that fact.
> Official docs: https://clickhouse.com/docs/best-practices
Quick Reference β Read the Right Section
| Task | Go To | |------|-------| | Design a new table | Schema Design | | Write a migration | Migrations | | Insert data from code | Insert Strategy | | Run queries / inspect the DB | CLI Reference | | Connect from Node.js / Python / Go | Backend Integration | | Optimize a slow query | Query Optimization | | Decide on Redis vs direct query | Redis Caching Strategy | | Understand cluster behavior | Cluster Considerations |
Core Mental Model
ClickHouse is an append-only, batch-oriented analytics database. The biggest performance wins come from:
1. Writing large batches (10Kβ100K rows), not individual rows 2. Choosing ORDER BY carefully β it is immutable and drives all query performance 3. Using native types β never store everything as String 4. Reading many rows across few columns β not few rows across many columns
Avoid ClickHouse for:
Schema Design
Step 1 β Plan ORDER BY Before Creating Any Table
ORDER BY is immutable. Changing it requires creating a new table and migrating all data. Get it right before writing a single row.
Questions to answer first:
WHERE clauses most often?tenant_id, app_id)?-- BAD: UUID as first ORDER BY column β no index benefit
CREATE TABLE events (
id UUID,
timestamp DateTime,
event_type String,
user_id UInt64
) ENGINE = MergeTree()
ORDER BY (id);-- GOOD: Low cardinality first, then date, then higher cardinality
CREATE TABLE events (
id UUID,
timestamp DateTime,
event_type LowCardinality(String),
user_id UInt64
) ENGINE = MergeTree()
ORDER BY (event_type, toDate(timestamp), user_id);
Cardinality ordering rule: Put columns with fewer distinct values first.
| Position | Cardinality | Examples |
|----------|-------------|----------|
| 1st | Low (2β1,000) | event_type, status, country |
| 2nd | Date (coarse) | toDate(timestamp) |
| 3rd+ | Medium-High | user_id, session_id |
| Last | High (if needed) | event_id, UUID |
Index usage by query pattern (for ORDER BY (event_type, event_date, user_id)):
| Filter | Index Used? |
|--------|-------------|
| WHERE event_type = 'X' | β
Yes |
| WHERE event_type = 'X' AND event_date = '...' | β
Yes |
| WHERE event_date = '...' | β No β skips first column |
| WHERE user_id = 123 | β No β skips first two |
For columns that can't be in ORDER BY, add a data skipping index (see Query Optimization).
Step 2 β Choose the Right Engine
| Engine | Use When |
|--------|----------|
| MergeTree | Standard append-only analytics |
| ReplacingMergeTree(ver) | Need logical "upserts" (new version replaces old) |
| AggregatingMergeTree | Pre-aggregated data for materialized views |
| CollapsingMergeTree(sign) | Logical deletes via insert pattern |
| SummingMergeTree | Automatically sum numeric columns on merge |
| ReplicatedMergeTree | Any engine on a cluster with replication |
For clusters, prefix engine name with Replicated: ReplicatedMergeTree(...).
Step 3 β Pick Native Types (Never Store Everything as String)
| Data | Wrong | Right | Savings |
|------|-------|-------|---------|
| UUID | String | UUID | 56% |
| Timestamp | String | DateTime / DateTime64(3) | 58β79% |
| Integer ID | String | UInt32 / UInt64 | varies |
| Boolean | String | Bool | 75β80% |
| IPv4 | String | IPv4 | 43β73% |
| Decimal amount | String | Decimal(10,2) | significant |
Use the smallest numeric type that fits:
| Type | Range | Use For |
|------|-------|---------|
| UInt8 | 0β255 | age, rating, status code |
| UInt16 | 0β65,535 | year, port |
| UInt32 | 0β4.2B | most IDs, unix timestamps |
| UInt64 | 0β18E | very large counters |
Use LowCardinality for repeated strings with < 10,000 unique values:
country LowCardinality(String), -- ~200 unique values
browser LowCardinality(String), -- ~50 unique values
event_type LowCardinality(String) -- ~100 unique values
Use Enum for fixed, known value sets:
-- Provides insert-time validation + 1-byte storage
status Enum8('pending' = 1, 'processing' = 2, 'shipped' = 3, 'delivered' = 4)
Avoid Nullable unless the null is semantically meaningful:
-- BAD: Nullable everywhere
name Nullable(String),
login_count Nullable(UInt32)-- GOOD: Use defaults; Nullable only when null has distinct meaning
name String DEFAULT '',
login_count UInt32 DEFAULT 0,
deleted_at Nullable(DateTime), -- NULL = "not deleted" is semantically distinct
parent_id Nullable(UInt64) -- NULL = "no parent" is semantically distinct
Step 4 β Partitioning Strategy
Partition for lifecycle management, NOT for query performance. Query performance comes from ORDER BY. Partitions enable fast data expiry.
-- GOOD: Monthly partitions for TTL and lifecycle
CREATE TABLE events (
timestamp DateTime,
event_type LowCardinality(String),
user_id UInt64
) ENGINE = MergeTree()
PARTITION BY toStartOfMonth(timestamp)
ORDER BY (event_type, toDate(timestamp), user_id)
TTL timestamp + INTERVAL 90 DAY;-- Instant deletion of a month
ALTER TABLE events DROP PARTITION '2024-01';
Keep partition count between 100β1,000. Daily partitions grow unbounded; monthly is usually safe.
Tiered storage:
TTL
timestamp + INTERVAL 7 DAY TO VOLUME 'hot',
timestamp + INTERVAL 30 DAY TO VOLUME 'warm',
timestamp + INTERVAL 365 DAY DELETE;
If you're unsure, start without partitioning. You can add it later by creating a new table, migrating data, and renaming.
Complete Table Example
CREATE TABLE page_events (
-- Identifiers
event_id UUID DEFAULT generateUUIDv4(),
tenant_id UInt32,
user_id UInt64, -- Low-cardinality dimensions (great for ORDER BY)
event_type LowCardinality(String),
country LowCardinality(String) DEFAULT '',
browser LowCardinality(String) DEFAULT '',
platform Enum8('web'=1, 'ios'=2, 'android'=3, 'api'=4),
-- Timestamps
occurred_at DateTime64(3),
inserted_at DateTime DEFAULT now(),
-- Metrics
duration_ms UInt32 DEFAULT 0,
revenue Decimal(12,4) DEFAULT 0,
-- Flexible properties
properties JSON,
-- Skipping index for user lookups
INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4
) ENGINE = MergeTree()
PARTITION BY toStartOfMonth(occurred_at)
ORDER BY (tenant_id, event_type, toDate(occurred_at), user_id)
TTL occurred_at + INTERVAL 365 DAY
SETTINGS index_granularity = 8192;
Migrations
ClickHouse does NOT support transactional DDL. There is no rollback. Plan carefully.
ORMs / Migration Tools Compatibility
| Tool | ClickHouse Support | |------|--------------------| | Prisma | β No native support β use raw SQL migrations | | Drizzle | β No native support β use raw SQL migrations | | TypeORM | β οΈ Unofficial community driver only | | Flyway | β Supported via JDBC driver | | Liquibase | β Supported | | golang-migrate | β Works well β recommended for Go | | Custom SQL files | β Always works |
For Node.js projects: maintain a migrations/ folder with numbered .sql files and a small runner script.
For Go projects: use golang-migrate with the ClickHouse driver.
Do NOT use Prisma/Drizzle to generate ClickHouse DDL. They have no concept of MergeTree engines, ORDER BY, or PARTITION BY.
Migration Patterns
#### Adding a Column
-- Safe: adding a column with a DEFAULT
ALTER TABLE events ADD COLUMN IF NOT EXISTS session_id UUID DEFAULT generateUUIDv4();-- For a cluster: ON CLUSTER must come first
ALTER TABLE events ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS session_id UUID DEFAULT generateUUIDv4();
#### Changing an ORDER BY (requires table recreation)
-- 1. Create new table with correct ORDER BY
CREATE TABLE events_v2 AS events; -- Copies structure
ALTER TABLE events_v2 MODIFY ORDER BY (tenant_id, event_type, toDate(occurred_at), user_id);-- Or create from scratch:
CREATE TABLE events_v2 (...) ENGINE = MergeTree() ORDER BY (...);
-- 2. Migrate data
INSERT INTO events_v2 SELECT * FROM events;
-- 3. Swap
RENAME TABLE events TO events_old, events_v2 TO events;
-- 4. Verify, then drop
DROP TABLE events_old;
#### Adding a Skipping Index
ALTER TABLE events ADD INDEX IF NOT EXISTS idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
ALTER TABLE events MATERIALIZE INDEX idx_user_id; -- Backfill existing data
#### Node.js Migration Runner Example
// migrations/runner.js
import { createClient } from '@clickhouse/client';
import fs from 'fs';
import path from 'path';const client = createClient({
url: process.env.CLICKHOUSE_URL,
username: process.env.CLICKHOUSE_USER,
password: process.env.CLICKHOUSE_PASSWORD,
database: process.env.CLICKHOUSE_DB,
});
// Track applied migrations
await client.command({
query: CREATE TABLE IF NOT EXISTS _migrations (
name String,
applied_at DateTime DEFAULT now()
) ENGINE = MergeTree() ORDER BY (applied_at, name)
});
const applied = new Set(
(await client.query({ query: 'SELECT name FROM _migrations', format: 'JSONEachRow' }))
.json().map(r => r.name)
);
const files = fs.readdirSync('./migrations').filter(f => f.endsWith('.sql')).sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join('./migrations', file), 'utf-8');
// Execute each statement separately (ClickHouse doesn't support multi-statement by default)
for (const stmt of sql.split(';').map(s => s.trim()).filter(Boolean)) {
await client.command({ query: stmt });
}
await client.command({ query: INSERT INTO _migrations (name) VALUES ('${file}') });
console.log(Applied: ${file});
}
#### Go Migration Runner (golang-migrate)
import (
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/clickhouse"
_ "github.com/golang-migrate/migrate/v4/source/file"
)m, err := migrate.New(
"file://migrations",
"clickhouse://localhost:9000?database=mydb&username=default&password=",
)
if err != nil { log.Fatal(err) }
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
log.Fatal(err)
}
Insert Strategy
Rule 1 β Batch 10,000β100,000 Rows Per INSERT
Each INSERT creates a data part. Many small inserts = many small parts = merge pressure = cluster instability.
# BAD: one row at a time
for event in events:
client.execute("INSERT INTO events VALUES", [event]) # Creates 10,000 parts!GOOD: batch appropriately
BATCH_SIZE = 10_000
for i in range(0, len(events), BATCH_SIZE):
client.execute("INSERT INTO events VALUES", events[i:i+BATCH_SIZE])
Monitor part health:
SELECT table, count() as parts, sum(rows) as total_rows
FROM system.parts
WHERE active AND database = currentDatabase()
GROUP BY table
ORDER BY parts DESC;
-- Warning: > 3,000 parts per table is trouble
Rule 2 β Use Async Inserts for Many Small Producers
When batching client-side isn't practical (many microservices, IoT, etc.):
SET async_insert = 1;
SET async_insert_max_data_size = 10000000; -- 10MB buffer
SET async_insert_busy_timeout_ms = 1000; -- Flush every 1s
SET wait_for_async_insert = 1; -- Wait for durability confirmation
Rule 3 β Avoid Mutations (UPDATE/DELETE)
ClickHouse is not built for mutations. They rewrite entire data parts.
| Need | Use Instead |
|------|-------------|
| UPDATE rows | ReplacingMergeTree + insert new version |
| DELETE rows frequently | Lightweight DELETE (23.3+): DELETE FROM events WHERE ... |
| Delete old data in bulk | DROP PARTITION |
| Track deletions | CollapsingMergeTree(sign) with sign = -1 row |
ReplacingMergeTree pattern:
CREATE TABLE users (
user_id UInt64,
name String,
status LowCardinality(String),
updated_at DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;-- "Update" by inserting a new version
INSERT INTO users (user_id, name, status) VALUES (123, 'Alice', 'inactive');
-- Query deduplicated (FINAL is slower but consistent)
SELECT * FROM users FINAL WHERE user_id = 123;
-- Or use argMax for better performance at scale
SELECT user_id, argMax(status, updated_at) as status
FROM users GROUP BY user_id;
Rule 4 β Never Run OPTIMIZE TABLE FINAL in Production
Background merges handle part consolidation automatically. Forcing it:
CLI Reference
Connect
# Basic
clickhouse-client -h -u --password -d Using env vars (recommended β hides password from process list)
export CLICKHOUSE_PASSWORD=yourpassword
clickhouse-client -h 127.0.0.1 -u app_user -d app_dbWith SSL
clickhouse-client -h -u -d --secure --port 9440
Parse a JDBC URL (jdbc:clickhouse://host:8123/db):
JDBC="jdbc:clickhouse://myhost.com:8123/mydb"
HOST=$(echo $JDBC | sed 's|.*://\([^:]*\):.*|\1|')
PORT=$(echo $JDBC | sed 's|.*:\([0-9]*\)/.*|\1|')
DB=$(echo $JDBC | sed 's|.*/||')
clickhouse-client -h "$HOST" --port "$PORT" -d "$DB"
Inspect the Database
# List databases
clickhouse-client -h -u -q "SHOW DATABASES;" --format=TSVList tables
clickhouse-client -h -u -d -q "SHOW TABLES;" --format=TSVDescribe a table
clickhouse-client -h -u -d -q "DESCRIBE TABLE my_table;" --format=TSVShow CREATE statement (includes engine, ORDER BY, partitioning)
clickhouse-client -h -u -d -q "SHOW CREATE TABLE my_table;" --format=TSVTable sizes
clickhouse-client -h -u -d -q "
SELECT table, total_rows as rows,
formatReadableSize(total_bytes) as size,
formatReadableSize(data_bytes) as data
FROM system.tables WHERE database = currentDatabase()
ORDER BY total_bytes DESC;" --format=PrettyCompactCheck primary key / partition key columns
clickhouse-client -h -u -d -q "
SELECT name, type, is_in_primary_key, is_in_partition_key
FROM system.columns
WHERE database = '' AND table = ''
ORDER BY position;" --format=PrettyCompactPart health check
clickhouse-client -h -u -d -q "
SELECT table, count() as parts, sum(rows) as rows,
formatReadableSize(sum(bytes_on_disk)) as size
FROM system.parts WHERE active AND database = currentDatabase()
GROUP BY table ORDER BY parts DESC;" --format=PrettyCompact
Query Data
# Basic query β JSON output
clickhouse-client -h -u -d \
-q "SELECT * FROM events LIMIT 10;" --format=JSONEachRow | jq -s '.'Aggregation
clickhouse-client -h -u -d \
-q "SELECT event_type, count() as n FROM events GROUP BY event_type ORDER BY n DESC;" \
--format=PrettyCompactExport to CSV
clickhouse-client -h -u -d \
-q "SELECT * FROM events FORMAT CSV" > /tmp/events.csv
Analyze Query Performance
# Execution plan
clickhouse-client -h -u -d \
-q "EXPLAIN SELECT * FROM events WHERE user_id = 123;" --format=TSVWith actual timing (ClickHouse 21.1+)
clickhouse-client -h -u -d \
-q "EXPLAIN ANALYZE SELECT * FROM events WHERE user_id = 123;" --format=TSVSee which indexes were used
clickhouse-client -h -u -d \
-q "EXPLAIN indexes = 1 SELECT * FROM events WHERE user_id = 123;" --format=TSV
Look for:
Rows in EXPLAIN output β fewer is better
Skip entries showing granules skipped by indexes
Full scan β indicates missing index coverageInsert / Modify Data
# Insert from file (CSV)
clickhouse-client -h -u -d \
-q "INSERT INTO events FORMAT CSV" < data.csvInsert from file (JSONEachRow)
clickhouse-client -h -u -d \
-q "INSERT INTO events FORMAT JSONEachRow" < data.ndjsonRun a SQL script
clickhouse-client -h -u -d --multiquery < migration.sqlLightweight delete (23.3+)
clickhouse-client -h -u -d \
-q "DELETE FROM events WHERE occurred_at < '2023-01-01';"Drop partition (instant, for lifecycle)
clickhouse-client -h -u -d \
-q "ALTER TABLE events DROP PARTITION '2023-01';"
Backend Integration
Node.js
Install:
npm install @clickhouse/client
Module setup (src/clickhouse.js or src/clickhouse.ts):
// src/clickhouse.js
import { createClient } from '@clickhouse/client';let _client = null;
export function getClickHouseClient() {
if (_client) return _client;
_client = createClient({
url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123',
username: process.env.CLICKHOUSE_USER ?? 'default',
password: process.env.CLICKHOUSE_PASSWORD ?? '',
database: process.env.CLICKHOUSE_DB ?? 'default',
clickhouse_settings: {
async_insert: 1, // Buffer small inserts server-side
wait_for_async_insert: 1, // Confirm durability
async_insert_busy_timeout_ms: 1000,
},
compression: { request: true }, // Compress inserts
request_timeout: 30_000,
});
return _client;
}
Environment variables (.env):
CLICKHOUSE_URL=http://localhost:8123
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=secret
CLICKHOUSE_DB=analytics
Insert (batch β always batch):
import { getClickHouseClient } from './clickhouse.js';// Accumulate rows, then flush in a batch
const BATCH_SIZE = 10_000;
const buffer = [];
export async function trackEvent(event) {
buffer.push(event);
if (buffer.length >= BATCH_SIZE) {
await flush();
}
}
export async function flush() {
if (buffer.length === 0) return;
const rows = buffer.splice(0, buffer.length);
const client = getClickHouseClient();
await client.insert({
table: 'events',
values: rows,
format: 'JSONEachRow',
});
}
// Also flush on process exit / interval
setInterval(flush, 5_000);
process.on('beforeExit', flush);
Query (analytics β aggregate, don't fetch rows one by one):
export async function getEventStats({ startDate, endDate, eventType }) {
const client = getClickHouseClient();
const result = await client.query({
query:
SELECT
toStartOfHour(occurred_at) AS hour,
count() AS events,
uniq(user_id) AS unique_users
FROM events
WHERE
event_type = {eventType: String}
AND occurred_at >= {startDate: DateTime}
AND occurred_at < {endDate: DateTime}
GROUP BY hour
ORDER BY hour
,
query_params: { eventType, startDate, endDate },
format: 'JSONEachRow',
});
return result.json();
}
TypeScript types:
interface EventRow {
event_id: string;
tenant_id: number;
event_type: string;
user_id: number;
occurred_at: string; // ClickHouse returns DateTime as string
properties: Record;
}const result = await client.query({
query: 'SELECT * FROM events LIMIT 100',
format: 'JSONEachRow',
});
const rows = await result.json();
Python
Install:
pip install clickhouse-connect # Official Anthropic-maintained driver
or
pip install clickhouse-driver # Older but widely used
Module setup (clickhouse.py):
# clickhouse.py
import os
import clickhouse_connect
from functools import lru_cache@lru_cache(maxsize=1)
def get_client():
return clickhouse_connect.get_client(
host=os.environ.get('CLICKHOUSE_HOST', 'localhost'),
port=int(os.environ.get('CLICKHOUSE_PORT', 8123)),
username=os.environ.get('CLICKHOUSE_USER', 'default'),
password=os.environ.get('CLICKHOUSE_PASSWORD', ''),
database=os.environ.get('CLICKHOUSE_DB', 'default'),
settings={
'async_insert': 1,
'wait_for_async_insert': 1,
'async_insert_busy_timeout_ms': 1000,
},
compress=True,
)
Batch insert:
from clickhouse import get_client
from datetime import datetimeBATCH_SIZE = 10_000
def insert_events(events: list[dict]):
"""Always insert in batches of 10K+ rows."""
client = get_client()
# clickhouse_connect expects column-oriented data
column_names = ['tenant_id', 'event_type', 'user_id', 'occurred_at', 'properties']
data = [
[e['tenant_id'] for e in events],
[e['event_type'] for e in events],
[e['user_id'] for e in events],
[e['occurred_at'] for e in events],
[e.get('properties', {}) for e in events],
]
client.insert('events', data, column_names=column_names)
def batch_insert(events: list[dict]):
for i in range(0, len(events), BATCH_SIZE):
insert_events(events[i:i+BATCH_SIZE])
Query:
def get_event_stats(event_type: str, start_date: str, end_date: str):
client = get_client()
result = client.query("""
SELECT
toStartOfHour(occurred_at) AS hour,
count() AS events,
uniq(user_id) AS unique_users
FROM events
WHERE event_type = {event_type:String}
AND occurred_at >= {start_date:DateTime}
AND occurred_at < {end_date:DateTime}
GROUP BY hour
ORDER BY hour
""", parameters={'event_type': event_type, 'start_date': start_date, 'end_date': end_date})
return result.named_results() # Returns list of dicts
With pandas (for data pipelines):
def get_dataframe(query: str, params: dict = None):
client = get_client()
return client.query_df(query, parameters=params or {})df = get_dataframe("SELECT event_type, count() as n FROM events GROUP BY event_type")
Go
Install:
go get github.com/ClickHouse/clickhouse-go/v2
Module setup (internal/clickhouse/client.go):
package clickhouseimport (
"context"
"crypto/tls"
"fmt"
"os"
"sync"
"time"
ch "github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)
var (
once sync.Once
client driver.Conn
)
func GetClient() (driver.Conn, error) {
var err error
once.Do(func() {
options := &ch.Options{
Addr: []string{fmt.Sprintf("%s:%s",
getEnv("CLICKHOUSE_HOST", "localhost"),
getEnv("CLICKHOUSE_PORT", "9000"),
)},
Auth: ch.Auth{
Database: getEnv("CLICKHOUSE_DB", "default"),
Username: getEnv("CLICKHOUSE_USER", "default"),
Password: getEnv("CLICKHOUSE_PASSWORD", ""),
},
Settings: ch.Settings{
"async_insert": 1,
"wait_for_async_insert": 1,
"async_insert_busy_timeout_ms": 1000,
},
DialTimeout: time.Second * 5,
MaxOpenConns: 10,
MaxIdleConns: 5,
ConnMaxLifetime: time.Hour,
Compression: &ch.Compression{
Method: ch.CompressionLZ4,
},
}
// Enable TLS for production
if os.Getenv("CLICKHOUSE_TLS") == "true" {
options.TLS = &tls.Config{InsecureSkipVerify: false}
}
client, err = ch.Open(options)
})
return client, err
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
Batch insert:
package clickhouseimport (
"context"
"time"
)
type Event struct {
TenantID uint32 ch:"tenant_id"
EventType string ch:"event_type"
UserID uint64 ch:"user_id"
OccurredAt time.Time ch:"occurred_at"
}
func InsertEvents(ctx context.Context, events []Event) error {
conn, err := GetClient()
if err != nil {
return err
}
batch, err := conn.PrepareBatch(ctx, "INSERT INTO events")
if err != nil {
return err
}
for _, e := range events {
if err := batch.AppendStruct(&e); err != nil {
return err
}
}
return batch.Send()
}
Query:
type HourlyStats struct {
Hour time.Time ch:"hour"
Events uint64 ch:"events"
UniqueUsers uint64 ch:"unique_users"
}func GetEventStats(ctx context.Context, eventType, start, end string) ([]HourlyStats, error) {
conn, err := GetClient()
if err != nil {
return nil, err
}
rows, err := conn.Query(ctx,
SELECT toStartOfHour(occurred_at) AS hour,
count() AS events,
uniq(user_id) AS unique_users
FROM events
WHERE event_type = @eventType
AND occurred_at >= @start
AND occurred_at < @end
GROUP BY hour ORDER BY hour,
ch.Named("eventType", eventType),
ch.Named("start", start),
ch.Named("end", end),
)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []HourlyStats
for rows.Next() {
var s HourlyStats
if err := rows.ScanStruct(&s); err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, rows.Err()
}
Query Optimization
Use ORDER BY Prefix in Every WHERE Clause
Always filter on the leftmost columns of ORDER BY first.
If you can't, add a data skipping index.
Data Skipping Indexes
For columns NOT in ORDER BY that you filter on:
-- Add bloom filter for high-cardinality equality lookups
ALTER TABLE events ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
ALTER TABLE events MATERIALIZE INDEX idx_user_id; -- Backfill-- Index types:
-- bloom_filter: equality on high-cardinality (user IDs, session IDs)
-- set(N): low-cardinality equality (status IN ('a','b'))
-- minmax: range queries (amount > 1000)
-- ngrambf_v1: text search (LIKE '%term%')
-- tokenbf_v1: token search (hasToken(text, 'word'))
-- Verify it's being used
EXPLAIN indexes = 1
SELECT * FROM events WHERE user_id = 12345;
-- Look for "Skip" entries in output
JOINs
ClickHouse JOINs load the right table into memory. Always put the smaller table on the right.
-- BAD: large table on right
SELECT * FROM small_table s JOIN large_table l ON l.id = s.id;-- GOOD: small table on right
SELECT * FROM large_table l JOIN small_table s ON s.id = l.id;
Filter BEFORE joining:
-- GOOD: reduce data before the join
SELECT * FROM
(SELECT * FROM orders WHERE status = 'completed') o
JOIN
(SELECT * FROM customers WHERE country = 'US') c
ON c.id = o.customer_id;
Choose the right algorithm:
SET join_algorithm = 'auto'; -- Default: ClickHouse decides
SET join_algorithm = 'partial_merge'; -- Large-to-large, memory-constrained
SET join_algorithm = 'grace_hash'; -- Large datasets, can spill to disk
Use ANY JOIN when you only need one match:
SELECT o.*, c.name
FROM orders o
ANY LEFT JOIN customers c ON c.id = o.customer_id;
-- Faster and less memory when right table may have duplicates
Alternatives to JOINs (often faster):
-- Dictionary for dimension lookups
SELECT o.*, dictGet('customers_dict', 'name', o.customer_id) as name
FROM orders o;-- IN subquery for filtering
SELECT * FROM orders
WHERE customer_id IN (SELECT id FROM customers WHERE country = 'US');
Materialized Views
Use materialized views to pre-aggregate data instead of scanning raw tables.
Incremental MV (updates in real time):
-- Destination table
CREATE TABLE events_hourly (
hour DateTime,
event_type LowCardinality(String),
events AggregateFunction(count, UInt64),
unique_users AggregateFunction(uniq, UInt64)
) ENGINE = AggregatingMergeTree()
ORDER BY (event_type, hour);-- MV triggers on every INSERT into events
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT
toStartOfHour(occurred_at) AS hour,
event_type,
countState() AS events,
uniqState(user_id) AS unique_users
FROM events
GROUP BY hour, event_type;
-- Query (reads thousands instead of billions)
SELECT hour, event_type, countMerge(events), uniqMerge(unique_users)
FROM events_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY hour, event_type;
Refreshable MV (periodic rebuild, good for complex JOINs):
CREATE MATERIALIZED VIEW customer_summary
REFRESH EVERY 1 HOUR
ENGINE = MergeTree() ORDER BY customer_id
AS SELECT
c.customer_id, c.name,
count() as orders, sum(o.amount) as total_spent
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name;-- Force refresh
SYSTEM REFRESH VIEW customer_summary;
Avoid Small/Single-Row Queries
ClickHouse is built for scanning many rows and returning aggregates.
Do not use it like a key-value store.
// BAD: Fetching one user's data from ClickHouse on every request
app.get('/user/:id/events', async (req, res) => {
const events = await ch.query(SELECT * FROM events WHERE user_id = ${req.params.id});
res.json(events); // This is a key-value access pattern
});// GOOD: Aggregate query that leverages ClickHouse's strength
app.get('/analytics/summary', async (req, res) => {
const stats = await ch.query(
SELECT event_type, count() as n, uniq(user_id) as users
FROM events
WHERE occurred_at >= today() - 7
GROUP BY event_type
);
res.json(stats);
});
Redis Caching Strategy
When to Use Redis in Front of ClickHouse
| Scenario | Use Redis? | Reason |
|----------|-----------|--------|
| Dashboard with same query run by many users | β
Yes | Prevents redundant large scans |
| Single user fetching their own recent events | β
Yes | ClickHouse isn't a KV store |
| Aggregation query taking > 500ms | β
Yes | Cache computed result |
| Real-time per-user event counts | β
Yes | Maintain counter in Redis, bulk-sync to CH |
| Ad-hoc analytics queries (new filters every time) | β No | Cache hit rate will be low |
| Time-series queries where time range keeps moving | β Careful | Invalidation is complex |
| Backfill / batch ETL pipeline | β No | No user-facing latency concern |
Recommended Cache Pattern
// Cache ClickHouse aggregation results in Redis
async function getDashboardStats(tenantId, dateRange) {
const cacheKey = stats:${tenantId}:${dateRange};
const TTL = 300; // 5 minutes // 1. Try cache first
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Run the (potentially expensive) ClickHouse query
const result = await clickhouse.query({
query:
SELECT event_type, count() as n, uniq(user_id) as users
FROM events
WHERE tenant_id = {tenantId: UInt32}
AND occurred_at >= {start: DateTime}
GROUP BY event_type
,
query_params: { tenantId, start: dateRange },
format: 'JSONEachRow',
});
const data = await result.json();
// 3. Cache for TTL
await redis.setex(cacheKey, TTL, JSON.stringify(data));
return data;
}
When to Query ClickHouse Directly (No Redis)
The query is already fast (< 100ms) due to good schema design and materialized views
The query parameters are always unique (ad-hoc analytics, no cache benefit)
You have a materialized view pre-aggregating the data β query the MV directly
It's an internal/batch process with no latency requirement The right answer is usually: build good materialized views so the ClickHouse query is already fast enough that you don't need Redis.
Cluster Considerations
On a ClickHouse cluster, DDL and certain operations must include ON CLUSTER.
Engine Naming
| Single-node | Cluster |
|-------------|---------|
| MergeTree | ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}') |
| ReplacingMergeTree(ver) | ReplicatedReplacingMergeTree(...) |
| All MergeTree variants | Replicated prefix |
In practice, use macros defined in config.xml:
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
DDL on Cluster
-- Always include ON CLUSTER for DDL on distributed setups
CREATE TABLE events ON CLUSTER '{cluster}' ( ... )
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
ORDER BY (...);ALTER TABLE events ON CLUSTER '{cluster}' ADD COLUMN new_col UInt32 DEFAULT 0;
Distributed Tables
-- Create local table first (on cluster)
CREATE TABLE events_local ON CLUSTER '{cluster}' ( ... )
ENGINE = ReplicatedMergeTree(...)
ORDER BY (...);-- Then create a Distributed table as the access layer
CREATE TABLE events ON CLUSTER '{cluster}' ( ... )
ENGINE = Distributed('{cluster}', currentDatabase(), 'events_local', rand());
Applications connect to the Distributed table; ClickHouse routes queries to shards transparently.
INSERT Routing
On a cluster, insert into the Distributed table (not the local table) unless you are doing a shard-local operation intentionally.
System Queries on Clusters
-- Check part health across all shards
SELECT hostName(), table, count() as parts
FROM clusterAllReplicas('{cluster}', system.parts)
WHERE active GROUP BY hostName(), table ORDER BY parts DESC;-- Check replication lag
SELECT database, table, replica_name, queue_size
FROM system.replication_queue
WHERE queue_size > 0;
Rules Reference
Detailed per-rule files are in rules/ (loaded on demand):
Schema / Primary Key: rules/schema-pk-*.md
Schema / Types: rules/schema-types-*.md
Schema / Partitioning: rules/schema-partition-*.md
Schema / JSON: rules/schema-json-when-to-use.md
Query / JOINs: rules/query-join-*.md
Query / Indexes: rules/query-index-skipping-indices.md
Query / Materialized Views: rules/query-mv-*.md
Insert / Batching: rules/insert-batch-size.md
Insert / Async: rules/insert-async-small-batches.md
Insert / Format: rules/insert-format-native.md
Insert / Mutations: rules/insert-mutation-*.md
Insert / Optimize: rules/insert-optimize-avoid-final.md