ClickHouse Charges Memory Through a Hierarchy of Trackers

ClickHouse charges every allocation to a chain of memory trackers — global, user, query, thread — each with its own hard limit, walked one link at a time.

TL;DR: Every allocation in ClickHouse is charged to a chain of MemoryTracker objects: total_memory_tracker (global) → the user’s tracker → the query’s tracker → the thread’s tracker. Each link has its own hard limit, and the allocation is offered to each link in turn. A limit hit anywhere in the chain aborts the allocation with a MEMORY_LIMIT_EXCEEDED exception.

A columnar database that runs 200 concurrent queries on 64 cores has a bookkeeping problem that a row store does not. It has to answer four questions at the same time:

  • Is the server as a whole about to run out of RAM?
  • Is this user about to run out of RAM?
  • Is this one query about to run out of RAM?
  • And, at the lowest level, which thread should be charged for the bytes?

Answering them with four independent counters is the obvious approach and it is wrong, for two reasons. First, a query’s memory is not owned by one thread: a SELECT fans out across a thread pool, blocks are handed between threads, and the final aggregation happens on yet another one. Second, the four answers are not independent — a query’s limit is meaningless if the server’s limit is lower.

ClickHouse’s answer is a tree of counters with a parent pointer, where one allocation walks up the tree and is offered to every level.

The four levels

The levels are enumerated, and the enumeration order is the tree order — a lower value is closer to the root:

cpp
// src/Common/VariableContext.h:7
enum class VariableContext : uint8_t
{
    Global = 0,
    User,           /// Group of processes
    Process,        /// For example, a query or a merge
    Thread,         /// A thread of a process

    Max, /// size of the enum, not a real level
};

There are exactly two global-scope trackers, created at static-init time:

cpp
// src/Common/MemoryTracker.cpp:160
MemoryTracker total_memory_tracker(nullptr, VariableContext::Global);
MemoryTracker background_memory_tracker(&total_memory_tracker, VariableContext::User, false);

total_memory_tracker is the server total: it has no parent, and its hard limit is the server’s max_server_memory_usage. background_memory_tracker is a User-level tracker for work that is not a user query — merges, mutations, and other background jobs:

cpp
// src/Interpreters/ThreadStatusExt.cpp:275
ThreadGroupPtr ThreadGroup::createForMergeMutate(ContextPtr storage_context)
{
    const Int32 os_threads_nice_value = storage_context->getServerSettings()[ServerSetting::os_threads_nice_value_merge_mutate];
    auto group = create(storage_context, os_threads_nice_value);
    group->memory_tracker.setDescription("Background process (mutate/merge)");
    group->memory_tracker.setParent(&background_memory_tracker);
    return group;
}

Note the third constructor argument false on background_memory_tracker: background work does not get a “peak memory usage” log line in the destructor.

The per-user and per-query trackers are created at query start. The per-user one lives in ProcessListForUser:

cpp
// src/Interpreters/ProcessList.h:350
    MemoryTracker user_memory_tracker{VariableContext::User};

and the per-query one lives in ThreadGroup, which is created per query:

cpp
// src/Common/ThreadStatus.h:103
    MemoryTracker memory_tracker{VariableContext::Process};

The chain, at steady state, is:

text
  total_memory_tracker                 VariableContext::Global
  hard limit: max_server_memory_usage
        ^ parent
        |
  user_memory_tracker                  VariableContext::User
  hard limit: max_memory_usage_for_user
        ^ parent
        |
  ThreadGroup::memory_tracker          VariableContext::Process
  hard limit: max_memory_usage
        ^ parent
        |
  ThreadStatus::memory_tracker         VariableContext::Thread
  no hard limit (see below)

A fourth level, Thread, exists per OS thread. It carries no limit, and the source is explicit about why:

cpp
// src/Interpreters/ProcessList.cpp:385
            /// NOTE: Do not set the limit for thread-level memory tracker since it could show unreal values
            ///  since allocation and deallocation could happen in different threads

That comment is the whole reason the tree is a tree and not a flat set of counters. A block allocated on thread A and freed on thread B would make a per-thread counter go negative. The thread tracker is therefore a routing node, not a limit node: it exists so that the chain has a leaf the thread’s allocations can be charged to, and so that the blocker’s level has something to compare against.

The parent pointer

Each tracker holds a single atomic pointer to its parent, and the header explains exactly why it is atomic and why the ordering matters:

cpp
// src/Common/MemoryTracker.h:91
        /// Singly-linked list. All information will be passed to subsequent memory trackers also (it allows to implement trackers hierarchy).
        /// In terms of tree nodes it is the list of parents. Lifetime of these trackers should "include" lifetime of current tracker.
        /// Requires acquire-release:
        /// 1. Thread A constructs MemoryTracker object and attaches MemoryTracker pointer
        ///    (e.g. ProcessList::insert where the user's MemoryTracker is constructed right before calling setParent).
        /// 2. Thread B traverses the chain and dereferences each pointer (e.g. another thread in thread group).
        /// 3. If Thread B sees a pointer, it should be guaranteed to see the object's memory without data races.
        /// Hence, we need the Thread A's pointer store to synchronize-with the Thread B's pointer load.
        std::atomic<MemoryTracker *> parent {};

setParent is the only place the link is written, and it does two things that are easy to get wrong: it flushes the thread’s deferred bytes before moving the pointer, and it attaches the child to the new parent before publishing the pointer, so a concurrent reader that sees the new parent also sees the child counted:

cpp
// src/Common/MemoryTracker.cpp:1058
void MemoryTracker::setParent(MemoryTracker * elem)
{
    /// Untracked memory shouldn't be accounted to a query or a user if it was allocated before the thread was attached
    /// to a query thread group or a user group, because this memory will be (🤞) freed outside of these scopes.
    if (level == VariableContext::Thread && DB::current_thread)
        DB::current_thread->flushUntrackedMemory();

    /// Attach before publishing, so the new parent counts this child before any of its bytes arrive.
    if (elem && level == VariableContext::Process)
        elem->attachChild();
    auto * old_parent = parent.exchange(elem, std::memory_order_acq_rel);
    if (old_parent && level == VariableContext::Process)
        old_parent->detachChild();

The last four lines are the interesting ones. A Process-level tracker counts its children:

cpp
// src/Common/MemoryTracker.cpp:737
void MemoryTracker::attachChild()
{
    if (level == VariableContext::User)
        children_count.fetch_add(1);
}

and when the last child goes away, whatever residual is left on the user is settled, so a user does not accumulate a permanent residue:

cpp
// src/Common/MemoryTracker.cpp:744
void MemoryTracker::detachChild()
{
    if (level != VariableContext::User || children_count.fetch_sub(1) != 1)
        return;

    /// Read before checking again: a child attached meanwhile is either seen below or charges after the read.
    Int64 residual = amount.load();
    if (residual == 0 || children_count.load() != 0)
        return;

Walking the chain

The whole design reduces to one recursive function. MemoryTracker::allocImpl charges this level, then hands the same bytes to the parent:

cpp
// src/Common/MemoryTracker.cpp:391
    Int64 will_be = size ? size + amount.fetch_add(size, std::memory_order_relaxed) : amount.load(std::memory_order_relaxed);
    Int64 will_be_rss = 0;
    if (level == VariableContext::Global)
        will_be_rss = size ? size + rss.fetch_add(size, std::memory_order_relaxed) : rss.load(std::memory_order_relaxed);

    Int64 current_hard_limit = hard_limit.load(std::memory_order_relaxed);
    bool memory_limit_exceeded_ignored = false;

The amount.fetch_add happens before the limit check, which means the counter is momentarily over the limit while the check runs. The comment above it says so explicitly:

cpp
// src/Common/MemoryTracker.cpp:387
    /** Using memory_order_relaxed means that if allocations are done simultaneously,
      *  we allow exception about memory limit exceeded to be thrown only on next allocation.
      *  So, we allow over-allocations.
      */

This is a deliberate trade: relaxed ordering avoids the cost of an acquire-release fence on the hottest path in the server, at the price of letting several threads briefly overshoot the limit before any of them is refused. The overshoot is bounded by the number of threads racing, not by the size of the allocations.

After the check, the chain continues:

cpp
// src/Common/MemoryTracker.cpp:574
    AllocationTrace allocation_trace(_sample_probability);
    if (auto * loaded_next = parent.load(std::memory_order_acquire))
    {
        MemoryTracker * tracker = level == VariableContext::Process ? this : query_tracker;
        try
        {
            allocation_trace = loaded_next->allocImpl(size, enforce_memory_limit, tracker, _sample_probability);
        }
        catch (...)
        {
            decrementLocalUsage(size);
            throw;
        }
    }

Note the catch: if the parent refuses the allocation, this level has already added the bytes to its own counter, so it must undo them before rethrowing. That single catch block is the difference between “the query hit its limit” and “the server now permanently believes the query is holding 50 GB”. The unit tests in src/Common/tests/gtest_memory_tracker.cpp exist specifically to pin this down — ParentLimitFailureRollsBackHierarchy, ProcessLimitFailureRollsBackDescendants, RepeatedParentLimitFailuresDoNotAccumulate.

Carrying the query identity down the chain

The query_tracker argument is a small but load-bearing trick. The Global level needs to know which query asked for the memory, because the overcommit mechanism has to pick a victim query to kill — and the global tracker has no idea which query that is. So the Process-level tracker passes itself down as the “query tracker” and the global level keeps it:

cpp
// src/Common/MemoryTracker.cpp:377
        /// Since the MemoryTrackerBlockerInThread should respect the level, we should go to the next parent.
        if (auto * loaded_next = parent.load(std::memory_order_acquire))
        {
            MemoryTracker * tracker = level == VariableContext::Process ? this : query_tracker;
            return loaded_next->allocImpl(size, enforce_memory_limit, tracker, _sample_probability);
        }

The same expression appears twice — once in the blocked path above and once in the normal path. The invariant is: at the Global level, query_tracker is the innermost Process-level tracker that was on the chain, and at any other level it is passed through unchanged.

Where the limits come from

The limits are session settings, applied when the query is registered:

cpp
// src/Interpreters/ProcessList.cpp:322
        /// Track memory usage for all simultaneously running queries from single user.
        if (starts_a_new_period)
            user_process_list.user_memory_tracker.setHardLimit(settings[Setting::max_memory_usage_for_user]);
        else
            user_process_list.user_memory_tracker.setOrRaiseHardLimit(settings[Setting::max_memory_usage_for_user]);
        user_process_list.user_memory_tracker.setSoftLimit(settings[Setting::memory_overcommit_ratio_denominator_for_user]);
        user_process_list.user_memory_tracker.setDescription("User");

and for the query itself:

cpp
// src/Interpreters/ProcessList.cpp:359
            /// Set query-level memory trackers
            thread_group->memory_tracker.setOrRaiseHardLimit(settings[Setting::max_memory_usage]);
            configureMemoryTrackerFromSettings(query_context->hasTraceCollector(), thread_group->memory_tracker, settings);

Two different setters, and the difference is deliberate. setHardLimit replaces the limit outright; setOrRaiseHardLimit only ever raises it:

cpp
// src/Common/MemoryTracker.cpp:1034
void MemoryTracker::setOrRaiseHardLimit(Int64 value)
{
    /// This is just atomic set to maximum.
    Int64 old_value = hard_limit.load(std::memory_order_relaxed);
    while ((value == 0 || old_value < value) && !hard_limit.compare_exchange_weak(old_value, value))
        ;
}

The value == 0 case is the interesting one: zero means “unlimited”, and “unlimited” is encoded as raise to infinity. So a user who runs one query with max_memory_usage_for_user = 10 GiB and then a second with max_memory_usage_for_user = 0 does not get an unlimited user — the user stays capped at 10 GiB. The starts_a_new_period flag above decides when the cap is reset: a “period” ends when the user’s last query leaves the list.

The setSoftLimit line is the overcommit knob. memory_overcommit_ratio_denominator defaults to 1 GiB:

cpp
// src/Core/Settings.cpp:4623
    DECLARE(UInt64, memory_overcommit_ratio_denominator, 1_GiB, R"(
    It represents the soft memory limit when the hard limit is reached on the global level.
    This value is used to compute the overcommit ratio for the query.
    Zero means skip the query.
    Read more about [memory overcommit](/concepts/features/configuration/settings/memory-overcommit).
)", 0, \
        {"22.5", 0, 1073741824, "Enable memory overcommit feature by default"}) \

The soft limit is not a cap. It is the denominator of the ratio used to rank queries when the server has to choose one to kill.

Where the thread tracker joins

A thread attaches to a query’s group when the group is created, and the thread’s tracker is reparented at that moment:

cpp
// src/Interpreters/ThreadStatusExt.cpp:446
        /// Reparenting the memory tracker flushes the untracked balance the thread carried in, so the
        /// counters must be reparented after it, or those bytes are reported as this group's.
        memory_tracker.setParent(&thread_group->memory_tracker);
        performance_counters.setParent(&thread_group->performance_counters);

The comment records the ordering constraint precisely: the reparenting flushes the thread’s deferred (untracked) balance, and that flush has to land on the new parent, so the tracker must be reparented before the performance counters are. The inverse ordering would report the flushed bytes against the wrong scope.

When the thread detaches — a query finishing, or a pool thread returning to the pool — the tracker goes back to the global tracker and the deferred bytes are flushed one last time:

cpp
// src/Interpreters/ThreadStatusExt.cpp:481
void ThreadStatus::detachFromGroup()
{
    if (!thread_group)
        return;

    LockMemoryExceptionInThread lock_memory_tracker(VariableContext::Global);

    /// flush untracked memory before resetting memory_tracker parent
    flushUntrackedMemory();

The LockMemoryExceptionInThread at the top matters: detaching happens during stack unwinding when a query is cancelled, and an exception thrown from a destructor during unwinding is std::terminate. The lock is the mechanism that says “no memory-limit exceptions from here on”.

The free path

Freeing is not symmetric with allocating, and the difference is where most of the subtle bugs live. MemoryTracker::free takes the bytes off this level first, and then only passes on what was actually there:

cpp
// src/Common/MemoryTracker.cpp:890
AllocationTrace MemoryTracker::free(Int64 size, double _sample_probability)
{
    if (_sample_probability < 0)
        _sample_probability = sample_probability.load(std::memory_order_relaxed);

    if (!isSizeOkForSampling(size))
        _sample_probability = 0;

    if (MemoryTrackerBlockerInThread::isBlocked(level))
    {
        if (level == VariableContext::Global)
        {
            /// For global memory tracker always update memory usage.
            amount.fetch_sub(size, std::memory_order_relaxed);
            rss.fetch_sub(size, std::memory_order_relaxed);
            auto metric_loaded = metric.load(std::memory_order_relaxed);
            if (metric_loaded != CurrentMetrics::end())
                CurrentMetrics::sub(metric_loaded, size);
        }

The removal itself saturates at zero for the User and Process levels, because a query may free memory it never held (a cache eviction, a pool object freed by a different query’s thread):

cpp
// src/Common/MemoryTracker.cpp:607
    else
    {
        /// A query may free memory it never held (e.g. a cache eviction): take only what is there, in one
        /// compare-exchange so that concurrent frees cannot together remove more than is there.
        Int64 current = amount.load(std::memory_order_relaxed);
        do
        {
            accounted_size = std::min(size, std::max<Int64>(current, 0));
        }
        while (!amount.compare_exchange_weak(current, current - accounted_size, std::memory_order_relaxed));
    }

and the “freed more than we held” residue is not silently dropped — it goes to the server total, and the deficit is remembered:

cpp
// src/Common/MemoryTracker.cpp:924
    /// A query freeing what it never held does not free it for its user either; only the server total holds it.
    if (accounted_size < size && loaded_next->level == VariableContext::User)
    {
        std::ignore = total_memory_tracker.free(size - accounted_size, _sample_probability);
#ifdef DEBUG_OR_SANITIZER_BUILD
        freed_beyond_held.fetch_add(size - accounted_size, std::memory_order_relaxed);
#endif
        size = accounted_size;
    }

The freed_beyond_held counter exists only in debug builds, and it is there to explain a specific false positive: thread A of a query allocates 2 MiB that stays in its untracked (deferred) buffer, and thread B frees that block. The free reaches the server total first, so the query now holds 2 MiB less than it should, and when A finally flushes, the query is charged for a block that is already gone. That shows up as drift at the end of the query. The header documents it in full:

cpp
// src/Common/MemoryTracker.h:158
    /// What a query root freed beyond what it held, which went straight to the server total (see `free`). For example,
    /// thread A of a query allocates a 2 MiB block that stays in its untracked memory, below `max_untracked_memory`, and
    /// thread B frees the block and flushes: the query holds less than 2 MiB, so the rest goes here. When A flushes later,
    /// the query is charged for a block that is already freed, which shows as drift at the end of the query but is not.
    std::atomic<Int64> freed_beyond_held = 0;

Settling what a query leaves behind

When a query’s tracker is destroyed, whatever it still holds is taken off its user — so that a user does not end up charged for a query’s leftovers:

cpp
// src/Common/MemoryTracker.cpp:697
void MemoryTracker::settleDriftOnQueryEnd()
{
    Int64 drift = amount.load(std::memory_order_relaxed);
    if (drift == 0)
        return;

    /// Nested groups (a view, a merge inside `OPTIMIZE`) leave their drift to the query above them.
    auto * user_tracker = parent.load(std::memory_order_relaxed);
    if (!user_tracker || user_tracker->level != VariableContext::User)
        return;

    /// Another query of the same user may be releasing the same bytes right now, so count what was actually removed.
    Int64 settled = user_tracker->adjustLocally(drift);
    adjustLocally(drift);
    if (settled == 0)
        return;

    ProfileEvents::increment(ProfileEvents::QueryMemoryDriftSettled);
    ProfileEvents::increment(ProfileEvents::QueryMemoryDriftSettledBytes, std::abs(settled));
}

The “nested groups” line is a subtlety worth pausing on. A SELECT from a view, or an OPTIMIZE that runs a merge, creates a nested thread group. The nested group’s tracker is not attached to the user — it charges through the group above it, so the user is not billed twice:

cpp
// src/Interpreters/ProcessList.cpp:337
            /// A nested group (a view, a flush run by `SYSTEM FLUSH ASYNC INSERT QUEUE`, a dictionary load) is
            /// charged through the group above it, and would be charged to the user twice.
            if (!thread_group->isNested())
            {
                thread_group->memory_tracker.reparent(&user_process_list.user_memory_tracker);
                thread_group->memory_pressure_monitor.setParent(user_process_list.user_memory_pressure_monitor);
            }

isNested() is just “does this group have a parent group”:

cpp
// src/Common/ThreadStatus.h:157
    bool isNested() const { return parent != nullptr; }

And the settleDriftOnQueryEnd guard user_tracker->level != VariableContext::User is what implements that: a nested group’s parent is a Process-level tracker, not a User-level one, so the nested group returns early and leaves its drift to the query above it, which does have a User parent and does settle.

The hot fields

The tracker’s own layout is hand-tuned, because amount is written on every allocation and every free:

cpp
// src/Common/MemoryTracker.h:72
        /// Hot: every field touched on the alloc/free path. Packed into a single cache line
        /// (24B writes + 40B reads = 64B exactly) so each allocation needs at most one line
        /// in flight. The line bounces among writer cores either way; co-locating the
        /// read-mostly limits/parent/metric here means writers get them for free with the
        /// same fetch. The only cost is reader-only cores re-fetching when allocator writes
        /// invalidate, but allocations dominate over pure reads in practice.
        struct
        {
            /// Hot writes: updated on every alloc/free flush.
            alignas(DB::CH_CACHE_LINE_SIZE) std::atomic<Int64> amount {0};
            std::atomic<Int64> peak {0};
            std::atomic<Int64> rss {0};

Twenty-four bytes of writes (three Int64 counters) plus forty bytes of reads (three limits, the parent pointer, the metric) fills a 64-byte cache line exactly. The whole struct is aligned to the cache line:

cpp
// src/Common/MemoryTracker.h:421
static_assert(std::alignment_of_v<MemoryTracker> == DB::CH_CACHE_LINE_SIZE);

and the “cold” fields — the description string, the fault-injection probability, the sampling configuration — live in a second cache line that is never touched on the alloc/free path.

Metrics and observability

A tracker can be attached to a CurrentMetrics counter, which is how background work reports itself:

cpp
// src/Common/MemoryTracker.h:337
    /// The memory consumption could be shown in realtime via CurrentMetrics counter
    void setMetric(CurrentMetrics::Metric metric_)
    {
        metric.store(metric_, std::memory_order_relaxed);
    }

The server total is not a metric but a tracker, and it is reported:

cpp
// src/Common/CurrentMetrics.cpp:75
    M(MemoryTracking, "Total amount of memory (bytes) tracked by the server. By default the background memory worker corrects this value on every tick from an external measurement of the memory the process really uses (see the `memory_worker_correct_memory_tracker` server setting), so it follows the measured usage rather than a plain count of allocations. See `MemoryTrackingUncorrected` for the value with no corrections applied.") \

The distinction matters: MemoryTracking is corrected against the real RSS by the background memory worker, while MemoryTrackingUncorrected is the raw counter of the tree. The gap between them is the accounting drift plus the memory the process uses that never went through the counter at all — the pages the allocator retains.

Per-query usage is visible in the process list and the query log:

sql
SELECT query_id, memory_usage, peak_memory_usage
FROM system.processes
WHERE memory_usage > 0
ORDER BY memory_usage DESC;
sql
SELECT
    query_id,
    formatReadableSize(memory_usage)  AS used,
    formatReadableSize(peak_memory_usage) AS peak,
    exception
FROM system.query_log
WHERE type = 'QueryFinish' AND memory_usage > 0
ORDER BY peak_memory_usage DESC
LIMIT 10;

What the hierarchy buys and what it costs

What it buys. One allocation is checked against four independent budgets without any of them knowing about the others. The user’s limit works even when the user’s queries run on 32 different threads. The server’s limit works even when the server has 200 users. And because the check happens before the bytes are handed to the allocator, an over-budget allocation costs nothing but the counter update.

What it costs.

  • The chain walk is a pointer chase on the hottest path in the server. Four levels means four atomic loads, four fetch_adds, four limit checks, and four fetch_subs on the free side — per allocation. This is why the thread level batches allocations instead of charging them one by one, and why the whole tracker struct is packed into a single cache line.

  • The counters can overshoot. relaxed ordering plus the add-then-check order means the reported usage can exceed the limit by whatever several racing threads allocated. The tests acknowledge this with a tolerance rather than an equality.

  • The tree is mutable at run time. reparent moves a tracker and its bytes between subtrees, and it has to check the new ancestors’ limits before the move, because moving is not an allocation and would not go through the normal path:

    cpp
    // src/Common/MemoryTracker.cpp:792
        /// Moving is not an allocation, so check the limits here, before anything changes.
        for (const auto * tracker = new_parent; size > 0 && tracker && tracker != common; tracker = tracker->parent.load(std::memory_order_relaxed))
        {
            Int64 limit = tracker->hard_limit.load(std::memory_order_relaxed);
            Int64 will_be = tracker->amount.load(std::memory_order_relaxed) + size;
            if (limit && will_be > limit)
            {
                const auto * description = tracker->description_ptr.load(std::memory_order_relaxed);
                throw DB::Exception(
                    DB::ErrorCodes::MEMORY_LIMIT_EXCEEDED,
                    "{}{}exceeded: would use {} (attempt to move {} already allocated), maximum: {}",
  • The Thread level is not a limit and the source says so. Any attempt to put one there produces numbers that “could show unreal values”.

The part people get wrong. A query that fails with MEMORY_LIMIT_EXCEEDED did not necessarily hit its own max_memory_usage. The chain is checked from the leaf up, and the global tracker checks both its own byte counter and the process RSS:

cpp
// src/Common/MemoryTracker.cpp:427
    if (unlikely(
            current_hard_limit && (will_be > current_hard_limit || (level == VariableContext::Global && will_be_rss > current_hard_limit))))

So a server-level max_server_memory_usage can refuse an allocation that is perfectly within the query’s budget, and the message says which one it was.

What to go do about it. Read the exception text. The global-level message carries the RSS, the untracked-memory total and the overcommit decision; the query-level message carries only the numbers. The difference tells you whether you have a query problem or a server problem, and no amount of max_memory_usage tuning fixes the latter.

ClickhousePerformanceInternalsMemoryMemory tracking