swecrets

Debugging with System Tables

Internals & Failure — Rung 530 min read

Learning Objectives

  • Read system.mutations to diagnose whether an ALTER UPDATE/DELETE is progressing, stuck, or failed, interpreting is_done, parts_to_do, and latest_fail_reason
  • Read system.merges to see currently in-progress background merges and mutations live, and explain why mutations and merges compete for the same background execution pool
  • Trace a stuck-mutation symptom to root cause by combining system.mutations, system.merges, and system.parts into one diagnostic narrative rather than reading any single table in isolation
  • Diagnose which specific query exceeded a memory limit using system.query_log's memory and exception columns, distinguishing one runaway query from sustained memory pressure across many

Why This Matters

the-system-database, back in Module 2, gave you three tables for a simple question: what exists, what's running, what already ran. mergetree-on-disk-format just showed you what's actually sitting in a part's directory. Neither one answers the question that actually shows up in a production incident: "I ran an ALTER TABLE ... DELETE yesterday and the data is still there — is it still working, or is it stuck?" That question isn't about a part's structure or a query's history. It's about a background job — a long-running, stateful, potentially-failing process ClickHouse is (or isn't) actively working on right now. This lesson covers the two system tables that expose that: system.mutations and system.merges. More importantly, it covers the actual skill real debugging requires and no single lesson before this one has forced: reading several system tables together, as one narrative, because no production incident is ever fully explained by one query against one table.

The mental model: work in progress, not just state

Every system table this course has used so far describes state — what a table looks like, what a query did, what's in a part. system.mutations and system.merges describe something different: work ClickHouse is actively doing, tracked with its own lifecycle, its own progress, and its own failure modes, exposed with the same SELECT you already know.

The relationship between the two is the whole mental model, and it's easy to get backwards:

system.mutations is the job ticket — one row per ALTER ... UPDATE/DELETE command, tracking overall progress across every part that command needs to touch, persisting for a while after it finishes. Its source-level backing, MergeTreeMutationEntry, is documented plainly: "A mutation entry for non-replicated MergeTree storage engines. Stores information about mutation in file mutation_*.txt." is_done tells you whether every part the command touches has been rewritten; parts_to_do tells you how many haven't yet; latest_fail_reason tells you what went wrong the last time ClickHouse tried and failed on one of them.

system.merges is the live work queue — one row per task ClickHouse is executing right now, merge or mutation alike, and the row vanishes the instant that task finishes, the same "live snapshot" behavior the-system-database already taught you for system.processes. Its own documentation states it plainly: it "contains information about merges and part mutations currently in process for tables in the MergeTree family." A row's is_mutation column tells you which kind of task it is — 1 for a mutation, 0 for an ordinary merge — but they're rows in the same table, which is the detail worth sitting with: a mutation isn't a separate mechanism from a merge. It's scheduled and executed through the exact same background pool.

That's not a coincidence of implementation — it's load-bearing. MergeTreeBackgroundExecutor, the class that actually runs both, describes itself as an "Executor for a background MergeTree related operations such as merges, mutations, fetches and so on." A shared pool means shared competition: a table under heavy insert load, already leaning on merges to keep part counts down, can leave a mutation waiting for a free slot for a long time — and ClickHouse deliberately throttles this in the other direction too, via number_of_free_entries_in_pool_to_execute_mutation: "when there is less than [the] specified number of free entries in [the] pool, do not execute part mutations. This is to leave free threads for regular merges and to avoid 'Too many parts' [errors]." A stuck-looking mutation and the "too many parts" failure mergetree-on-disk-format already covered aren't unrelated symptoms — they're two views of the same shared, finite resource.

system.mutations — one row per ALTER commandmutation_5821 DELETE WHERE tenant_id=501is_done=0 parts_to_do=3part Apart Bpart Csystem.merges — live, right nowis_mutation=1progress=0.4no rowno rowB and C: queued, waiting for a free pool slot — not executing, not failed.mutations says "not done." merges says whether it's actually running right now, or waiting its turn.
One job ticket, many possible live tasks. A left box labeled 'system.mutations — one row per ALTER command' containing a single row: 'mutation_5821, DELETE WHERE tenant_id=501, is_done=0, parts_to_do=3.' Three arrows point right from that row to three small boxes representing individual parts still needing the mutation applied. Below, a second row labeled 'system.merges — live, right now' shows only ONE of those three parts currently has an active row (is_mutation=1, progress=0.4), while the other two are shown grayed out with a caption 'queued — waiting for a free background pool slot, not yet executing, not failed.' A caption at the bottom reads: 'system.mutations tells you the mutation is not done. system.merges tells you whether it is actually running right now, or waiting its turn.'

Codebase tour: where this lives in ClickHouse's source

Codebase Tour

  1. src/Storages/MergeTree/MergeTreeMutationEntry.h / .cpp

    The source-level struct behind every system.mutations row for non-replicated tables — the mutation command, its creation time, which part (if any) it last failed on, and why.

  2. src/Interpreters/MutationsInterpreter.h / .cpp

    Turns an ALTER TABLE ... UPDATE/DELETE command into the actual per-part transformation ClickHouse applies — the piece that decides, for a given part, which rows survive and what the rewritten columns look like.

  3. src/Storages/MergeTree/MergeTreeBackgroundExecutor.h / .cpp

    The shared scheduler behind this lesson's central mental model: merges and mutations aren't run by separate mechanisms, they're both ExecutableTask implementations competing for slots in the same pool, self-suspending as coroutines so many tasks can be fairly interleaved.

  4. src/Storages/MergeTree/MergeTreeDataMergerMutator.h / .cpp

    Selection logic: given a table's current parts and pending mutations, decides what to merge next and how much background-pool capacity to reserve for it.

  5. src/Storages/System/StorageSystemMutations.cpp

    The literal implementation of the system.mutations table — if you ever want to know exactly where a specific column's value comes from, rather than trusting the docs page's description of it, this is the file that assembles every row.

Debugging walkthrough: "the DELETE ran, but the data's still there"

Symptom. A team runs ALTER TABLE events DELETE WHERE tenant_id = 501 on a large, long-running table, expecting the matching rows to disappear. A day later, those rows are still queryable, and disk usage hasn't dropped. A follow-up attempt to run OPTIMIZE TABLE events FINAL fails outright with an error about the background pool being full.

Step 1 — confirm the mutation itself, not the symptom's surface. ALTER TABLE ... DELETE is not instant — it's a mutation, queued and applied in the background, exactly like the codec change mergetree-on-disk-format covered was metadata-only until a merge (or, here, the mutation itself) actually rewrote the affected parts. The first move isn't to guess — it's to read the job ticket:

Checking the mutation's own statussql
SELECT mutation_id, command, is_done, parts_to_do, latest_fail_reason, latest_fail_time
FROM system.mutations
WHERE database = 'default' AND table = 'events' AND NOT is_done
ORDER BY create_time;
Result — this mutation is not done, and has failed at least oncetext
┌─mutation_id─┬─command────────────────────┬─is_done─┬─parts_to_do─┬─latest_fail_reason──────────────┬──────latest_fail_time─┐
│ 0000000012  │ DELETE WHERE tenant_id=501 │       0 │           4 │                                  │  1970-01-01 00:00:00   │
└─────────────┴────────────────────────────┴─────────┴─────────────┴──────────────────────────────────┴────────────────────────┘

is_done = 0 confirms this: the mutation genuinely hasn't finished. But latest_fail_reason is empty and latest_fail_time is the zero-value timestamp — this mutation hasn't actually failed on anything. It just hasn't finished. That distinction matters: an empty latest_fail_reason with parts_to_do > 0 points toward "still working, or not yet scheduled" rather than "broken and needs a fix," which is exactly the ambiguity Step 2 exists to resolve.

Step 2 — check whether it's actually running right now, or waiting its turn. system.mutations alone can't answer "is ClickHouse working on this at this exact moment" — for that, you need the live table:

Is this mutation an active task right now?sql
SELECT database, table, mutation_id, is_mutation, progress, elapsed, memory_usage
FROM system.merges
WHERE table = 'events';
Result — no rows at alltext
(0 rows)

Zero rows in system.merges for this table, combined with the OPTIMIZE ... FINAL failure citing a full background pool, points at the actual root cause: this mutation isn't broken — it's queued, waiting for a free slot in a pool that's currently saturated by something else. This is precisely the documented purpose of number_of_free_entries_in_pool_to_execute_mutation, deliberately holding mutations back when the pool is busy with regular merges, to avoid starving the merges a heavy-insert table needs to avoid its own "too many parts" failure.

Step 3 — confirm the pool really is the bottleneck, not guess. system.merges, queried without the table filter, shows what's actually consuming the shared pool right now:

What is the background pool actually busy with?sql
SELECT database, table, is_mutation, num_parts, progress, elapsed
FROM system.merges
ORDER BY elapsed DESC
LIMIT 10;
Result — the pool is full of ordinary merges on a different, high-ingest tabletext
┌─database─┬─table──────────┬─is_mutation─┬─num_parts─┬─progress─┬─elapsed─┐
│ default  │ ad_impressions │           0 │         6 │     0.71 │  184.2  │
│ default  │ ad_impressions │           0 │         4 │     0.35 │   92.6  │
│ default  │ ad_impressions │           0 │         9 │     0.12 │   41.0  │
│ ...      │ ...            │           0 │       ... │      ... │    ...  │
└──────────┴────────────────┴─────────────┴───────────┴──────────┴─────────┘

This is the actual root cause, confirmed rather than assumed: a separate, much higher-ingest table (ad_impressions) is keeping the entire shared background pool saturated with ordinary merges, and the throttling setting covered above is correctly, deliberately declining to schedule the events mutation until a slot frees up — protecting ad_impressions from a "too many parts" failure at the direct cost of events' DELETE sitting queued far longer than the team expected. This isn't a bug in either table's configuration; it's two tables competing for one shared, finite resource, invisible if you only ever look at the table you're debugging in isolation.

Step 4 — confirm the practical consequence back in familiar territory. system.mutations and system.merges explain why nothing has happened yet; system.parts — the table this course has used since Module 3 — shows what that delay actually costs events in the meantime:

Has the delay left events with more parts than usual?sql
SELECT count() AS active_parts, max(modification_time) AS most_recent_merge
FROM system.parts
WHERE table = 'events' AND active;
Result — part count elevated, no recent merge activitytext
┌─active_parts─┬──most_recent_merge──┐
│          247 │  2026-07-11 09:14:02 │
└──────────────┴──────────────────────┘

An elevated part count with a most_recent_merge well behind the current time confirms the same story from a third angle: events isn't just waiting on this one DELETE — it's not getting any merges right now, mutation or otherwise, because the shared pool has no free capacity for it. All three tables now agree: the job ticket (system.mutations) says not done, the live queue (system.merges) says nothing for this table is executing, and the part count (system.parts) shows the visible consequence — one consistent narrative, not three isolated facts.

A stuck mutation blocking every subsequent ALTER/OPTIMIZE. A real, publicly filed ClickHouse issue shows this exact shape: a user querying system.mutations found one mutation reading is_done: 0, is_killed: 0, with a real latest_fail_reason"Unexpected size of dynamic path admin4_code: 127 != 121" — against one specific part, and a second mutation reading is_done: 0, is_killed: 1 with 956 parts still to do; every further OPTIMIZE/ALTER TABLE against that table failed until the team worked around it. The signature: a genuinely broken mutation (unlike this lesson's walkthrough, which was merely queued) doesn't just sit there quietly — it can block the entire table's future schema and optimization commands until it's explicitly resolved, typically via KILL MUTATION.

Merge starvation from mutation competition, not just insert volume. mergetree-on-disk-format's "too many parts" failure was framed around insert rate outpacing merges. This lesson's walkthrough shows the same shared-pool ceiling from the mutation side: a table running frequent, expensive mutations can starve its own merges of pool capacity just as effectively as another table's ingestion can starve a mutation, since both draw from the identical resource this lesson's mental model described.

TTL expiration silently not happening. designing-events-tables already established that TTL-expired rows are "removed when ClickHouse merges data parts" — not the instant they expire. If a table's background pool is chronically saturated (by mutations, by another table's merges, or simply by an ingestion rate that outpaces available pool capacity), the merges that would normally sweep up TTL-expired parts may simply not be getting scheduled promptly — the same system.merges emptiness this walkthrough diagnosed for a stuck mutation is exactly what you'd check to confirm this, rather than assuming TTL itself is broken.

MEMORY_LIMIT_EXCEEDED — one runaway query, or many ordinary ones stacking up. A failed query throwing this exception shows up in system.query_log with exception_code = 241 and a populated exception message.

Finding every query that hit a memory limit todaysql
SELECT event_time, query_id, normalized_query_hash, memory_usage, exception
FROM system.query_log
WHERE exception_code = 241 AND event_date = today()
ORDER BY event_time DESC;

A single row, for a normalized_query_hash you've never seen fail before, with a memory_usage value far larger than anything else in the same time window, is one runaway query — a one-off, usually traceable to a specific expensive join or an unexpectedly high-cardinality GROUP BY in that one query's plan. A cluster of different queries (different normalized_query_hash values) failing in the same short window, each with a comparatively ordinary memory_usage, is a different diagnosis entirely: sustained memory pressure from many concurrent queries competing for a shared limit, none of them individually unreasonable. The fix for the first case is usually query-specific (rewrite, filter earlier, or raise a per-query limit deliberately); the fix for the second is capacity or concurrency, not any single query's SQL.

One caution worth being explicit about here, because it's a genuinely common mistake in blog posts and forum answers about this exact topic: system.processes has a real peak_memory_usage column, tracking the running peak of a query still in flight — but system.query_log does not have a peak_memory_usage column at all, only memory_usage. Several secondary sources describe querying query_log.peak_memory_usage directly — that column doesn't exist there, and a query written against it fails outright. query_log.memory_usage already is that completed query's peak: it's assigned from info.peak_memory_usage when the query's status is written to the log — so a finished query's peak footprint is already sitting in query_log.memory_usage, with no need to have caught it live from system.processes while the query was still running.

Hands-on forensics: a mental checklist for "is this background job actually working?"

  1. Check the job ticket. SELECT * FROM system.mutations WHERE NOT is_done for the table in question — is parts_to_do shrinking over repeated checks, or holding steady? Is latest_fail_reason populated (a real failure) or empty (not yet failed, possibly not yet scheduled)?
  2. Check whether it's an active task right now. SELECT * FROM system.merges WHERE table = '...' — a matching row with is_mutation = 1 means it's genuinely executing; no row at all means it's queued, not broken.
  3. If queued, check what's actually holding the pool. SELECT * FROM system.merges ORDER BY elapsed DESC with no table filter — is the pool full of long-running tasks on a different table entirely?
  4. If genuinely failed, read latest_fail_reason as a real error message, not a generic timeout — it names the specific exception from the specific part that failed, which is usually enough to point at root cause without guessing.
  5. Only after 1–4, consider KILL MUTATION (for a genuinely broken mutation) or waiting/freeing pool capacity (for one that's merely queued) — treating these as the same fix is how a queued-but-healthy mutation gets killed unnecessarily, or a genuinely broken one gets left to block the table indefinitely.

Reasoning Prompt

A table's TTL event_time + INTERVAL 90 DAY DELETE has been in place for months, but a routine audit shows rows well past 90 days old are still present, and the table's total disk usage has been climbing steadily rather than holding roughly steady the way a working TTL policy would produce. Form a hypothesis about where to look first, and what you'd check, before reading further.

Model Answer

The instinct to suspect the TTL clause itself — wrong syntax, wrong column, a typo in the interval — is reasonable but should be checked second, not first, because this lesson's mental model points somewhere more likely: TTL cleanup isn't a separate mechanism with its own schedule, it rides on the same background merge process (and the same shared pool) everything else in this lesson has been about.

First check: system.merges, filtered to this table, run a few times over a short window. If it's consistently empty, or shows the same few tasks stuck at a low progress value for an unusually long elapsed time, that's evidence the table's parts simply aren't getting merged often or fast enough — for any reason, TTL included — not that TTL's logic is wrong. Cross-referencing against system.parts (specifically, whether the count of parts is unusually high, or many parts show an old modification_time with no recent merge activity) reinforces the same read: a starved or backlogged background pool, not a broken TTL rule.

Second check, only if the first comes back clean: confirm the TTL expression itself against the table's actual CREATE TABLE (available from system.tables, per the-system-database) — has it perhaps been silently left off during some earlier schema change, or does it reference a column whose values don't actually behave the way the team assumes?

The reasoning pattern worth keeping: a policy that depends on a background process (TTL, and everything else in this lesson) can fail in two structurally different ways — the policy's own logic is wrong, or the background process it depends on isn't running when or as often as expected — and this lesson's tools (system.mutations, system.merges, cross-referenced with system.parts) are built specifically to distinguish which one you're actually facing, rather than guessing based on which explanation feels more likely.

Teach It Back

Explain to a colleague who's comfortable running ALTER TABLE ... DELETE and reading system.parts, but has never looked at system.mutations or system.merges: why did their DELETE from yesterday not show up as freed disk space yet, and how would they actually check whether it's still working versus stuck? Use this lesson's "job ticket vs. live work queue" framing, in your own words, as if they just asked you "isn't system.mutations and system.merges basically the same information twice?"

Summary

system.mutations and system.merges expose something the rest of this course's system tables don't: work ClickHouse is actively doing, not just state it's holding. system.mutations is one row per ALTER ... UPDATE/DELETE command, tracking overall completion (is_done, parts_to_do) and the last real failure (latest_fail_reason) across every part that command touches. system.merges is a live snapshot — one row per task currently executing, merge or mutation alike (is_mutation distinguishes them) — because both are scheduled and run through the exact same shared background pool, MergeTreeBackgroundExecutor. That shared pool is the mental model's payoff: a mutation that looks "stuck" might be genuinely broken (a real error in latest_fail_reason, as the cited GitHub issue showed) or might simply be queued behind other work competing for the same finite resource — and system.merges, checked both for the table in question and across the whole server, is what tells those two situations apart instead of guessing. The same shared-pool ceiling explains "too many parts" from mergetree-on-disk-format, mutation contention in this lesson's walkthrough, and TTL rows that outlive their expiration in the Reasoning Prompt — three different symptoms, one recurring root cause, visible only once you stop reading any single system table in isolation. A MEMORY_LIMIT_EXCEEDED failure is a different kind of question entirely — not "is a background job stuck" but "which query, or which pattern of queries, actually blew the limit" — and system.query_log's exception_code, memory_usage, and normalized_query_hash columns answer it, as long as you don't reach for a peak_memory_usage column that only exists on system.processes, not here.