Everything for ClickHouse in one place — pick a section below. 29 reviewed items across 3 content types, plus a hands-on tutorial.
Quick Answer
ClickHouse is an open-source column-oriented OLAP database designed for real-time analytics. It achieves 10-100x faster aggregation performance than PostgreSQL by storing data in columns, using vectorized query execution with SIMD instructions, and achieving superior compression ratios on homogeneous columnar data.
Detailed Answer
Think of a library where books are organized by topic (row-oriented like PostgreSQL) versus a library where all chapter-1s are stored together, all chapter-2s together, and so on (column-oriented like ClickHouse). If you want to count how many books mention 'analytics' in chapter 3, the column-oriented library lets you scan just the chapter-3 shelf instead of pulling every book off every shelf and opening it to chapter 3.
ClickHouse is an open-source, column-oriented Online Analytical Processing (OLAP) database management system developed originally at Yandex for their web analytics product Metrica. Unlike PostgreSQL which stores entire rows together on disk (great for transactional workloads where you read/write full records), ClickHouse stores each column in a separate file. This means analytical queries that aggregate over a few columns out of hundreds only read the columns they need, dramatically reducing I/O.
Internally, ClickHouse achieves its speed through several architectural choices. Vectorized query execution processes data in batches of columns rather than row-by-row, leveraging CPU cache lines efficiently. SIMD (Single Instruction Multiple Data) instructions process multiple values in a single CPU cycle. Data compression works exceptionally well because columns contain homogeneous data types (all integers together, all strings together), achieving 5-20x compression with LZ4 or ZSTD codecs. The sparse primary index fits entirely in memory, enabling fast data skipping without reading unnecessary granules.
At production scale, ClickHouse handles billions of rows per second for aggregation queries on commodity hardware. A typical deployment ingests clickstream data, logs, or metrics at millions of rows per second and serves analytical dashboards with sub-second response times. Companies like Uber, Cloudflare, and eBay use ClickHouse for real-time analytics where PostgreSQL would require minutes or hours for the same queries.
The non-obvious gotcha is that ClickHouse excels at analytical reads but is fundamentally different from PostgreSQL for writes. There are no transactions, no UPDATE or DELETE in the traditional sense (mutations are asynchronous background operations), and point lookups by primary key are not its strength. Teams migrating from PostgreSQL must redesign their data model around append-only inserts and pre-aggregation rather than expecting CRUD-style operations.
Code Example
# Connect to ClickHouse using clickhouse-client
clickhouse-client --host localhost --port 9000 # Interactive SQL client
# Create an analytics table for web events
CREATE TABLE events_log (
event_date Date, -- Partition key for date-based pruning
event_time DateTime, -- Timestamp of the event
user_id UInt64, -- Numeric user identifier
event_type LowCardinality(String), -- Enum-like compression for repeated values
page_url String, -- URL visited
duration_ms UInt32, -- Time spent on page
country_code FixedString(2) -- ISO country code, fixed length
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date) -- Monthly partitions for lifecycle management
ORDER BY (event_type, user_id, event_time) -- Sorting key for query optimization
SETTINGS index_granularity = 8192; -- Default granule size (8192 rows)
# Insert sample data
INSERT INTO events_log VALUES
('2026-06-21', '2026-06-21 10:00:00', 12345, 'page_view', '/products', 3200, 'US'),
('2026-06-21', '2026-06-21 10:00:01', 12346, 'click', '/checkout', 1500, 'UK');
# Analytical query - aggregation across millions of rows
SELECT
event_type,
count() AS total_events, -- count() is ClickHouse-specific shorthand
avg(duration_ms) AS avg_duration,
uniq(user_id) AS unique_users -- HyperLogLog-based approximate distinct
FROM events_log
WHERE event_date >= '2026-06-01'
GROUP BY event_type
ORDER BY total_events DESC
LIMIT 10;Interview Tip
A junior engineer typically says ClickHouse is just a fast database, but the interviewer wants you to articulate WHY it is fast at the architectural level. Explain the three pillars: columnar storage (read only needed columns, better compression), vectorized execution (batch processing with SIMD), and sparse indexing (skip irrelevant data granules). Crucially, mention what ClickHouse is NOT good at: point lookups, transactions, and frequent updates. This shows you understand when to choose ClickHouse over PostgreSQL rather than blindly recommending it for everything.
◈ Architecture Diagram
┌─── PostgreSQL (Row-Oriented) ───┐ ┌─── ClickHouse (Column-Oriented) ──┐
│ Row1: [id|name|age|city|salary] │ │ Column: id → [1,2,3,4,5...] │
│ Row2: [id|name|age|city|salary] │ │ Column: name → [a,b,c,d,e...] │
│ Row3: [id|name|age|city|salary] │ │ Column: age → [25,30,28,...] │
│ │ │ Column: city → [NY,LA,SF,...] │
│ SELECT avg(salary) reads ALL │ │ Column: salary → [50k,60k,...] │
│ columns for every row │ │ │
│ I/O: 100% of data │ │ SELECT avg(salary) reads ONLY │
└──────────────────────────────────┘ │ the salary column │
│ I/O: 20% of data (1/5 columns) │
└────────────────────────────────────┘💬 Comments
Quick Answer
MergeTree is ClickHouse's primary storage engine that stores data as immutable sorted parts which are periodically merged in the background. It provides sparse primary indexing, data partitioning, and efficient compression. All production ClickHouse tables use MergeTree variants including ReplacingMergeTree, AggregatingMergeTree, and ReplicatedMergeTree.
Detailed Answer
Think of a filing cabinet where new documents are always placed in a new folder (part) rather than inserted into existing folders. Periodically, a clerk comes in and merges small folders into larger organized ones, keeping everything sorted. This is how MergeTree works: new inserts create new immutable parts, and background merges consolidate them into larger, optimally sorted parts.
MergeTree is the core storage engine in ClickHouse and the foundation upon which all other production engines are built. When you insert data into a MergeTree table, ClickHouse writes it as an immutable part (a directory on disk containing column files, a primary index, and metadata). Each part contains rows sorted by the ORDER BY key. The engine never modifies existing parts; instead, background merge operations combine smaller parts into larger ones, maintaining sort order and applying any engine-specific logic (deduplication for ReplacingMergeTree, aggregation for AggregatingMergeTree).
Internally, each part is organized as follows: every column is stored in a separate compressed file (.bin), with a marks file (.mrk2) that maps granule numbers to byte offsets. The primary index (primary.idx) stores the first value of the ORDER BY key for each granule (default 8192 rows). This sparse index is tiny enough to fit in RAM even for tables with billions of rows. When a query filters by the ORDER BY key, ClickHouse checks the sparse index to identify which granules might contain matching rows, skipping the rest entirely.
At production scale, the MergeTree family includes specialized variants: ReplacingMergeTree deduplicates rows by sorting key during merges, AggregatingMergeTree stores pre-aggregated states for materialized views, CollapsingMergeTree handles mutable data through sign-based cancellation, and ReplicatedMergeTree adds synchronous replication via ClickHouse Keeper. Choosing the right variant is a critical design decision that depends on your data mutation patterns and consistency requirements.
The non-obvious gotcha is that merges are not instantaneous and not guaranteed to have completed at query time. Until all parts are merged, you may see duplicate rows in ReplacingMergeTree or unmerged intermediate states. Using FINAL in queries forces a merge at read time but significantly impacts performance. Production systems should either tolerate eventual consistency or design queries that handle unmerged parts correctly using techniques like argMax or GROUP BY with the latest version.
Code Example
# Basic MergeTree table with partition and ordering
CREATE TABLE user_actions (
action_date Date,
user_id UInt64,
action_type LowCardinality(String),
product_id UInt32,
amount Decimal(10, 2),
session_id UUID
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(action_date) -- Monthly partitions
ORDER BY (user_id, action_date) -- Sparse index on user_id + date
SETTINGS index_granularity = 8192; -- 8192 rows per granule
# Inspect parts on disk after inserting data
SELECT
partition,
name AS part_name,
rows,
bytes_on_disk,
modification_time
FROM system.parts
WHERE table = 'user_actions' AND active = 1 -- Only show active (non-merged) parts
ORDER BY modification_time DESC;
# Force merge of all parts in a partition (use sparingly in production)
OPTIMIZE TABLE user_actions PARTITION '202606' FINAL; -- Merges all parts in June 2026
# Check ongoing merges
SELECT
table,
elapsed,
progress,
num_parts,
result_part_name
FROM system.merges
WHERE table = 'user_actions'; -- Shows background merge operationsInterview Tip
A junior engineer typically describes MergeTree as just a table engine, but the interviewer wants to hear about the immutable parts architecture and how it enables ClickHouse's performance. Explain the write path (inserts create new parts, never modify existing ones), the merge process (background consolidation maintaining sort order), and the sparse index (primary.idx with one entry per 8192 rows that fits in RAM). Mention that all production variants (Replacing, Aggregating, Replicated) extend MergeTree, and acknowledge the FINAL keyword tradeoff for consistency versus performance.
◈ Architecture Diagram
┌─── Insert Path ──────────────────────────────┐ │ │ │ INSERT batch → New Part (immutable) │ │ Part_1: [sorted rows 1-8192] │ │ Part_2: [sorted rows 8193-16384] │ │ │ ├─── Background Merge ─────────────────────────┤ │ │ │ Part_1 + Part_2 + Part_3 → Merged_Part │ │ (re-sorted, re-compressed, engine logic) │ │ │ ├─── Query Path ───────────────────────────────┤ │ │ │ primary.idx: [min_key per granule] │ │ Granule 0: user_id >= 100 │ │ Granule 1: user_id >= 500 ← SKIP │ │ Granule 2: user_id >= 1000 ← READ │ │ │ └───────────────────────────────────────────────┘
💬 Comments
Quick Answer
ClickHouse stores each column in a separate file within a part directory, with rows grouped into granules of 8192 rows. Columnar storage achieves 5-20x compression because columns contain homogeneous data types where consecutive values are often similar, enabling efficient encoding with LZ4 or ZSTD plus delta/double-delta codecs.
Detailed Answer
Think of packing a suitcase. If you throw in random items (a shoe, a book, a shirt, a mug) like a row-oriented database, you cannot compress efficiently because shapes and sizes vary wildly. But if you pack all shoes together, all books together, and all shirts together (columnar), each group compresses beautifully because items in each group share physical properties. The shoes nest into each other, books stack flat, and shirts fold uniformly.
ClickHouse stores data in parts, which are directories on the filesystem. Each part contains one file per column (column_name.bin) holding compressed column data, a marks file (column_name.mrk2) mapping granule numbers to byte positions in the compressed file, and a primary.idx file storing the ORDER BY key values at granule boundaries. A granule is the minimum unit of data ClickHouse reads and contains 8192 rows by default (configurable via index_granularity).
Internally, column compression works in two stages. First, an optional encoding codec transforms the raw values: Delta encoding stores differences between consecutive values (great for timestamps), DoubleDelta stores differences of differences (great for monotonically increasing sequences), and Gorilla encoding handles floating-point metrics. Second, a general-purpose compression algorithm (LZ4 for speed or ZSTD for ratio) compresses the encoded data. Because columns contain only one data type, the encoded values exhibit patterns that general compression exploits efficiently. An integer column with timestamps might compress from 8 bytes per value down to 0.5 bytes per value.
At production scale, compression ratios directly translate to cost savings and performance. A 10TB raw dataset might compress to 500GB-2TB on disk, reducing storage costs and I/O bandwidth requirements proportionally. ClickHouse reads compressed blocks from disk and decompresses them in CPU cache, meaning compression actually speeds up queries by reducing the bottleneck (disk I/O) at the cost of cheap CPU cycles. The marks file enables ClickHouse to seek directly to the correct byte offset within a compressed column file without scanning from the beginning.
The non-obvious gotcha is that column ordering within the ORDER BY key dramatically affects compression. If you order by (timestamp, user_id), the timestamp column compresses extremely well (delta encoding on sorted timestamps) but user_id values jump around randomly. If you order by (user_id, timestamp), user_id compresses well (many consecutive identical values) and timestamp still compresses reasonably within each user's events. Choose ordering based on both query patterns AND compression efficiency.
Code Example
# Create a table with explicit compression codecs per column
CREATE TABLE payments_analytics (
payment_date Date CODEC(DoubleDelta, LZ4), -- Sorted dates compress with DoubleDelta
payment_time DateTime CODEC(Delta, ZSTD(3)), -- Timestamps benefit from Delta encoding
merchant_id UInt32 CODEC(Delta, LZ4), -- IDs with locality benefit from Delta
amount Decimal(12,2) CODEC(Gorilla, LZ4), -- Floating-point values use Gorilla
currency LowCardinality(String), -- Auto dictionary encoding for low cardinality
status LowCardinality(String), -- Few distinct values → dictionary
description String CODEC(ZSTD(5)) -- Free text needs strong compression
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(payment_date)
ORDER BY (merchant_id, payment_date);
# Check actual compression ratios per column
SELECT
column,
formatReadableSize(sum(data_compressed_bytes)) AS compressed,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'payments_analytics'
GROUP BY column
ORDER BY sum(data_uncompressed_bytes) DESC; -- Shows compression ratio per column
# Check on-disk storage layout
SELECT
partition,
formatReadableSize(sum(bytes_on_disk)) AS disk_size,
sum(rows) AS total_rows,
count() AS parts_count
FROM system.parts
WHERE table = 'payments_analytics' AND active = 1
GROUP BY partition;Interview Tip
A junior engineer typically says ClickHouse compresses data well without explaining why. The interviewer wants you to explain the two-stage compression pipeline: encoding codecs (Delta, DoubleDelta, Gorilla) that exploit data patterns, followed by general compression (LZ4/ZSTD) that exploits the resulting byte patterns. Mention that homogeneous column data compresses 5-20x because consecutive values of the same type share patterns. Discuss the marks file and granules to show you understand how ClickHouse seeks into compressed files efficiently without full scans.
◈ Architecture Diagram
┌─── Part Directory on Disk ─────────────────────┐ │ │ │ primary.idx → [key values at granule │ │ boundaries] │ │ │ │ payment_date.bin → [compressed column data] │ │ payment_date.mrk2 → [granule→byte offsets] │ │ │ │ merchant_id.bin → [compressed column data] │ │ merchant_id.mrk2 → [granule→byte offsets] │ │ │ │ amount.bin → [compressed column data] │ │ amount.mrk2 → [granule→byte offsets] │ │ │ ├─── Compression Pipeline ─────────────────────── │ │ │ │ Raw: [1000,1001,1002,1003,1004] │ │ → Delta: [1000,1,1,1,1] │ │ → LZ4: [1000,1×4] (highly compressible) │ │ │ └─────────────────────────────────────────────────┘
💬 Comments
Quick Answer
ClickHouse is optimized for structured analytical queries with SQL, delivering 5-10x faster aggregations at 3-10x lower storage cost. Elasticsearch excels at full-text search and unstructured data with its inverted index. For log analytics, ClickHouse wins on cost and aggregation speed while Elasticsearch wins on free-text search and document retrieval.
Detailed Answer
Think of two different types of librarians. One librarian (Elasticsearch) has memorized every word in every book and can instantly find which books contain any phrase you ask about. The other librarian (ClickHouse) has organized books into precise categories with detailed statistics and can instantly tell you how many books were published per year, the average page count by genre, or trends over time. You pick the librarian based on whether you need to find specific text or analyze patterns in structured data.
ClickHouse and Elasticsearch approach log analytics from fundamentally different architectures. Elasticsearch uses an inverted index that maps every term to the documents containing it, enabling millisecond full-text search across unstructured text. ClickHouse uses columnar storage with sparse indexes optimized for scanning and aggregating structured fields like timestamps, status codes, service names, and numeric metrics. Both can handle log analytics, but they trade off differently.
Internally, the storage efficiency difference is dramatic. Elasticsearch stores the original document plus an inverted index for every field, plus doc values for aggregations, resulting in significant storage amplification (often 1.5-3x the raw data size). ClickHouse stores only the columnar data with compression, typically achieving 5-20x compression on structured log fields. A 10TB/day log pipeline might require 30TB of Elasticsearch storage versus 2-5TB in ClickHouse. This translates directly to infrastructure cost savings of 3-10x.
At production scale, the query performance difference depends on the workload. For aggregations (count errors per service per hour, p99 latency by endpoint, unique users by country), ClickHouse is 5-50x faster because it scans compressed columns without index overhead. For full-text search (find all logs containing a specific error message substring), Elasticsearch is dramatically faster because ClickHouse must scan the entire string column while Elasticsearch jumps directly to matching documents via the inverted index. Many organizations use both: ClickHouse for dashboards and analytics, Elasticsearch for ad-hoc text search.
The non-obvious gotcha is that ClickHouse has added full-text index support (tokenbf_v1, ngrambf_v1 bloom filter indexes) that narrows the gap for log search use cases. These are not as capable as Elasticsearch's inverted index, but for structured log analytics where 90% of queries filter on known fields and only occasionally search message text, ClickHouse can serve both needs. Teams should evaluate whether their actual query patterns justify the cost and complexity of maintaining two systems.
Code Example
# ClickHouse table optimized for log analytics
CREATE TABLE events_log (
timestamp DateTime64(3), -- Millisecond precision
service LowCardinality(String), -- Service name (few distinct values)
level LowCardinality(String), -- info/warn/error/fatal
trace_id String, -- Distributed tracing ID
message String, -- Log message body
INDEX idx_message message TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4, -- Bloom filter for text search
INDEX idx_trace trace_id TYPE bloom_filter(0.01) GRANULARITY 1 -- Fast trace lookup
) ENGINE = MergeTree()
PARTITION BY toDate(timestamp)
ORDER BY (service, level, timestamp)
TTL timestamp + INTERVAL 30 DAY; -- Auto-delete after 30 days
# Aggregation query (ClickHouse excels here - sub-second on billions of rows)
SELECT
service,
level,
toStartOfHour(timestamp) AS hour,
count() AS log_count
FROM events_log
WHERE timestamp >= now() - INTERVAL 24 HOUR
GROUP BY service, level, hour
ORDER BY hour DESC, log_count DESC;
# Text search using bloom filter index (decent for structured logs)
SELECT timestamp, service, message
FROM events_log
WHERE message LIKE '%connection timeout%' -- Uses tokenbf index to skip granules
AND service = 'payment-gateway'
AND timestamp >= now() - INTERVAL 1 HOUR
LIMIT 100;Interview Tip
A junior engineer typically says they used one or the other without understanding the architectural tradeoff. The interviewer wants you to articulate when each tool wins: ClickHouse for aggregation-heavy dashboards and cost efficiency, Elasticsearch for full-text search and document retrieval. Mention the storage cost difference (3-10x cheaper for ClickHouse), the aggregation speed difference (5-50x faster), and the text search difference (Elasticsearch wins for substring/fuzzy search). Discussing the bloom filter indexes in ClickHouse as a middle ground shows current knowledge.
◈ Architecture Diagram
┌─── Elasticsearch ────────────────┐ ┌─── ClickHouse ──────────────────┐
│ │ │ │
│ Inverted Index: │ │ Columnar Storage: │
│ "timeout" → [doc3, doc7, doc99] │ │ service: [svc1,svc1,svc2,...] │
│ "error" → [doc1, doc3, doc5] │ │ level: [info,error,warn,...] │
│ │ │ message: [compressed strings] │
│ Strength: text search O(1) │ │ │
│ Weakness: aggregations slow │ │ Strength: aggregations O(scan) │
│ Storage: 1.5-3x raw size │ │ Weakness: text search O(n) │
│ │ │ Storage: 0.1-0.3x raw size │
└──────────────────────────────────┘ └──────────────────────────────────┘
Use Case Decision:
Aggregations/Dashboards → ClickHouse
Full-text/Fuzzy Search → Elasticsearch
Both needed? → Dual-write or ClickHouse + bloom💬 Comments
Quick Answer
ClickHouse uses standard SQL with extensions for analytical workloads. Queries follow SELECT...FROM...WHERE...GROUP BY...HAVING...ORDER BY...LIMIT syntax. Key differences from MySQL/PostgreSQL include the count() shorthand, specialized aggregate functions like uniq() and quantile(), and the WITH TOTALS clause for automatic subtotals.
Detailed Answer
Think of ClickHouse SQL as a dialect of standard SQL that was fine-tuned for data analysts. Just as British English and American English are mutually intelligible but have different idioms and preferences, ClickHouse SQL follows the same grammar as PostgreSQL or MySQL but adds analytical idioms like count() without arguments, array functions, and approximate aggregation functions that make analytical queries more concise and performant.
ClickHouse supports the standard SQL query structure: SELECT columns, FROM tables, WHERE filters, GROUP BY dimensions, HAVING post-aggregation filters, ORDER BY sort expressions, and LIMIT row count. The query optimizer evaluates filters before aggregation (pushing WHERE predicates down), applies GROUP BY to create result groups, evaluates HAVING on aggregated results, sorts by ORDER BY, and finally applies LIMIT. This is the same logical execution order as any SQL database.
Internally, ClickHouse extends standard SQL with powerful analytical functions. The count() function without arguments counts rows (equivalent to COUNT(*) in other databases). The uniq() function provides approximate distinct counts using HyperLogLog (much faster than COUNT(DISTINCT) on large datasets). Quantile functions like quantile(0.99)(column) calculate percentiles efficiently. The arrayJoin() function unnests arrays inline. LowCardinality columns work like enums with dictionary encoding. The WITH TOTALS clause adds a summary row showing aggregates across all groups without a separate query.
At production scale, understanding query execution helps write performant queries. ClickHouse reads only the columns referenced in the query (columnar advantage). The WHERE clause leverages the primary key index to skip irrelevant granules. GROUP BY is executed using hash tables in memory, so queries grouping by high-cardinality columns (millions of distinct values) need sufficient RAM. The ORDER BY after GROUP BY sorts only the aggregated results (typically small), not the raw data. LIMIT is applied last and prevents unnecessary result serialization.
The non-obvious gotcha is that ClickHouse processes GROUP BY differently from row-oriented databases. In PostgreSQL, GROUP BY on a large table often triggers a disk-based sort, while ClickHouse uses in-memory hash tables that are extremely fast but can OOM on very high cardinality groupings. If your GROUP BY produces millions of groups, use LIMIT BY (ClickHouse-specific) to cap results per group, or set max_rows_to_group_by with an overflow mode to protect against memory exhaustion.
Code Example
# Basic aggregation with GROUP BY and ORDER BY
SELECT
toStartOfHour(event_time) AS hour, -- Truncate timestamp to hour
event_type,
count() AS event_count, -- ClickHouse shorthand for COUNT(*)
uniq(user_id) AS unique_users, -- Approximate distinct (HyperLogLog)
avg(duration_ms) AS avg_duration,
quantile(0.95)(duration_ms) AS p95 -- 95th percentile
FROM events_log
WHERE event_date >= today() - 7 -- Last 7 days (uses partition pruning)
AND country_code = 'US' -- Filter before aggregation
GROUP BY hour, event_type -- Aggregate by time bucket and type
HAVING event_count > 100 -- Filter on aggregated results
ORDER BY hour DESC, event_count DESC -- Sort result set
LIMIT 50; -- Return top 50 rows
# WITH TOTALS adds a summary row across all groups
SELECT
event_type,
count() AS total,
round(avg(duration_ms), 2) AS avg_ms
FROM events_log
WHERE event_date = today()
GROUP BY event_type WITH TOTALS -- Adds a totals row at the end
ORDER BY total DESC;
# LIMIT BY - return top 3 results per group (ClickHouse-specific)
SELECT
country_code,
event_type,
count() AS cnt
FROM events_log
WHERE event_date = today()
GROUP BY country_code, event_type
ORDER BY country_code, cnt DESC
LIMIT 3 BY country_code -- Top 3 event types per country
LIMIT 100; -- Overall result capInterview Tip
A junior engineer typically writes ClickHouse queries identical to PostgreSQL, missing ClickHouse-specific optimizations. The interviewer wants to see that you know count() without arguments, uniq() for approximate distinct counts, quantile() for percentiles, and toStartOfHour/toStartOfDay for time bucketing. Mention WITH TOTALS for summary rows and LIMIT BY for per-group limiting. Demonstrate awareness that WHERE uses the primary index for granule skipping and that GROUP BY uses in-memory hash tables, so high-cardinality groupings need memory protection.
◈ Architecture Diagram
┌─── Query Execution Flow ──────────────────────┐ │ │ │ FROM events_log │ │ ↓ │ │ WHERE (primary key → granule skipping) │ │ ↓ │ │ GROUP BY (in-memory hash table) │ │ ↓ │ │ HAVING (filter on aggregated results) │ │ ↓ │ │ ORDER BY (sort small aggregated result) │ │ ↓ │ │ LIMIT BY (per-group cap, ClickHouse-only) │ │ ↓ │ │ LIMIT (final row cap) │ │ ↓ │ │ Result to client │ │ │ └────────────────────────────────────────────────┘
💬 Comments
Quick Answer
ClickHouse materialized views trigger on INSERT to the source table, transform incoming data in-flight, and write results to a separate target table. They enable incremental pre-aggregation without recomputing from raw data. Unlike PostgreSQL materialized views, they are never refreshed from scratch but continuously maintained as new data arrives.
Detailed Answer
Think of a news ticker at a stock exchange. Rather than recalculating the market index from all individual stock prices every time someone asks (refresh-based materialized view like PostgreSQL), ClickHouse updates the index incrementally as each trade happens (trigger-based materialized view). The ticker stays current without ever needing to re-read all historical trades.
ClickHouse materialized views are INSERT triggers that execute a SELECT query against each incoming batch of data and write the transformed results to a destination table. When you INSERT 1000 rows into the source table, the materialized view's SELECT query runs against those 1000 rows (not the entire table) and the output is inserted into the target table. This is fundamentally different from PostgreSQL where REFRESH MATERIALIZED VIEW re-executes the full query against all data.
Internally, the materialized view is defined with three components: the source table (implicitly from the SELECT's FROM clause), the transformation query (the SELECT statement with optional GROUP BY for pre-aggregation), and the target table (specified with TO clause or auto-created with ENGINE). For aggregation views, the target table typically uses AggregatingMergeTree engine which stores intermediate aggregation states (like partial counts, sums, and HyperLogLog sketches) that can be merged correctly during queries using the -Merge combinator functions (countMerge, sumMerge, uniqMerge).
At production scale, materialized views enable sub-second dashboard queries on data that arrives at millions of rows per second. A raw events table might have billions of rows, but a materialized view pre-aggregating events by hour and service reduces this to thousands of rows in the target table. Queries hit the small pre-aggregated table instead of scanning billions of rows. Teams commonly build multiple materialized views on the same source table for different dashboard panels, each optimized for specific query patterns.
The non-obvious gotcha is that materialized views only process new inserts, not historical data already in the source table. If you create a materialized view on a table with existing data, the target table starts empty. You must manually backfill with INSERT INTO target_table SELECT ... FROM source_table. Also, if the materialized view query fails (out of memory, schema mismatch), the original INSERT to the source table still succeeds but the view's target table misses that batch, causing data gaps that are difficult to detect without monitoring.
Code Example
# Source table: raw events arriving at high throughput
CREATE TABLE events_log (
event_time DateTime,
service LowCardinality(String),
endpoint String,
status_code UInt16,
duration_ms UInt32,
user_id UInt64
) ENGINE = MergeTree()
PARTITION BY toDate(event_time)
ORDER BY (service, event_time);
# Target table using AggregatingMergeTree for storing aggregation states
CREATE TABLE events_hourly_mv (
hour DateTime,
service LowCardinality(String),
request_count AggregateFunction(count, UInt64), -- Stores partial count state
unique_users AggregateFunction(uniq, UInt64), -- Stores HyperLogLog state
p95_duration AggregateFunction(quantile(0.95), UInt32) -- Stores t-digest state
) ENGINE = AggregatingMergeTree()
PARTITION BY toDate(hour)
ORDER BY (service, hour);
# Materialized view: triggers on INSERT to events_log
CREATE MATERIALIZED VIEW events_hourly_trigger
TO events_hourly_mv -- Write results to the target table
AS SELECT
toStartOfHour(event_time) AS hour,
service,
countState() AS request_count, -- Partial aggregation state
uniqState(user_id) AS unique_users, -- Partial HyperLogLog state
quantileState(0.95)(duration_ms) AS p95_duration -- Partial quantile state
FROM events_log
GROUP BY hour, service;
# Query the materialized view (sub-second, scans thousands not billions)
SELECT
hour,
service,
countMerge(request_count) AS total_requests, -- Merge partial counts
uniqMerge(unique_users) AS unique_users, -- Merge HyperLogLog states
quantileMerge(0.95)(p95_duration) AS p95_ms -- Merge quantile states
FROM events_hourly_mv
WHERE hour >= now() - INTERVAL 24 HOUR
GROUP BY hour, service
ORDER BY hour DESC;Interview Tip
A junior engineer typically confuses ClickHouse materialized views with PostgreSQL materialized views (refresh-based). The interviewer wants you to explain that ClickHouse MVs are INSERT triggers that process data incrementally, not full table refreshes. Describe the three-part architecture: source table, transformation query with State functions, and target AggregatingMergeTree table. Explain the -State/-Merge function pairs (countState/countMerge) for correct incremental aggregation. Mention the backfill requirement for historical data and the silent failure risk that makes monitoring essential.
◈ Architecture Diagram
┌─────────────┐ INSERT ┌──────────────────────┐
│ Application │ ──────────→ │ Source: events_log │
└─────────────┘ └──────────┬───────────┘
│
Trigger on INSERT
│
↓
┌──────────────────────┐
│ MV SELECT query │
│ (runs on new batch │
│ only, not full │
│ table scan) │
└──────────┬───────────┘
│
↓
┌──────────────────────┐
│ Target: │
│ events_hourly_mv │
│ (AggregatingMerge │
│ Tree with partial │
│ aggregation states) │
└──────────────────────┘
│
Dashboard query
(sub-second, small table)
↓
┌──────────────────────┐
│ Grafana Dashboard │
└──────────────────────┘💬 Comments
Quick Answer
Partition by a time-based column (month) for TTL management and data lifecycle. Order by columns that appear most frequently in WHERE filters, placing the lowest cardinality column first. The primary key is the prefix of the ordering key and determines the sparse index entries. Wrong key choices can make queries 100x slower.
Detailed Answer
Think of organizing a massive file archive. Partitioning is like choosing which room to put files in (by year/month), so you can lock old rooms and throw away entire rooms when data expires. Ordering is like choosing how to sort files within each room (by department, then by date) so that when someone asks for all marketing files from June, you know exactly which shelf to check without scanning the entire room.
Partition keys in ClickHouse determine how data is physically split into independent subdirectories on disk. Each partition can be independently dropped, moved to cold storage, or expired via TTL. The ideal partition key creates 20-100 partitions over the table's lifetime (not thousands). Partitioning by toYYYYMM(date_column) creates monthly partitions, which is the most common choice. Too many partitions (e.g., partitioning by day on a multi-year dataset) creates thousands of small partitions that slow down metadata operations and INSERT performance.
Internally, the ORDER BY clause determines how rows are sorted within each part and directly controls the sparse primary index. ClickHouse creates one index entry per granule (8192 rows), storing the ORDER BY key values at granule boundaries. When a query filters by columns in the ORDER BY key, ClickHouse uses binary search on the sparse index to identify relevant granules, skipping the rest. The rule of thumb: place the lowest-cardinality column first (e.g., status with 5 values) then medium cardinality (e.g., service_name with 100 values) then highest cardinality (e.g., user_id or timestamp) last. This maximizes the probability of entire granules being skipped.
At production scale, the PRIMARY KEY is a prefix of the ORDER BY clause and determines what goes into the sparse index. Usually PRIMARY KEY equals ORDER BY, but you can define a shorter PRIMARY KEY to reduce index size while keeping the full ORDER BY for storage ordering. For example, ORDER BY (service, user_id, timestamp) with PRIMARY KEY (service, user_id) means the sparse index contains only service and user_id, reducing memory usage while still keeping data sorted by all three columns on disk.
The non-obvious gotcha is that the ORDER BY key also affects compression ratios. Columns that appear early in the ORDER BY key will have long runs of identical or similar values (because they are sorted first), compressing much better. If you order by (random_uuid, timestamp), the UUID column will have unique values in every row with no compression benefit, and the timestamp column will be randomly distributed rather than monotonically increasing. Always test different key orderings with real data and compare both query latency AND compression ratios using system.columns.
Code Example
# Well-designed table for a multi-tenant analytics platform
CREATE TABLE user_actions (
action_date Date,
tenant_id UInt16, -- Low cardinality: ~50 tenants
action_type LowCardinality(String), -- Medium cardinality: ~20 types
user_id UInt64, -- High cardinality: millions
action_time DateTime,
metadata String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(action_date) -- Monthly partitions (good for TTL)
ORDER BY (tenant_id, action_type, user_id, action_time) -- Low→High cardinality
PRIMARY KEY (tenant_id, action_type, user_id) -- Shorter prefix for index
SETTINGS index_granularity = 8192;
# This query uses all three levels of the sparse index
SELECT count(), uniq(user_id)
FROM user_actions
WHERE tenant_id = 42 -- Skips all other tenants
AND action_type = 'purchase' -- Skips other action types
AND user_id = 12345 -- Narrows to specific user
AND action_date >= '2026-06-01'; -- Partition pruning
# Check how effectively the primary key skips granules
SELECT
query,
read_rows,
read_bytes,
result_rows
FROM system.query_log
WHERE query LIKE '%user_actions%'
AND type = 'QueryFinish'
ORDER BY event_time DESC
LIMIT 5; -- Compare read_rows vs result_rows (lower ratio = better index usage)
# Anti-pattern: too many partitions
-- PARTITION BY toYYYYMMDD(action_date) -- BAD: 365+ partitions per year
-- PARTITION BY (tenant_id, toYYYYMM(action_date)) -- BAD: 50×12 = 600 partitions/yearInterview Tip
A junior engineer typically picks partition and ordering keys without understanding the implications. The interviewer wants to hear the design principles: partition by month for TTL and lifecycle (not too many partitions), order by low-to-high cardinality columns that match your WHERE filters, and understand that PRIMARY KEY can be a shorter prefix of ORDER BY. Explain granule skipping with the sparse index and mention checking system.query_log to compare read_rows versus result_rows. Discussing the compression impact of ordering shows advanced understanding.
◈ Architecture Diagram
┌─── Partition + Ordering Key Design ──────────────────┐ │ │ │ PARTITION BY toYYYYMM(date) │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ 202605 │ │ 202606 │ │ 202607 │ ← Drop/TTL │ │ └─────────┘ └─────────┘ └─────────┘ per partition │ │ │ │ ORDER BY (tenant_id, action_type, user_id) │ │ │ │ Sparse Index (primary.idx): │ │ Granule 0: tenant=1, type=click, user=100 │ │ Granule 1: tenant=1, type=click, user=5000 │ │ Granule 2: tenant=1, type=purchase,user=200 │ │ Granule 3: tenant=2, type=click, user=100 │ │ ↑ │ │ WHERE tenant_id=2 → skip granules 0,1,2 │ │ │ └───────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
ReplacingMergeTree deduplicates rows by the ORDER BY key during background merges, keeping only the latest version. It handles slowly changing dimensions and upsert patterns in an append-only system. However, deduplication is eventual (only during merges), so queries must use FINAL or GROUP BY with argMax to guarantee correctness.
Detailed Answer
Think of a wiki where every edit creates a new page version rather than overwriting the old one. ReplacingMergeTree is like a background process that periodically goes through the wiki and removes old versions, keeping only the latest. But at any given moment between cleanups, you might see multiple versions of the same page coexisting. You need to explicitly ask for the 'latest version only' view.
ReplacingMergeTree solves the problem of mutable data in ClickHouse's append-only architecture. Since ClickHouse does not support traditional UPDATE statements (mutations exist but are async background operations), teams that need to maintain current state (user profiles, product catalog, device status) use ReplacingMergeTree. You INSERT the new version of a row with the same ORDER BY key values, and during background merges, ClickHouse keeps only the row with the highest version column value, discarding older duplicates.
Internally, deduplication only happens during part merges, not at INSERT time. When two parts are merged, if they contain rows with the same ORDER BY key, the row with the higher ver column (a UInt or DateTime column specified as the engine parameter) survives. Between merges, duplicate rows coexist in different parts. This means a simple SELECT may return multiple versions of the same entity. To get deduplicated results, use either SELECT...FINAL (forces a merge at read time, slower) or manually deduplicate with GROUP BY the key columns and argMax(column, ver) to pick the latest value.
At production scale, ReplacingMergeTree is the standard pattern for maintaining dimension tables (user profiles, product catalogs, configuration data) that receive updates. A CDC pipeline from PostgreSQL can stream INSERT/UPDATE events as new rows with incrementing version numbers. The table eventually converges to contain only the latest version of each entity. For large tables, the argMax pattern in queries is preferred over FINAL because it leverages ClickHouse's parallel execution rather than single-threaded merge-on-read.
The non-obvious gotcha is that ReplacingMergeTree only guarantees deduplication within a single partition. If the same ORDER BY key appears in two different partitions (due to different partition key values), both copies survive forever because ClickHouse never merges across partitions. This means your partition key must not divide rows that should be deduplicated. Also, FINAL performance degrades significantly on large tables because it forces single-threaded processing of the merge logic.
Code Example
# ReplacingMergeTree for user profiles (CDC from PostgreSQL)
CREATE TABLE user_profiles (
user_id UInt64,
username String,
email String,
plan LowCardinality(String),
updated_at DateTime, -- Version column for deduplication
is_deleted UInt8 DEFAULT 0 -- Soft delete flag
) ENGINE = ReplacingMergeTree(updated_at) -- Keep row with latest updated_at
ORDER BY user_id; -- Deduplicate by user_id
# Insert initial profile
INSERT INTO user_profiles VALUES (1001, 'alice', '[email protected]', 'free', now(), 0);
# Update: insert new version with same user_id but later timestamp
INSERT INTO user_profiles VALUES (1001, 'alice', '[email protected]', 'pro', now(), 0);
# Without FINAL: may return BOTH versions (before merge completes)
SELECT * FROM user_profiles WHERE user_id = 1001;
# With FINAL: guaranteed latest version only (slower, single-threaded)
SELECT * FROM user_profiles FINAL WHERE user_id = 1001;
# Production pattern: argMax for better performance than FINAL
SELECT
user_id,
argMax(username, updated_at) AS username,
argMax(email, updated_at) AS email,
argMax(plan, updated_at) AS plan,
max(updated_at) AS last_updated
FROM user_profiles
WHERE is_deleted = 0
GROUP BY user_id; -- Deduplicate manually with parallel execution
# Check if merges have completed (parts count should decrease over time)
SELECT count() AS active_parts, sum(rows) AS total_rows
FROM system.parts
WHERE table = 'user_profiles' AND active = 1;Interview Tip
A junior engineer typically says ReplacingMergeTree handles updates like a normal database, missing the critical point that deduplication is eventual and only happens during merges. The interviewer wants to hear that you understand the consistency tradeoff: between merges, duplicates exist. Explain the two query strategies (FINAL for correctness at cost of speed, argMax for parallel performance). Mention the cross-partition limitation where same-key rows in different partitions are never deduplicated. Discussing the CDC pipeline pattern from PostgreSQL shows real-world application.
◈ Architecture Diagram
┌─── ReplacingMergeTree Lifecycle ──────────────────┐ │ │ │ INSERT user_id=1001, ver=T1 → Part_1 │ │ INSERT user_id=1001, ver=T2 → Part_2 │ │ INSERT user_id=1001, ver=T3 → Part_3 │ │ │ │ Before merge: SELECT returns 3 rows (all versions)│ │ │ │ ┌─ Background Merge ──────────────────────────┐ │ │ │ Part_1 + Part_2 + Part_3 → Merged_Part │ │ │ │ Keep only ver=T3 (latest), discard T1, T2 │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ After merge: SELECT returns 1 row (latest only) │ │ │ │ Query Strategies: │ │ • FINAL → forces merge at read time (slow) │ │ • argMax(col, ver) → manual dedup (fast, parallel)│ │ │ └────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
ClickHouse TTL (Time-To-Live) automatically manages data lifecycle at row, column, and partition levels. Row TTL deletes expired rows during merges. Column TTL resets columns to defaults when they age out. Move TTL migrates data between storage tiers (hot SSD to cold HDD). TTL rules execute during background merges, not in real-time.
Detailed Answer
Think of a grocery store's shelf management system. Fresh produce (hot data) sits on premium refrigerated shelves (SSD), items approaching expiration get moved to discount racks (HDD cold storage), and expired items are automatically removed from shelves entirely (deletion). This tiered lifecycle happens automatically based on the expiration date stamped on each item, without manual intervention.
ClickHouse TTL enables automatic data lifecycle management without external jobs or manual partition drops. You define TTL rules at table creation or alter them later, and ClickHouse enforces them during background merges. Row-level TTL deletes entire rows when a timestamp column exceeds the specified age. Column-level TTL resets specific columns to their default values (useful for GDPR compliance where you keep aggregate data but erase PII after 30 days). Move TTL migrates data between storage policies (tiers) based on age.
Internally, TTL rules are evaluated during part merges. When ClickHouse merges parts, it checks each row's TTL expression. If the row has expired, it is excluded from the merged output (effectively deleted). For column TTL, the column value is replaced with its default. For move TTL, the entire part is written to a different disk/volume defined in the storage policy. The materialize_ttl_after_modify setting controls whether altering a TTL triggers immediate re-evaluation or waits for natural merges. TTL operations are tracked in system.part_log.
At production scale, TTL is the standard mechanism for multi-tier storage architectures. A typical setup defines a hot tier on NVMe SSDs for the last 7 days, a warm tier on standard SSDs for 7-90 days, and a cold tier on HDD or S3 for 90-365 days. After 365 days, row TTL deletes the data entirely. This reduces storage costs by 70-80% compared to keeping all data on premium storage while maintaining query access to historical data (albeit slower for cold queries).
The non-obvious gotcha is that TTL enforcement is eventual, not real-time. Expired rows remain queryable until a merge processes their part. You cannot guarantee that a row is deleted exactly at expiration time. For compliance requirements (GDPR right-to-be-forgotten), you may need to combine TTL with explicit ALTER TABLE DELETE WHERE mutations for immediate (though async) deletion. Also, TTL moves to cold storage can cause query performance degradation if dashboards query across time ranges spanning multiple tiers without users realizing the latency difference.
Code Example
# Table with multi-level TTL for data lifecycle
CREATE TABLE payments_analytics (
payment_date Date,
payment_time DateTime,
user_id UInt64,
amount Decimal(12, 2),
card_number String TTL payment_time + INTERVAL 30 DAY, -- PII expires after 30 days
merchant_name String,
status LowCardinality(String)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(payment_date)
ORDER BY (payment_date, user_id)
TTL
payment_time + INTERVAL 7 DAY TO VOLUME 'warm', -- Move to warm after 7 days
payment_time + INTERVAL 90 DAY TO VOLUME 'cold', -- Move to cold after 90 days
payment_time + INTERVAL 365 DAY DELETE -- Delete after 1 year
SETTINGS
merge_with_ttl_timeout = 3600; -- Check TTL every hour during merges
# Define storage policy with multiple tiers
-- In config.xml or config.d/storage.xml:
-- <storage_configuration>
-- <disks>
-- <hot><path>/data/hot/</path></hot>
-- <warm><path>/data/warm/</path></warm>
-- <cold><path>/data/cold/</path></cold>
-- </disks>
-- <policies>
-- <tiered>
-- <volumes>
-- <hot><disk>hot</disk></hot>
-- <warm><disk>warm</disk></warm>
-- <cold><disk>cold</disk></cold>
-- </volumes>
-- </tiered>
-- </policies>
-- </storage_configuration>
# Check TTL status and which parts have been moved
SELECT
partition,
disk_name,
formatReadableSize(bytes_on_disk) AS size,
rows,
delete_ttl_info_min,
delete_ttl_info_max
FROM system.parts
WHERE table = 'payments_analytics' AND active = 1
ORDER BY partition;
# Force TTL evaluation without waiting for natural merge
ALTER TABLE payments_analytics MATERIALIZE TTL; -- Triggers TTL enforcement nowInterview Tip
A junior engineer typically mentions TTL as just data deletion, but the interviewer wants to see you understand the full lifecycle management capability. Explain three TTL types: row deletion, column reset (GDPR PII erasure), and volume movement (tiered storage). Describe the tiered storage architecture (hot/warm/cold) with concrete time boundaries. Mention that TTL is eventual (executed during merges, not real-time) and that MATERIALIZE TTL can force immediate evaluation. Discussing cost savings from tiered storage and compliance implications shows production maturity.
◈ Architecture Diagram
┌─── Data Lifecycle with TTL ───────────────────────┐ │ │ │ Day 0-7: HOT tier (NVMe SSD) │ │ ┌─────────────────────────────────┐ │ │ │ All columns, full speed queries │ │ │ └─────────────────┬───────────────┘ │ │ │ TTL: +7 days → MOVE TO warm │ │ ↓ │ │ Day 7-90: WARM tier (Standard SSD) │ │ ┌─────────────────────────────────┐ │ │ │ card_number column TTL expired │ │ │ │ (set to default after 30 days) │ │ │ └─────────────────┬───────────────┘ │ │ │ TTL: +90 days → MOVE TO cold │ │ ↓ │ │ Day 90-365: COLD tier (HDD/S3) │ │ ┌─────────────────────────────────┐ │ │ │ Slower queries, cheap storage │ │ │ └─────────────────┬───────────────┘ │ │ │ TTL: +365 days → DELETE │ │ ↓ │ │ Day 365+: DELETED │ │ │ └────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
ClickHouse exposes performance data through system tables: system.query_log for query analysis, system.parts for storage health, system.merges for background operations, and system.replication_queue for replica sync status. Key metrics to monitor are query latency, memory usage per query, merge lag, parts count, and replication queue size.
Detailed Answer
Think of a car's dashboard: the speedometer (query latency), fuel gauge (disk usage), engine temperature (memory pressure), and check-engine light (error rates) each tell you something different about vehicle health. ClickHouse's system tables are that dashboard, providing real-time visibility into every aspect of the database engine without external monitoring agents.
ClickHouse maintains dozens of system tables that expose internal state. The most important ones for operations are: system.query_log (every query executed with timing, rows read, memory used, and errors), system.parts (every data part on disk with size, row count, and partition), system.merges (currently running background merges with progress and estimated completion), system.metrics (real-time gauges like active connections and running queries), system.events (cumulative counters like total queries, inserts, and merge operations), and system.replication_queue (pending replication tasks for ReplicatedMergeTree tables).
Internally, system.query_log is the single most valuable table for performance troubleshooting. Each completed query records: query duration (query_duration_ms), rows and bytes read (read_rows, read_bytes), peak memory usage (memory_usage), whether the primary key was effective (the ratio of read_rows to result_rows), and the query type. By analyzing slow queries you can identify missing index usage, excessive full scans, or memory-intensive operations. The ProfileEvents column contains detailed counters like FileOpen, DiskReadElapsedMicroseconds, and NetworkSendBytes for deep performance analysis.
At production scale, the metrics to alert on are: parts count per partition exceeding 300 (indicates merge backlog), replication_queue size growing (replica falling behind), memory usage per query approaching max_memory_usage setting, and query_duration_ms p99 exceeding SLA thresholds. Export these metrics to Prometheus using the built-in /metrics HTTP endpoint or the clickhouse-exporter, then build Grafana dashboards with time-series views and set up PagerDuty alerts for critical thresholds.
The non-obvious gotcha is that system.query_log itself is a MergeTree table that grows indefinitely unless you configure TTL on it (set query_log TTL in server config). In busy clusters processing thousands of queries per second, the query_log can consume significant disk space and become slow to query. Also, the query_log is written asynchronously after query completion, so there is a brief delay before a query appears. Use SYSTEM FLUSH LOGS to force immediate write when debugging.
Code Example
# Find the slowest queries in the last hour
SELECT
query_duration_ms,
read_rows,
formatReadableSize(read_bytes) AS data_read,
formatReadableSize(memory_usage) AS peak_memory,
query
FROM system.query_log
WHERE type = 'QueryFinish' -- Only completed queries
AND event_time >= now() - INTERVAL 1 HOUR
AND query_kind = 'Select' -- Only SELECT queries
ORDER BY query_duration_ms DESC
LIMIT 10; -- Top 10 slowest queries
# Check primary key effectiveness (read_rows >> result_rows = bad index usage)
SELECT
query,
read_rows,
result_rows,
round(read_rows / result_rows, 1) AS scan_ratio -- Closer to 1 = better
FROM system.query_log
WHERE type = 'QueryFinish'
AND result_rows > 0
AND event_time >= now() - INTERVAL 1 HOUR
ORDER BY scan_ratio DESC
LIMIT 10; -- Queries with worst index utilization
# Monitor parts count per table (alert if > 300 per partition)
SELECT
database,
table,
partition,
count() AS parts_count,
sum(rows) AS total_rows,
formatReadableSize(sum(bytes_on_disk)) AS disk_usage
FROM system.parts
WHERE active = 1
GROUP BY database, table, partition
HAVING parts_count > 50 -- Flag partitions with many unmerged parts
ORDER BY parts_count DESC;
# Check replication health
SELECT
database,
table,
type,
count() AS queue_size,
min(create_time) AS oldest_task
FROM system.replication_queue
GROUP BY database, table, type
ORDER BY queue_size DESC; -- Growing queue = replica falling behindInterview Tip
A junior engineer typically says they check Grafana dashboards without knowing which ClickHouse system tables power those dashboards. The interviewer wants you to name specific tables and what you look for: system.query_log for slow query analysis (check read_rows vs result_rows ratio), system.parts for merge backlog (parts count per partition), system.merges for current merge progress, and system.replication_queue for replica lag. Explain that you alert on parts count > 300, replication queue growth, and query memory approaching limits. Mentioning the scan_ratio metric and SYSTEM FLUSH LOGS for debugging shows operational depth.
◈ Architecture Diagram
┌─── ClickHouse Monitoring Stack ───────────────────┐ │ │ │ System Tables (internal): │ │ ┌──────────────────┬─────────────────────────┐ │ │ │ system.query_log │ Query perf & errors │ │ │ │ system.parts │ Storage health │ │ │ │ system.merges │ Background operations │ │ │ │ system.metrics │ Real-time gauges │ │ │ │ system.events │ Cumulative counters │ │ │ │ system.replicas │ Replication status │ │ │ └──────────────────┴─────────────────────────┘ │ │ │ │ │ ↓ │ │ ┌─────────────────────────┐ │ │ │ /metrics HTTP endpoint │ │ │ │ (Prometheus format) │ │ │ └────────────┬────────────┘ │ │ ↓ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ Prometheus │ → │ Grafana │ │ │ │ (scrape) │ │ + Alerting │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ Key Alerts: │ │ • parts_count > 300 per partition │ │ • replication_queue growing │ │ • query memory > 80% of max_memory_usage │ │ • p99 query latency > SLA threshold │ │ │ └────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
ReplicatedMergeTree uses ClickHouse Keeper (a ZooKeeper-compatible coordination service) to maintain a replication log. Each replica independently fetches data parts from peers. Inserts are deduplicated by block hash. Common failures include Keeper quorum loss, replication queue lag from slow networks, and split-brain scenarios during network partitions.
Detailed Answer
Think of a legal document signing system where multiple offices must maintain identical copies of all contracts. A central registry (ClickHouse Keeper) records which contracts exist and which office has which version. When Office A receives a new contract, it registers it in the central registry and other offices fetch a copy. If the central registry goes down, no new contracts can be registered, but existing copies remain accessible. If an office loses network, it falls behind but catches up when reconnected.
ReplicatedMergeTree is the replicated variant of MergeTree that provides data redundancy and high availability. Each replica is a full copy of the table data, and all replicas are peers (no primary/secondary distinction for reads). When an INSERT hits any replica, it writes the data as a new part and registers that part in ClickHouse Keeper's replication log. Other replicas watch this log and fetch the new part from the inserting replica. The replication is at the part level (entire files transferred), not row-by-row like streaming replication in PostgreSQL.
Internally, ClickHouse Keeper (or ZooKeeper) stores the replication log (an ordered list of operations: new parts, merges, mutations), the current parts list for each replica, and deduplication block hashes. Insert deduplication works by hashing each inserted block; if the same hash appears again (from a retry after a network timeout), the duplicate is silently ignored. This provides exactly-once INSERT semantics even with at-least-once delivery from clients. Merges are coordinated: one replica decides which parts to merge (becomes the merge leader), performs the merge, and other replicas fetch the merged result rather than independently merging.
At production scale, a typical setup has 2-3 replicas per shard with a 3-node ClickHouse Keeper ensemble. The Keeper ensemble requires a quorum (2 of 3 nodes) to accept writes. If Keeper loses quorum, no new inserts can be registered (INSERT queries fail or queue in client buffers), but SELECT queries on existing data continue working. Replication queue lag is the most common operational issue: a slow network between replicas or a replica that was down for extended time must catch up by fetching many parts, which consumes network bandwidth and can further slow the cluster.
The non-obvious gotcha is split-brain during network partitions between Keeper and ClickHouse nodes. If a replica cannot reach Keeper but can still receive client connections, it will accept INSERTs into local parts but cannot register them in the replication log. When connectivity restores, these orphan parts must be reconciled. ClickHouse handles this with the insert_quorum setting: set it to 2 to ensure INSERTs only succeed when at least 2 replicas confirm the write, preventing orphan parts at the cost of higher INSERT latency. Also, replication queue entries for very large parts (hundreds of GB) can take hours to transfer, during which the replica serves stale data for those partitions.
Code Example
# Create a replicated table (requires ClickHouse Keeper running)
CREATE TABLE events_log ON CLUSTER 'production_cluster' (
event_time DateTime,
service LowCardinality(String),
level LowCardinality(String),
message String,
trace_id String
) ENGINE = ReplicatedMergeTree(
'/clickhouse/tables/{shard}/events_log', -- ZK path (unique per shard)
'{replica}' -- Replica name from macros
)
PARTITION BY toDate(event_time)
ORDER BY (service, event_time);
# Check replication status across replicas
SELECT
database,
table,
replica_name,
is_leader,
absolute_delay, -- Seconds behind the leader
queue_size, -- Pending replication tasks
inserts_in_queue,
merges_in_queue,
last_queue_update
FROM system.replicas
WHERE table = 'events_log'; -- Shows health of each replica
# Check replication queue for stuck operations
SELECT
type,
source_replica,
new_part_name,
create_time,
now() - create_time AS age_seconds,
num_tries,
last_exception
FROM system.replication_queue
WHERE table = 'events_log'
ORDER BY create_time ASC
LIMIT 20; -- Oldest pending tasks indicate bottlenecks
# Insert with quorum to prevent orphan parts during network issues
SET insert_quorum = 2; -- Require 2 replicas to confirm INSERT
SET insert_quorum_timeout = 10000; -- Wait up to 10s for quorum
INSERT INTO events_log VALUES (now(), 'auth-service', 'error', 'timeout', 'abc-123');Interview Tip
A junior engineer typically describes replication as automatic data copying without understanding the coordination mechanism. The interviewer wants you to explain the role of ClickHouse Keeper (stores replication log and part registry), how parts are fetched between replicas (not streamed), and insert deduplication via block hashing. Discuss failure modes: Keeper quorum loss (INSERTs fail, SELECTs work), replication lag (replica serves stale data), and split-brain with orphan parts. Mention insert_quorum as the mitigation for split-brain and explain the system.replicas and system.replication_queue tables for monitoring health.
◈ Architecture Diagram
┌─── Replication Architecture ──────────────────────────────┐ │ │ │ ┌───────────────────────────┐ │ │ │ ClickHouse Keeper │ │ │ │ (3-node ensemble) │ │ │ │ │ │ │ │ • Replication log │ │ │ │ • Part registry │ │ │ │ • Dedup block hashes │ │ │ └─────┬───────────┬─────────┘ │ │ │ │ │ │ Register │ │ Watch log │ │ new part │ │ + fetch part │ │ ↓ ↓ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ Replica A │ │ Replica B │ │ │ │ (receives │──→│ (fetches │ │ │ │ INSERT) │ │ part from │ │ │ │ │ │ Replica A) │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ Failure Modes: │ │ • Keeper quorum lost → INSERT fails, SELECT works │ │ • Replica network down → queue grows, catches up later │ │ • Split brain → orphan parts (use insert_quorum=2) │ │ │ └────────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Sharding distributes data across multiple servers using the Distributed table engine with a shard key. More shards increase write throughput and scan parallelism but complicate queries that span shards (JOINs, global aggregations). Replication increases read concurrency by serving queries from multiple copies. The optimal design combines both: 2-3 replicas per shard for HA and multiple shards for throughput.
Detailed Answer
Think of a restaurant chain. Sharding is like opening multiple locations (each serves different neighborhoods and handles its own orders independently, increasing total throughput). Replication is like staffing each location with multiple cashiers (each handles a different customer simultaneously, increasing concurrent service at one location). You need both: multiple locations for capacity and multiple cashiers per location for responsiveness.
ClickHouse sharding works through the Distributed table engine, which acts as a transparent query router. A Distributed table knows the topology of your cluster (which shards exist and where replicas live). When you INSERT into a Distributed table, it routes rows to the appropriate shard based on the sharding key (a hash or modulo function applied to a column). When you SELECT from it, the query is fanned out to all shards, each processes its local data in parallel, and results are aggregated on the initiator node.
Internally, the shard key determines data locality. With shardNum() = cityHash64(user_id) % num_shards, all data for a given user_id lives on one shard. This enables local GROUP BY operations for user-scoped queries without cross-shard communication. For queries that filter by the shard key, only one shard needs to be contacted. However, queries that aggregate across all users must touch all shards and merge results on the initiator, which becomes a bottleneck for complex aggregations with high intermediate result cardinality.
At production scale, the key tradeoffs are: more shards = higher write throughput (each shard handles 1/N of inserts) and faster full-table scans (N-way parallelism), but worse performance for cross-shard JOINs (requires shuffling data between nodes) and higher operational complexity (more nodes to manage, coordinate schema changes, backup). Replication within each shard provides read concurrency (multiple replicas serve different SELECT queries simultaneously) and high availability (one replica can go down without data loss). The standard architecture for a 1TB/day analytics platform might be 4 shards x 2 replicas = 8 nodes.
The non-obvious gotcha is resharding. Unlike Kafka where you can add partitions, adding a shard to a ClickHouse cluster requires redistributing existing data because the shard key hash now maps to different servers. ClickHouse does not natively support online resharding (some experimental features exist but are not production-ready). Teams must plan shard count for peak capacity from the start or use a resharding strategy that involves creating a new cluster with more shards and backfilling data. Also, the Distributed table's INSERT does not guarantee atomicity across shards: a partial failure can leave data on some shards but not others.
Code Example
# Cluster topology defined in config.xml
-- <remote_servers>
-- <production_cluster>
-- <shard> <!-- Shard 1 -->
-- <replica><host>ch-shard1-replica1</host><port>9000</port></replica>
-- <replica><host>ch-shard1-replica2</host><port>9000</port></replica>
-- </shard>
-- <shard> <!-- Shard 2 -->
-- <replica><host>ch-shard2-replica1</host><port>9000</port></replica>
-- <replica><host>ch-shard2-replica2</host><port>9000</port></replica>
-- </shard>
-- </production_cluster>
-- </remote_servers>
# Local table on each shard (ReplicatedMergeTree for HA)
CREATE TABLE events_log_local ON CLUSTER 'production_cluster' (
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
payload String
) ENGINE = ReplicatedMergeTree(
'/clickhouse/tables/{shard}/events_log',
'{replica}'
)
PARTITION BY toDate(event_time)
ORDER BY (user_id, event_time);
# Distributed table that routes queries across all shards
CREATE TABLE events_log_distributed ON CLUSTER 'production_cluster' (
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
payload String
) ENGINE = Distributed(
'production_cluster', -- Cluster name
'default', -- Database
'events_log_local', -- Local table
cityHash64(user_id) -- Shard key: hash of user_id
);
# Insert via Distributed table (auto-routes to correct shard)
INSERT INTO events_log_distributed VALUES (now(), 12345, 'purchase', '{"amount": 99.99}');
# Query fans out to all shards, results merged on initiator
SELECT
event_type,
count() AS total,
uniq(user_id) AS users
FROM events_log_distributed
WHERE event_time >= now() - INTERVAL 1 HOUR
GROUP BY event_type;Interview Tip
A junior engineer typically conflates sharding with replication. The interviewer wants you to clearly distinguish: sharding splits data across nodes for throughput and parallelism, while replication copies data for availability and read concurrency. Explain the Distributed table engine as the query router, the shard key's role in data locality, and the fan-out/merge pattern for cross-shard queries. Discuss the resharding limitation (cannot add shards without data redistribution) as the most important planning consideration. Mentioning cross-shard JOIN limitations and the 'local table + distributed table' pattern shows architectural depth.
◈ Architecture Diagram
┌─── Sharded + Replicated Cluster ───────────────────────┐ │ │ │ ┌─── Distributed Table (Router) ───┐ │ │ │ cityHash64(user_id) % 2 │ │ │ └──────────┬───────────┬───────────┘ │ │ │ │ │ │ Shard 0 │ │ Shard 1 │ │ ↓ ↓ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ Replica A │ │ Replica A │ │ │ │ (user_id │ │ (user_id │ │ │ │ hash=even) │ │ hash=odd) │ │ │ ├──────────────┤ ├──────────────┤ │ │ │ Replica B │ │ Replica B │ │ │ │ (copy of │ │ (copy of │ │ │ │ shard 0) │ │ shard 1) │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ Sharding: splits data → more throughput │ │ Replication: copies data → more concurrency + HA │ │ │ └─────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
PREWHERE reads filter columns first and skips rows before reading remaining columns, reducing I/O by 2-10x. Array functions (arrayJoin, arrayMap, arrayFilter) process nested data without JOINs. Approximate aggregations (uniqHLL12, quantileTDigest) trade <2% accuracy for 10-100x speed improvement on high-cardinality operations.
Detailed Answer
Think of shopping for a specific book in a massive warehouse. The naive approach (WHERE) loads every book onto a cart and then checks the title. The smart approach (PREWHERE) first reads only the title labels on the shelves, discards non-matching sections, and only loads the full books that match. You move far less weight (I/O) by checking the lightweight filter column first.
PREWHERE is a ClickHouse-specific optimization that splits query execution into two phases. In phase one, only the columns used in the PREWHERE clause are read from disk, and non-matching rows are identified. In phase two, remaining columns are read only for rows that passed the PREWHERE filter. ClickHouse automatically converts WHERE to PREWHERE for conditions on columns not in the primary key (controlled by optimize_move_to_prewhere setting), but you can manually specify PREWHERE for maximum control. The benefit is dramatic when your filter is selective (eliminates 90%+ of rows) and the remaining columns are wide (large strings, many columns).
Internally, array functions provide a powerful alternative to JOINs for nested or multi-valued data. Instead of normalizing events with multiple tags into a separate tags table requiring a JOIN, you store tags as Array(String) and use arrayJoin to unnest, arrayMap to transform, arrayFilter to select, and hasAny/hasAll for membership tests. This keeps all data in a single table scan without the network overhead and memory cost of distributed JOINs. For time-series data with variable attributes, Nested types combine multiple arrays with matching indices.
Approximate aggregation functions are the key to interactive analytics on billion-row tables. uniqHLL12 uses HyperLogLog with 2^12 buckets for approximate distinct counts with ~1.5% error and 100x less memory than exact COUNT(DISTINCT). quantileTDigest approximates percentiles using a t-digest sketch, enabling p50/p95/p99 calculations on billions of values without sorting. These functions also compose with materialized views: store the sketch state with uniqState/quantileState and merge across time windows with uniqMerge/quantileMerge.
At production scale, combining these techniques yields dramatic performance improvements. A query on a table with 50 columns and 10 billion rows that filters by status (5% selectivity) and computes unique users: PREWHERE on status reads only the status column first (1 column instead of 50 = 98% I/O reduction), then reads user_id only for matching rows, and uniqHLL12 computes the distinct count using KB of memory instead of GB. The query completes in seconds instead of minutes.
The non-obvious gotcha is that PREWHERE should not be used on columns that are part of the ORDER BY key because those columns are already efficiently filtered by the sparse primary index in the WHERE phase. Using PREWHERE on primary key columns can actually slow queries by adding an extra processing step. Also, approximate functions have error bounds that matter: uniqHLL12's 1.5% error on 1 million users means your count could be off by 15,000. For billing or compliance reporting where exact counts matter, use uniqExact and accept the performance cost.
Code Example
# PREWHERE: read status column first, skip non-matching rows before loading payload
SELECT
user_id,
event_type,
payload -- Large column only read for matching rows
FROM events_log
PREWHERE status_code >= 500 -- Read status_code column first (small)
WHERE event_time >= now() - INTERVAL 1 HOUR -- Primary key filter in WHERE
LIMIT 100;
# Array functions: analyze tags without a separate JOIN table
CREATE TABLE user_actions (
user_id UInt64,
action_time DateTime,
tags Array(String), -- Multi-valued attribute stored inline
scores Array(Float32) -- Parallel array of scores
) ENGINE = MergeTree()
ORDER BY (user_id, action_time);
# Unnest arrays with arrayJoin
SELECT
tag,
count() AS usage_count
FROM user_actions
ARRAY JOIN tags AS tag -- Expands each row per tag element
WHERE action_time >= today() - 7
GROUP BY tag
ORDER BY usage_count DESC
LIMIT 20;
# Filter within arrays using arrayFilter
SELECT
user_id,
arrayFilter(x -> x > 0.8, scores) AS high_scores -- Only scores above 0.8
FROM user_actions
WHERE hasAny(tags, ['premium', 'vip']); -- Filter rows where tags contain either
# Approximate aggregations for interactive analytics
SELECT
toStartOfHour(event_time) AS hour,
uniqHLL12(user_id) AS approx_unique_users, -- ~1.5% error, 100x faster
quantileTDigest(0.95)(duration_ms) AS p95_approx, -- Approximate percentile
quantileTDigest(0.99)(duration_ms) AS p99_approx
FROM events_log
WHERE event_date = today()
GROUP BY hour
ORDER BY hour;Interview Tip
A junior engineer typically writes ClickHouse queries like PostgreSQL without leveraging ClickHouse-specific optimizations. The interviewer wants you to explain three key techniques: PREWHERE for I/O reduction (filter columns read first, skip rows before loading wide columns), array functions for denormalized data (avoid JOINs by storing nested data inline), and approximate aggregations for interactive speed (uniqHLL12 for distinct counts, quantileTDigest for percentiles). Discuss when NOT to use each: PREWHERE on primary key columns is counterproductive, and approximate functions are inappropriate for billing/compliance. Quantifying the improvement (10-100x) shows benchmarking experience.
◈ Architecture Diagram
┌─── PREWHERE Optimization ─────────────────────────────┐ │ │ │ Traditional WHERE: │ │ Read ALL columns → Filter → Return │ │ I/O: [col1][col2][col3]...[col50] for every row │ │ │ │ PREWHERE: │ │ Phase 1: Read [status] only → identify matching rows │ │ Phase 2: Read [col1][col2]...[col50] ONLY for matches │ │ I/O: 95% reduction when filter eliminates 95% rows │ │ │ ├─── Approximate vs Exact ──────────────────────────────┤ │ │ │ uniqExact(user_id): RAM: 8GB, Time: 45s │ │ uniqHLL12(user_id): RAM: 4KB, Time: 0.5s │ │ Error: ~1.5% (acceptable for dashboards) │ │ │ │ quantileExact(0.99): Sorts entire dataset │ │ quantileTDigest(0.99): Sketch-based, streaming │ │ │ └────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
The Altinity ClickHouse Operator manages ClickHouse clusters as Kubernetes custom resources, handling StatefulSets per shard/replica, persistent storage, ClickHouse Keeper deployment, and rolling upgrades. Key challenges include persistent volume management, pod anti-affinity for HA, resource isolation for mixed workloads, and coordinating schema changes across a distributed cluster.
Detailed Answer
Think of the Altinity Operator as a specialized property manager for a high-rise building (Kubernetes cluster) who knows exactly how to build and maintain database apartments (ClickHouse nodes). Instead of you manually wiring electricity, plumbing, and internet to each apartment, you describe what you want (3 shards, 2 replicas, 500GB each) and the property manager handles construction, maintenance, and repairs according to database-specific best practices.
The Altinity ClickHouse Operator (also known as clickhouse-operator) watches for ClickHouseInstallation custom resources and reconciles them into Kubernetes primitives. A single ClickHouseInstallation CR creates: StatefulSets (one per shard-replica combination), PersistentVolumeClaims (for data and logs), Services (per-replica for direct access, per-shard for load balancing, cluster-wide for Distributed table queries), ConfigMaps (for ClickHouse server configuration, users, and cluster topology), and optionally a ClickHouse Keeper StatefulSet for coordination.
Internally, the Operator handles the complex orchestration that makes running a stateful distributed database on Kubernetes viable. During rolling upgrades, it restarts one replica at a time, waits for the replication queue to drain (ensuring the restarted replica has caught up), and only proceeds to the next replica when the cluster is healthy. It injects macros (shard number, replica name) into each pod's configuration so ReplicatedMergeTree tables automatically know their identity. The Operator also reconciles cluster topology changes: adding a shard creates new StatefulSets and updates the remote_servers configuration on all existing nodes.
At production scale, the operational challenges are significant. Persistent storage must use high-performance storage classes (gp3, io2 on AWS, pd-ssd on GCP) because ClickHouse is I/O intensive during merges. Pod anti-affinity rules must spread replicas of the same shard across different nodes and availability zones to survive node or zone failures. Resource requests and limits must account for merge operations that temporarily spike CPU and memory usage (set memory limits 30% above normal to prevent OOM during large merges). Schema changes (ALTER TABLE) must be coordinated across all replicas via ON CLUSTER syntax.
The non-obvious gotcha is PersistentVolume expansion. When a ClickHouse node fills its disk, you need to resize the PVC, which requires the storage class to support volume expansion (allowVolumeExpansion: true) and may require a pod restart depending on the CSI driver. If the disk fills completely before you resize, ClickHouse enters read-only mode and cannot even perform background merges, creating a deadlock where the table is both full and has too many parts. Production clusters should alert at 70% disk usage and automate PVC expansion or have a TTL policy that prevents unbounded growth.
Code Example
# Install the Altinity ClickHouse Operator
kubectl apply -f https://github.com/Altinity/clickhouse-operator/raw/master/deploy/operator/clickhouse-operator-install-bundle.yaml
# ClickHouseInstallation custom resource for a 2-shard, 2-replica cluster
apiVersion: clickhouse.altinity.com/v1 # Altinity Operator API
kind: ClickHouseInstallation # Custom resource for ClickHouse cluster
metadata:
name: analytics-cluster
namespace: clickhouse
spec:
configuration:
clusters:
- name: production
layout:
shardsCount: 2 # 2 shards for horizontal throughput
replicasCount: 2 # 2 replicas per shard for HA
zookeeper: # ClickHouse Keeper for replication
nodes:
- host: analytics-cluster-keeper-0.keeper.clickhouse.svc
- host: analytics-cluster-keeper-1.keeper.clickhouse.svc
- host: analytics-cluster-keeper-2.keeper.clickhouse.svc
defaults:
templates:
podTemplate: clickhouse-pod
volumeClaimTemplate: data-volume
templates:
podTemplates:
- name: clickhouse-pod
spec:
affinity:
podAntiAffinity: # Spread replicas across nodes
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: clickhouse.altinity.com/chi
operator: In
values: [analytics-cluster]
topologyKey: kubernetes.io/hostname
containers:
- name: clickhouse
resources:
requests:
memory: 16Gi
cpu: "4"
limits:
memory: 24Gi # 30% headroom for merge spikes
cpu: "8"
volumeClaimTemplates:
- name: data-volume
spec:
storageClassName: gp3 # High-performance storage
resources:
requests:
storage: 500Gi # Per-replica storage
# Check cluster health
kubectl get chi -n clickhouse # ClickHouseInstallation status
kubectl get pods -n clickhouse -l clickhouse.altinity.com/chi=analytics-clusterInterview Tip
A junior engineer typically says they deployed ClickHouse with Helm charts or Docker, but for a senior role, the interviewer wants to hear about Operator-based lifecycle management. Explain how the Altinity Operator translates a ClickHouseInstallation CR into StatefulSets, PVCs, Services, and ConfigMaps. Discuss rolling upgrade coordination (drain replication queue before proceeding), pod anti-affinity for HA, resource limits with merge headroom, and the PVC expansion challenge. Mentioning the disk-full deadlock scenario and the 70% alert threshold shows real operational experience with stateful workloads on Kubernetes.
◈ Architecture Diagram
┌─── Altinity Operator Architecture ─────────────────────┐ │ │ │ ┌──────────────────────────────┐ │ │ │ ClickHouseInstallation CR │ │ │ │ (2 shards × 2 replicas) │ │ │ └──────────────┬───────────────┘ │ │ │ Operator reconciles │ │ ↓ │ │ ┌──────────── Shard 0 ────────────┐ │ │ │ StatefulSet-0-0 StatefulSet-0-1│ │ │ │ ┌─────────┐ ┌─────────┐ │ │ │ │ │Replica 0│ │Replica 1│ │ │ │ │ │PVC:500Gi│ │PVC:500Gi│ │ │ │ │ └─────────┘ └─────────┘ │ │ │ └─────────────────────────────────┘ │ │ ┌──────────── Shard 1 ────────────┐ │ │ │ StatefulSet-1-0 StatefulSet-1-1│ │ │ │ ┌─────────┐ ┌─────────┐ │ │ │ │ │Replica 0│ │Replica 1│ │ │ │ │ │PVC:500Gi│ │PVC:500Gi│ │ │ │ │ └─────────┘ └─────────┘ │ │ │ └─────────────────────────────────┘ │ │ ┌──────── ClickHouse Keeper ──────┐ │ │ │ Pod-0 Pod-1 Pod-2 │ │ │ │ (3-node Raft consensus) │ │ │ └─────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────┘
💬 Comments
Quick Answer
Migration requires data model redesign (denormalize, flatten nested structures), ETL pipeline construction (batch historical backfill + real-time CDC), materialized views for pre-aggregation, and query rewriting from Elasticsearch DSL or PostgreSQL SQL to ClickHouse-optimized SQL. The key challenge is shifting from mutable-record thinking to append-only event thinking.
Detailed Answer
Think of moving from a small city apartment (PostgreSQL) or a flexible co-working space (Elasticsearch) to a purpose-built warehouse (ClickHouse). You cannot just move your furniture as-is. The apartment had rooms for different purposes (normalized tables) that you need to consolidate into one open floor plan (denormalized wide table). The co-working space had flexible desk arrangements (schema-on-read) that you need to commit to a fixed layout (schema-on-write). The reorganization effort pays off in operational efficiency.
The data model transformation is the most critical step. PostgreSQL analytics typically involve multiple normalized tables with JOINs (users JOIN orders JOIN products). ClickHouse performs best with wide denormalized tables where all dimensions are pre-joined at insert time. Instead of users and orders tables with a foreign key relationship, create a single orders_analytics table that includes user_name, user_country, product_category inline. For Elasticsearch migrations, nested JSON objects must be flattened into typed columns, and full-text search fields need bloom filter indexes (tokenbf_v1) for approximate substring search capability.
Internally, the migration pipeline has two phases: historical backfill and ongoing real-time sync. For PostgreSQL, use clickhouse-local or INSERT INTO...SELECT FROM postgresql() table function for the initial bulk load, then set up Debezium CDC to capture ongoing INSERT/UPDATE/DELETE events as a Kafka stream consumed into ClickHouse with ReplacingMergeTree for mutable dimensions. For Elasticsearch, use elasticdump or scrolled queries to extract historical data in batches, then redirect the application's indexing pipeline to write to both Elasticsearch and ClickHouse during a transition period.
At production scale, the query rewrite layer is often the longest-running workstream. Elasticsearch aggregations (terms, date_histogram, percentiles) map to ClickHouse GROUP BY with toStartOfHour and quantile functions. PostgreSQL window functions (ROW_NUMBER, LAG, LEAD) work in ClickHouse with identical syntax. However, PostgreSQL CTEs that depend on previous CTE results must often be restructured as subqueries because ClickHouse CTEs are substituted inline (not materialized). JOINs in ClickHouse prefer the smaller table on the right side and use hash join by default, so query patterns from PostgreSQL that JOIN large tables may need restructuring or pre-materialization.
The non-obvious gotcha is that migration is not all-or-nothing. The most successful migrations follow a gradual pattern: keep the source system running, dual-write to ClickHouse, build new dashboards against ClickHouse while keeping existing dashboards on the old system, and only decommission the old system after 2-4 weeks of validation. Teams that attempt a big-bang cutover frequently discover data quality issues, missing query patterns, or performance edge cases that would have been caught in a parallel-run phase. Also, ClickHouse's eventual consistency (ReplacingMergeTree dedup timing, materialized view processing) requires adjusting expectations from users accustomed to PostgreSQL's immediate consistency.
Code Example
# Phase 1: Bulk load from PostgreSQL using table function
INSERT INTO payments_analytics
SELECT
o.order_date,
o.order_time,
o.user_id,
u.username, -- Denormalize: inline user data
u.country,
o.amount,
p.product_name, -- Denormalize: inline product data
p.category
FROM postgresql('pg-host:5432', 'app_db', 'orders', 'etl_user', 'password') AS o
JOIN postgresql('pg-host:5432', 'app_db', 'users', 'etl_user', 'password') AS u
ON o.user_id = u.id
JOIN postgresql('pg-host:5432', 'app_db', 'products', 'etl_user', 'password') AS p
ON o.product_id = p.id
WHERE o.order_date >= '2025-01-01'; -- Backfill from a specific date
# Phase 2: Real-time CDC with Kafka engine (Debezium format)
CREATE TABLE payments_cdc_queue (
user_id UInt64,
username String,
country String,
amount Decimal(12,2),
product_name String,
category String,
updated_at DateTime,
_operation LowCardinality(String) -- 'c'=create, 'u'=update, 'd'=delete
) ENGINE = Kafka()
SETTINGS
kafka_broker_list = 'kafka:9092',
kafka_topic_list = 'pg.public.orders', -- Debezium topic
kafka_group_name = 'clickhouse-consumer',
kafka_format = 'JSONEachRow';
# Materialized view to process CDC stream into target table
CREATE MATERIALIZED VIEW payments_cdc_mv TO payments_analytics AS
SELECT
toDate(updated_at) AS payment_date,
updated_at AS payment_time,
user_id,
username,
country,
amount,
product_name,
category
FROM payments_cdc_queue
WHERE _operation IN ('c', 'u'); -- Process creates and updates
# Query rewrite example: Elasticsearch terms aggregation → ClickHouse
-- ES: {"aggs": {"by_country": {"terms": {"field": "country", "size": 10}}}}
-- ClickHouse equivalent:
SELECT country, count() AS doc_count
FROM payments_analytics
GROUP BY country
ORDER BY doc_count DESC
LIMIT 10;Interview Tip
A junior engineer typically describes migration as just moving data, but the interviewer wants to hear about the full transformation: data model redesign (denormalize JOINs into wide tables), pipeline architecture (bulk backfill + real-time CDC with Debezium/Kafka), query rewriting (Elasticsearch DSL to SQL, PostgreSQL JOINs to pre-joined tables), and the parallel-run validation strategy. Mention specific ClickHouse features that enable migration: postgresql() table function for bulk loads, Kafka engine for CDC streams, ReplacingMergeTree for handling updates, and tokenbf bloom filter indexes as an Elasticsearch full-text search substitute. Discussing the gradual cutover approach shows operational maturity.
◈ Architecture Diagram
┌─── Migration Architecture ─────────────────────────────┐ │ │ │ Source Systems: │ │ ┌────────────┐ ┌───────────────┐ │ │ │ PostgreSQL │ │ Elasticsearch │ │ │ │ (OLTP) │ │ (Search) │ │ │ └─────┬──────┘ └───────┬───────┘ │ │ │ │ │ │ ↓ ↓ │ │ ┌──────────┐ ┌──────────────┐ │ │ │ Debezium │ │ elasticdump │ │ │ │ CDC │ │ (batch) │ │ │ └────┬─────┘ └──────┬───────┘ │ │ │ │ │ │ ↓ ↓ │ │ ┌─────────────────────────────────┐ │ │ │ Apache Kafka │ │ │ │ (CDC events + batch loads) │ │ │ └────────────────┬────────────────┘ │ │ │ │ │ ↓ │ │ ┌─────────────────────────────────┐ │ │ │ ClickHouse │ │ │ │ • Kafka Engine (consume CDC) │ │ │ │ • Materialized Views (transform)│ │ │ │ • ReplacingMergeTree (upserts) │ │ │ │ • Denormalized wide tables │ │ │ └─────────────────────────────────┘ │ │ │ │ Validation: parallel-run 2-4 weeks before cutover │ │ │ └─────────────────────────────────────────────────────────┘
💬 Comments
Symptom
Grafana alert: 'ClickHouse insert failures on payments_analytics table > 50/min' at 03:47 UTC. Application logs showing HTTP 500 responses from ClickHouse with rejection messages. The payments processing pipeline backed up 2.3 million events in Kafka consumer lag. Dashboard queries on the affected table timing out after 60 seconds. System.parts showing 48,000 active parts for the single table, growing by 200/second.
Error Message
DB::Exception: Too many parts (48291). Merges are processing significantly slower than inserts. Parts at partition 202606: 48291. See the 'parts_to_delay_insert' and 'parts_to_throw_insert' settings.
Root Cause
The payments microservice was refactored two weeks prior to emit events directly to ClickHouse via HTTP INSERT instead of batching through Kafka. Each payment transaction triggered an individual INSERT containing 1-5 rows. At peak traffic of 800 transactions per second, this created 800 new parts per second on the payments_analytics table. ClickHouse's merge scheduler processes background merges in a bounded number of threads (default 16), and each merge cycle can only combine up to 150 parts. At 800 parts/second inflow versus roughly 50 parts/second merge throughput, the active part count grew continuously. After running for 16 hours at moderate traffic, the part count crossed the parts_to_delay_insert threshold (300 by default) causing insert latency to spike, then crossed parts_to_throw_insert (300 by default in older versions, 3000 in newer) causing hard rejections. The merge scheduler was further hampered because each tiny part (1-5 rows) required the same file I/O overhead to merge as a large part, making merges inefficient. The problem compounded overnight when a batch analytics job also began inserting, creating competition for merge threads.
Diagnosis Steps
Solution
Immediately stop the direct inserts from the microservice to halt part count growth. Manually trigger merges using OPTIMIZE TABLE payments_analytics FINAL to force consolidation of existing parts (this is expensive but necessary during the incident). For the permanent fix, revert the microservice to publish events to Kafka and use the ClickHouse Kafka engine with kafka_max_block_size = 65536 to batch events into appropriately-sized blocks. Alternatively, enable async_insert = 1 with async_insert_max_data_size = 10000000 on the ClickHouse server, which buffers incoming inserts server-side and flushes them as a single part every few seconds. Set wait_for_async_insert = 1 for at-least-once delivery guarantees. After stabilization, configure parts_to_delay_insert = 150 and parts_to_throw_insert = 300 with Prometheus alerts at 100 parts to catch the issue before it becomes critical.
Commands
SELECT count() AS active_parts FROM system.parts WHERE table = 'payments_analytics' AND active
OPTIMIZE TABLE payments_analytics FINAL
ALTER TABLE payments_analytics MODIFY SETTING parts_to_delay_insert = 150, parts_to_throw_insert = 300
Prevention
Never allow application code to INSERT individual rows into ClickHouse. Enforce this with code review guidelines and a linting rule that detects single-row INSERT patterns. Set up Prometheus alerts on clickhouse_table_parts{state='active'} > 100 for early warning. Use the Kafka table engine or async_insert for all event-driven workloads. Load test INSERT patterns in staging to verify part creation rate stays below merge throughput.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────┐ │ Too Many Parts Cascade │ ├──────────────────────────────────────────────────────┤ │ │ │ Microservice: 800 INSERT/sec (1-5 rows each) │ │ │ │ │ ▼ │ │ ┌────────────────────────────────────┐ │ │ │ payments_analytics table │ │ │ │ Part creation: 800/sec │ │ │ │ Merge throughput: ~50/sec │ │ │ │ Net growth: +750 parts/sec │ │ │ └────────────────┬───────────────────┘ │ │ │ │ │ Timeline: ▼ │ │ T+0h: Parts = 200 (healthy) │ │ T+4h: Parts = 10,000 (delays start) │ │ T+8h: Parts = 25,000 (queries slow) │ │ T+16h: Parts = 48,291 (inserts rejected!) │ │ │ │ │ ▼ │ │ ┌────────────────────────────────────┐ │ │ │ Kafka consumer lag: 2.3M events │ │ │ │ Dashboard queries: TIMEOUT │ │ │ │ Pipeline: BLOCKED │ │ │ └────────────────────────────────────┘ │ │ │ │ FIX: Kafka engine (kafka_max_block_size=65536) │ │ → 1 part per 65K rows → merge easily keeps up │ └──────────────────────────────────────────────────────┘
💬 Comments
Symptom
Business team reporting discrepancies between dashboard numbers and raw data exports. Investigation revealed that queries hitting different ClickHouse replicas returned different row counts for the same time range, with differences of 2-4 million rows. system.replicas showed queue_size of 34,000 entries and absolute_delay of 14,400 seconds (4 hours) on replica 2. No alerts fired because replication lag monitoring was not configured. User-facing analytics showed data 4 hours behind real-time on 50% of requests (when load balancer routed to the lagging replica).
Error Message
SELECT absolute_delay, queue_size FROM system.replicas WHERE table = 'events_log' -- Returns: absolute_delay = 14400, queue_size = 34291
Root Cause
The events_log table receives 150,000 inserts per minute across the cluster. Replica 2 experienced a 45-minute network partition to ClickHouse Keeper at 23:15 UTC when a data center switch was being upgraded. During the partition, replica 2 could not fetch replication log entries from Keeper, causing them to queue up. When connectivity restored, replica 2 had to replay 45 minutes of accumulated replication entries (approximately 6.75 million INSERT blocks). However, the replica was also serving read queries from the load balancer, and its merge threads were competing with the replication fetch threads for disk I/O. The replication_fetch_threads setting was at the default of 4, which was insufficient to catch up while also serving reads. Additionally, the replicated_fetches_max_bandwidth was set to 100MB/s, throttling replication throughput. The replica fell further behind as new inserts continued arriving while it processed the backlog. After 4 hours, it had cleared only 40% of the initial backlog because new entries continued accumulating faster than they were being replayed under the I/O contention.
Diagnosis Steps
Solution
Immediately remove the lagging replica from the load balancer to prevent users from seeing stale data. Increase replication_fetch_threads from 4 to 16 on the lagging replica to accelerate catch-up. Remove the replicated_fetches_max_bandwidth limit temporarily. If the queue is too large to catch up within an acceptable timeframe, consider dropping the replica's local data and re-syncing from scratch using SYSTEM RESTORE REPLICA. After catch-up is complete, restore the replica to the load balancer. For long-term fix, configure max_replica_delay_for_distributed_queries = 300 on all Distributed table queries so that the query router automatically skips replicas lagging more than 5 minutes. Add Prometheus alerting on clickhouse_replication_queue_size > 100 and clickhouse_replication_absolute_delay_seconds > 300. Increase replication_fetch_threads to 8 as the baseline.
Commands
SELECT replica_name, queue_size, absolute_delay FROM system.replicas WHERE table = 'events_log'
SYSTEM START REPLICATION QUEUES events_log
ALTER TABLE events_log ON CLUSTER '{cluster}' MODIFY SETTING replicated_fetches_max_bandwidth_for_server = 0Prevention
Configure max_replica_delay_for_distributed_queries = 300 to automatically route queries away from lagging replicas. Set Prometheus alerts on absolute_delay > 300 seconds and queue_size > 100 entries. Increase default replication_fetch_threads to 8 for high-throughput tables. During planned maintenance windows that affect network connectivity to Keeper, proactively remove replicas from load balancers and increase fetch thread counts pre-emptively.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────┐ │ Replication Queue Backlog Timeline │ ├──────────────────────────────────────────────────────┤ │ │ │ 23:15 ─── Network partition to Keeper (45 min) ──── │ │ │ │ │ ▼ │ │ Replica 1 (leader): Replica 2 (lagging): │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ Accepting │ │ Cannot fetch │ │ │ │ inserts OK │ │ from Keeper │ │ │ │ 150K/min │ │ Queue builds │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ 00:00 ─── Network restores ─────────────────────── │ │ │ │ │ ▼ │ │ Replica 2 backlog: 6.75M entries │ │ Fetch threads: 4 (too few) │ │ I/O contention: read queries + merges + fetches │ │ Catch-up rate: < new insert rate │ │ │ │ │ ▼ │ │ 04:00 ─── 4 hours later ────────────────────────── │ │ Queue still has 34,000 entries │ │ Load balancer sends 50% of reads to stale replica │ │ Users see: different numbers on page refresh │ │ │ │ FIX: Remove from LB → increase fetch threads → 16 │ │ → clear backlog → restore to LB │ └──────────────────────────────────────────────────────┘
💬 Comments
Symptom
PagerDuty critical alert: 'ClickHouse pod clickhouse-shard0-replica0 OOMKilled' at 14:32 UTC. All active queries terminated simultaneously. Grafana dashboards showing 'connection refused' errors. Kafka consumer lag spiking to 5 million events. Application error logs: 'Connection to ClickHouse lost, retrying.' Pod restart took 45 seconds, during which 100% of analytics queries failed. The pod restarted successfully but was OOM-killed again 3 minutes later when the same query was retried by an automated report scheduler.
Error Message
Last State: Terminated, Reason: OOMKilled, Exit Code: 137 kubectl describe pod: The container was killed because it exceeded its memory limit (32Gi).
Root Cause
An automated daily report job executed a GROUP BY query on the user_actions table with 890 million rows, grouping by user_id (47 million unique values). ClickHouse builds an in-memory hash table for GROUP BY operations, and with 47 million unique user_ids each holding aggregation state (count, sum, avg across 5 metrics), the hash table grew to approximately 38GB. The ClickHouse pod had a Kubernetes memory limit of 32Gi and no max_memory_usage setting configured at the query or user profile level. When the hash table allocation exceeded 32GB, the kernel OOM killer terminated the ClickHouse process. The pod restarted via the Kubernetes restart policy, but the report scheduler detected the failed query and immediately retried it, triggering the same OOM kill cycle. Without max_bytes_before_external_group_by configured, ClickHouse had no mechanism to spill the hash table to disk. The issue had not appeared before because a previous version of the report filtered by region (limiting unique user_ids to 3 million), but a recent change removed the region filter to generate a global report.
Diagnosis Steps
Solution
First, prevent the query from being retried by pausing the report scheduler. Then configure memory protection settings at the server level and per user profile. Set max_memory_usage = 20000000000 (20GB, leaving headroom below the 32GB pod limit) in the default profile. Set max_bytes_before_external_group_by = 10000000000 (10GB) to spill large GROUP BY hash tables to disk when they exceed 10GB. This allows the report query to complete by using disk-backed aggregation instead of failing with OOM. Restart the ClickHouse pod with the new configuration. Re-enable the report scheduler and verify it completes (slower due to disk spill, but successful). For the long-term fix, create a materialized view that pre-aggregates daily user metrics so the report queries the pre-aggregated table (47M rows with partial states) instead of scanning 890M raw rows. Increase the pod memory limit to 48Gi and set max_server_memory_usage to 40GB.
Commands
kubectl get pods -n clickhouse -l app=clickhouse -o wide
kubectl exec clickhouse-shard0-replica0 -n clickhouse -- clickhouse-client --query "SET max_memory_usage = 20000000000; SET max_bytes_before_external_group_by = 10000000000;"
kubectl exec clickhouse-shard0-replica0 -n clickhouse -- clickhouse-client --query "SELECT name, value FROM system.settings WHERE name IN ('max_memory_usage', 'max_bytes_before_external_group_by')"kubectl patch statefulset clickhouse-shard0 -n clickhouse --type=json -p '[{"op":"replace","path":"/spec/template/spec/containers/0/resources/limits/memory","value":"48Gi"}]'Prevention
Always configure max_memory_usage and max_bytes_before_external_group_by in ClickHouse production deployments. Set max_memory_usage to 70% of the pod memory limit to leave room for OS buffers and ClickHouse internal overhead. Alert on container_memory_working_set_bytes / container_spec_memory_limit_bytes > 0.75 sustained for 2 minutes. Require all scheduled report queries to be tested with EXPLAIN and profiled for memory usage before promotion to production.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────┐ │ OOM Kill → Retry → OOM Kill Loop │ ├──────────────────────────────────────────────────────┤ │ │ │ Report scheduler fires daily query: │ │ SELECT user_id, count(), sum(amount) │ │ FROM user_actions GROUP BY user_id │ │ (47M unique user_ids) │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────┐ │ │ │ ClickHouse builds hash table │ │ │ │ 47M entries × 800 bytes = 38GB │ │ │ │ Pod memory limit: 32GB │ │ │ └───────────────┬──────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────┐ │ │ │ OOM KILLED (exit code 137) │ │ │ │ ALL concurrent queries LOST │ │ │ │ Kafka consumer: disconnected │ │ │ └───────────────┬──────────────────┘ │ │ │ 45s restart │ │ ▼ │ │ ┌──────────────────────────────────┐ │ │ │ Pod restarts → scheduler retries│ │ │ │ Same query → same OOM → LOOP │ │ │ └──────────────────────────────────┘ │ │ │ │ FIX: max_bytes_before_external_group_by = 10GB │ │ → hash table spills to disk at 10GB │ │ → query completes (slower but alive) │ └──────────────────────────────────────────────────────┘
💬 Comments
Symptom
Dashboard latency degraded from 400ms to 18 seconds over three weeks. No single event triggered the degradation; it was gradual. system.query_log showed read_rows and read_bytes increasing daily for the same queries. ClickHouse server logs showing frequent 'merged X parts into Y' messages but part count continuing to climb. ZooKeeper memory usage grew from 500MB to 3.2GB. Replica synchronization time increased from seconds to 15-20 minutes after adding new nodes.
Root Cause
A developer changed the partition key of the clickstream_data table from PARTITION BY toYYYYMM(event_date) to PARTITION BY (toYYYYMMDD(event_date), region) intending to improve query pruning for region-specific queries. With 365 days and 42 regions, this created up to 15,330 partitions per year instead of 12. Each partition maintains its own set of parts that cannot be merged across partition boundaries, so even with proper batch inserts, every INSERT block was split into 42 sub-blocks (one per region), each becoming a separate part in its respective partition. The merge scheduler could not keep up because merges happen within partitions, and with 15,330 partitions each containing multiple small parts, the merge workload exploded. ZooKeeper stored metadata for each partition and part, growing from 500MB to 3.2GB, causing ZooKeeper GC pauses. Queries could not benefit from the partition pruning because most dashboard queries filtered by date range across all regions, meaning ClickHouse had to open and read from thousands of partitions instead of 1-2 monthly partitions.
Diagnosis Steps
Solution
The partition key change must be reverted but ALTER TABLE MODIFY PARTITION BY is not supported in ClickHouse. Create a new table with the correct monthly partition key. Use INSERT INTO new_table SELECT FROM old_table with a LIMIT and OFFSET pattern to migrate data in chunks of 10 million rows to avoid memory issues. After migration, rename tables atomically with RENAME TABLE clickstream_data TO clickstream_data_old, clickstream_data_new TO clickstream_data. Update the ZooKeeper path for ReplicatedMergeTree tables. Verify query performance returns to baseline after migration. For the region filter use case that motivated the change, add region to the ORDER BY key instead of the partition key, which gives the sparse index granule-level pruning without the partition explosion. Drop the old table after verification period.
Commands
SELECT uniq(partition) AS partitions, count() AS parts FROM system.parts WHERE table = 'clickstream_data' AND active
CREATE TABLE clickstream_data_new AS clickstream_data ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/clickstream_data_new', '{replica}') PARTITION BY toYYYYMM(event_date) ORDER BY (region, user_id, event_date, timestamp)INSERT INTO clickstream_data_new SELECT * FROM clickstream_data
RENAME TABLE clickstream_data TO clickstream_data_old, clickstream_data_new TO clickstream_data
Prevention
Establish a partition key policy: only partition by a time column with monthly or daily granularity, never by a categorical dimension. Add a CI check that validates CREATE TABLE and ALTER TABLE statements against a partition count estimator. Document that ClickHouse partition pruning is rarely needed because the ordering key already provides fine-grained data skipping. Alert when system.parts shows more than 500 unique partitions for any table.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────┐ │ Over-Partitioning Impact Over Time │ ├──────────────────────────────────────────────────────┤ │ │ │ Before (monthly): After (daily × region): │ │ Partitions: 12/year Partitions: 15,330/year │ │ │ │ ┌────────┐ ┌─┐┌─┐┌─┐...×15,330 │ │ │ 202601 │ │ ││ ││ │ │ │ │ 202602 │ └─┘└─┘└─┘ │ │ │ ... │ │ │ │ 202612 │ Each INSERT split into │ │ └────────┘ 42 parts (per region) │ │ │ │ Query: WHERE event_date BETWEEN '2026-01-01' │ │ AND '2026-03-31' │ │ │ │ Monthly: scan 3 partitions → 400ms │ │ Daily×Region: scan 3780 partitions → 18 seconds │ │ │ │ Week 1: Dashboard: 800ms (degrading) │ │ Week 2: Dashboard: 5s (noticeable) │ │ Week 3: Dashboard: 18s (unusable) │ │ │ │ ZooKeeper: 500MB → 3.2GB (GC pauses) │ │ Replica sync: seconds → 15 minutes │ └──────────────────────────────────────────────────────┘
💬 Comments
Symptom
All inserts to all ReplicatedMergeTree tables failing simultaneously across the entire cluster at 02:30 UTC. Application logs flooded with 'Coordination::Exception: Connection loss' errors. SELECT queries on existing data still working. system.zookeeper queries returning errors. ClickHouse Keeper logs showing repeated 'Cannot elect leader' messages. Three-node Keeper ensemble with all nodes reporting 'Looking for leader' state. Kafka consumer lag growing at 500K events/minute across all topics.
Error Message
DB::Exception: Coordination::Exception: Connection loss. (KEEPER_EXCEPTION) Code: 999. DB::Exception: Cannot allocate block number in partition 202606. (KEEPER_EXCEPTION)
Root Cause
The ClickHouse Keeper ensemble consisted of three nodes running on the same physical hosts as ClickHouse server processes. A compaction job on Keeper node 1 triggered heavy disk I/O that caused Keeper heartbeat responses to exceed the tick_time of 2000ms. Keeper node 2 then initiated a new leader election, but during the election, Keeper node 3 also experienced high disk I/O from a concurrent ClickHouse merge operation on the co-located server, causing its heartbeat to timeout as well. With all three nodes experiencing intermittent heartbeat failures within the same election round, no node could achieve a quorum (2 out of 3) to complete the election. The election algorithm entered a retry loop with exponential backoff, but each retry coincided with ongoing disk I/O from the merge scheduler and Keeper snapshot/compaction operations. The cluster remained leaderless for 12 minutes until the merge operations completed and disk I/O subsided enough for heartbeats to stabilize. During those 12 minutes, all INSERT operations and DDL commands failed cluster-wide because they require Keeper coordination for block number allocation and replication log entries.
Diagnosis Steps
Solution
Immediately isolate Keeper from disk I/O contention by restarting Keeper nodes one at a time with ionice -c2 -n0 priority. If the election is still stuck, restart all three Keeper nodes in sequence with 30-second gaps to force a clean election. Once Keeper has a leader, verify with echo stat | nc keeper-host 9181 | grep Mode showing 'leader' on one node and 'follower' on others. Then verify inserts resume by running a test INSERT. For the permanent fix, migrate Keeper to dedicated hosts (or at minimum dedicated SSDs) separate from ClickHouse server workloads. Increase tick_time to 3000ms and init_limit/sync_limit to 10 to tolerate brief I/O spikes. Configure Keeper's datadir and snapshot_storage_path on dedicated NVMe volumes separate from ClickHouse data directories. If running on Kubernetes, use separate StatefulSets with dedicated PVCs for Keeper pods.
Commands
echo mntr | nc clickhouse-keeper-1 9181
echo mntr | nc clickhouse-keeper-2 9181
echo mntr | nc clickhouse-keeper-3 9181
systemctl restart clickhouse-keeper
Prevention
Never co-locate ClickHouse Keeper on the same disk as ClickHouse server data. Use dedicated NVMe volumes or separate hosts for Keeper. Set Keeper tick_time to 3000ms and increase sync_limit to 10 for production environments with varying I/O loads. Monitor Keeper latency with zk_avg_latency metric and alert when it exceeds 500ms. Configure ClickHouse merge scheduler to limit I/O impact with max_bytes_to_merge_at_max_space_in_pool and background_merges_mutations_concurrency_ratio.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────┐ │ Keeper Leader Election Failure │ ├──────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Keeper-1 │ │ Keeper-2 │ │ Keeper-3 │ │ │ │ + CH Server │ │ + CH Server │ │ + CH Server │ │ │ │ (shared │ │ (shared │ │ (shared │ │ │ │ disk!) │ │ disk!) │ │ disk!) │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ 02:30 │ Compaction │ │ │ │ │ + merge I/O │ Election │ merge I/O │ │ │ → heartbeat │ starts │ → timeout │ │ │ timeout │ │ │ │ │ │ │ │ │ └───────────────┴───────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ NO QUORUM ACHIEVED │ │ │ │ All nodes: Looking │ │ │ │ for leader (12 min) │ │ │ └──────────┬──────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ ALL inserts fail │ │ │ │ ALL DDL fails │ │ │ │ Kafka lag: 6M │ │ │ │ SELECTs still work │ │ │ └─────────────────────┘ │ │ │ │ FIX: Dedicated disks for Keeper │ │ Increase tick_time to 3000ms │ └──────────────────────────────────────────────────────┘
💬 Comments
Symptom
Product analytics dashboard response time degraded from 200ms to 22 seconds after a query optimization that was supposed to improve data accuracy. CPU usage on ClickHouse nodes spiked from 35% to 92% sustained. Other dashboards sharing the same cluster experienced 3-5x latency increase due to CPU contention. Grafana panels showing timeout errors for queries exceeding 30-second limit. The issue appeared immediately after a PR was merged that added FINAL to dashboard queries.
Root Cause
The events_log table used ReplacingMergeTree to handle duplicate events from at-least-once delivery. A developer noticed that some dashboard counts were slightly inflated due to unmerged duplicates (ClickHouse deduplicates lazily during background merges, not at insert time). They added the FINAL modifier to all dashboard SELECT queries, which forces ClickHouse to perform on-the-fly deduplication at query time by reading all parts for the queried partitions, sorting them by the ordering key, and applying the replacement logic in real-time. For a table with 200 active parts across 3 monthly partitions and 4.2 billion rows, FINAL forced a full sort-merge of all parts during each query execution. The on-the-fly merge consumed all available CPU cores and required reading entire partitions from disk instead of just the relevant granules. The sparse index optimization was partially negated because FINAL must read all versions of a row to determine the latest one. With 15 concurrent dashboard users each running FINAL queries every 10 seconds, the cluster was perpetually under full CPU load processing sort-merge operations.
Diagnosis Steps
Solution
Immediately revert the FINAL modifier from all dashboard queries to restore latency. Accept the minor count inflation (typically 0.01-0.1% with proper batch inserts) or handle deduplication differently. For accurate counts without FINAL, use OPTIMIZE TABLE events_log PARTITION '202606' FINAL during off-peak hours to force merge-time deduplication, then queries without FINAL return accurate results until new unmerged parts arrive. For the long-term solution, create a materialized view using AggregatingMergeTree with argMax to maintain the latest version of each event, pre-deduplicated at insert time. Alternatively, use the do_not_merge_across_partitions_select_final setting (available in recent versions) to limit FINAL scope, or use subqueries with GROUP BY on the deduplication key with argMax to achieve targeted deduplication on specific columns without the full table merge cost.
Commands
SELECT query, query_duration_ms, read_rows FROM system.query_log WHERE query LIKE '%FINAL%' AND event_date = today() ORDER BY query_duration_ms DESC LIMIT 10
OPTIMIZE TABLE events_log PARTITION '202606' FINAL
EXPLAIN PIPELINE SELECT count() FROM events_log FINAL WHERE service = 'payments'
Prevention
Document in team runbooks that FINAL should never be used in latency-sensitive dashboard queries. Use FINAL only in batch jobs running during off-peak hours with resource limits. For real-time deduplication needs, design materialized views with AggregatingMergeTree and argMax that handle deduplication at insert time. Add a query analysis rule in CI/CD that flags FINAL usage in dashboard query definitions. Monitor MergedRows profile event metric to detect FINAL abuse.
◈ Architecture Diagram
┌──────────────────────────────────────────────────────┐ │ FINAL Modifier Performance Impact │ ├──────────────────────────────────────────────────────┤ │ │ │ WITHOUT FINAL (normal query): │ │ ┌────────────────────────────────────┐ │ │ │ Sparse index → skip 98% granules │ │ │ │ Read 2 parts × matching granules │ │ │ │ Return rows (may have 0.1% dupes) │ │ │ │ Time: 200ms │ │ │ └────────────────────────────────────┘ │ │ │ │ WITH FINAL (forced dedup): │ │ ┌────────────────────────────────────┐ │ │ │ Read ALL 200 parts for partition │ │ │ │ Sort-merge 4.2B rows by ORDER BY │ │ │ │ Compare versions, keep latest │ │ │ │ THEN apply WHERE filter │ │ │ │ Time: 22,000ms (100x slower) │ │ │ └────────────────────────────────────┘ │ │ │ │ 15 users × 1 query/10s = 1.5 FINAL queries/sec │ │ Each consuming full CPU for 22 seconds │ │ Cluster CPU: 35% → 92% sustained │ │ │ │ BETTER APPROACH: │ │ ┌────────────────────────────────────┐ │ │ │ MV with argMax for latest version │ │ │ │ Pre-deduplicates at insert time │ │ │ │ Dashboard queries: no FINAL needed│ │ │ │ Time: 200ms (accurate + fast) │ │ │ └────────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘
💬 Comments