ClickHouse Merges Columns One at a Time: The Vertical Merge Algorithm

ClickHouse's vertical merge writes a one-byte-per-row row-source stream while merging the key, then gathers each wide column separately from the source parts using that stream.

TL;DR: ClickHouse’s vertical merge splits a merge into two stages: merge the key and record where each output row came from (one byte per row), then gather each payload column separately. Peak memory drops from “all columns at once” to “one column at a time” — which is the difference between a merge that OOMs and one that doesn’t.

Every MergeTree table is a pile of immutable parts. Every part is a pile of immutable columns. Background merges take a contiguous range of parts and rewrite them as one bigger part. That sounds like a simple copy, and for a table with three narrow columns it is one.

Now consider a table with two hundred columns, one of which is a 40 MB String of raw JSON and the rest are UInt64 counters.

The obvious merge implementation reads all two hundred columns of the source parts, merges the sorting key, and writes two hundred columns out. It has to hold, in memory at once, the merged key plus every payload column of the rows it is currently holding — which for a wide table means the peak memory of a merge is proportional to the sum of all column widths, not to the width of the widest column.

That is a bad trade. The payload columns are almost always much larger than the key, and they are almost always independent of the key: merge the keys first and separately, and you can then reconstruct each payload column without ever holding two payload columns in memory at the same time.

ClickHouse implements exactly that. It is called the vertical merge algorithm, and it is one of the more elegant pieces of engineering in the MergeTree code base. The trick that makes it work is a one-byte-per-row side-channel file that records where each output row came from.

The problem, stated precisely

Suppose we merge three parts, each sorted by the key k:

text
  part A (10 rows)      part B (10 rows)      part C (10 rows)
  k=1  a1=...           k=1  b1=...           k=2  c1=...
  k=1  a2=...           k=2  b2=...           k=2  c2=...
  k=3  a3=...           k=2  b3=...           k=5  c3=...
  ...                   ...                   ...

A horizontal merge reads all three parts’ k columns, merges them into one sorted stream, and then — because MergeTree guarantees that all rows with the same key are adjacent and, within a key, appear in source-part order — it can reconstruct every other column by a gather: walk the merged key stream, and for each row, take the next value of that column from the part the row came from.

The output order of the merged key is not the concatenation of the inputs; it is the k-way merge of them. So for each output row we need to know which input part it came from.

That is the entire information content of the vertical merge’s side channel.

And it is exactly one small integer per row.

The horizontal stage: merging the key and writing the row sources

The vertical merge is not a separate code path that replaces the normal merge. It is a second stage that runs after the normal merge has merged the key. The normal merge is called the “horizontal” stage, and the code says so explicitly — the stage class is called ExecuteAndFinalizeHorizontalPart:

cpp
// src/Storages/MergeTree/MergeTask.h:63
/**
 * Overview of the merge algorithm
 *
 * Each merge is executed sequentially block by block.
 * The main idea is to make a merge not a subroutine which is executed
 * in a thread pool and may occupy a thread for a period of time,
 * but to make a merge a coroutine which can suspend the execution
 * in some points and then resume the execution from this point.
 *
 * A perfect point where to suspend the execution is after the work over a block is finished.
 * The task itself will be executed via BackgroundJobExecutor.
 *
 * The interface of the task is simple.
 * The main method is `execute()` which will return true, if the task wants to be executed again and false otherwise.
 *
 * With this kind of task we can give a merge a priority.
 * A priority is simple - the lower the size of the merge, the higher priority.
 * So, if ClickHouse wants to merge some really big parts into a bigger part,
 * then it will be executed for a long time, because the result of the merge is not really needed immediately.
 * It is better to merge small parts as soon as possible.
*/

Note the second half of that comment: the merge is a coroutine. It is resumed by the background pool between blocks, which is what makes it possible to give a small merge priority over a huge one without pinning a thread. The vertical stage is expressed in the same idiom: a list of sub-tasks, each of which returns true when it wants to be resumed.

The side channel is a real file, managed by a small class:

cpp
// src/Storages/MergeTree/MergeTask.cpp:257
/// Manages the "rows_sources" temporary file that is used during vertical merge.
class RowsSourcesTemporaryFile : public ITemporaryFileLookup
{
public:
    /// A logical name of the temporary file under which it will be known to the plan steps that use it.
    static constexpr auto FILE_ID = "rows_sources";

    explicit RowsSourcesTemporaryFile(TemporaryDataOnDiskScopePtr temporary_data_on_disk_)
        : temporary_data_on_disk(temporary_data_on_disk_->childScope({CurrentMetrics::TemporaryFilesForMerge}))
    {
    }

and it is deliberately re-openable, so that several columns can be gathered in parallel from independent file handles — getTemporaryFileForReading returns tmp_data_buffer->read(), a fresh reader each time, with the comment “Reopen the file for each read so that multiple reads can be performed in parallel and there is no need to seek to the beginning.”

That matters: the row-source stream is consumed once per column, and each column’s gather starts from the beginning of the file. If the file were a single sequential stream, gathering column #2 would have to seek back to offset 0 and re-read column #1’s worth of bytes. Re-opening gives every column its own independent read cursor.

The encoding: one byte per row

The row source is not a struct with a source number and a flag in two fields. It is a single byte with two bit-fields packed into it:

cpp
// src/Processors/Transforms/ColumnGathererTransform.h:15
/// Tiny struct, stores number of a Part from which current row was fetched, and insertion flag.
struct RowSourcePart
{
    UInt8 data = 0;

    RowSourcePart() = default;

    explicit RowSourcePart(size_t source_num, bool skip_flag = false)
    {
        static_assert(sizeof(*this) == 1, "Size of RowSourcePart is too big due to compiler settings");
        setSourceNum(source_num);
        setSkipFlag(skip_flag);
    }

    size_t getSourceNum() const { return data & MASK_NUMBER; }

    /// In CollapsingMergeTree case flag means "skip this rows"
    bool getSkipFlag() const { return (data & MASK_FLAG) != 0; }

    void setSourceNum(size_t source_num)
    {
        data = (data & MASK_FLAG) | (static_cast<UInt8>(source_num) & MASK_NUMBER);
    }

    void setSkipFlag(bool flag)
    {
        data = flag ? data | MASK_FLAG : data & ~MASK_FLAG;
    }

    static constexpr size_t MAX_PARTS = 0x7F;
    static constexpr UInt8 MASK_NUMBER = 0x7F;
    static constexpr UInt8 MASK_FLAG = 0x80;
};

The top bit is the “skip this row” flag (used when a lightweight delete mask removes rows during the merge), and the low seven bits are the source part number. Seven bits means at most 127 source parts per merge — and you can watch that limit propagate all the way into the merge-algorithm choice (see “When is it worth it?” below).

The static_assert is not decoration. If the compiler ever made this struct two bytes, the row-source file would double in size and every write to it would become a two-byte write. The assert turns a silent performance regression into a build failure.

Who writes the bytes

The horizontal stage’s merge transform writes one byte per output row as it emits it. For the Ordinary merging mode that is the plain k-way merge algorithm:

cpp
// src/Processors/Merges/Algorithms/MergingSortedAlgorithm.cpp:444
void MergingSortedAlgorithm::insertRow(const SortCursorImpl & current)
{
    auto write_row_source = [&](bool skipped)
    {
        if (out_row_sources_buf)
        {
            RowSourcePart row_source(current.order, skipped);
            out_row_sources_buf->write(row_source.data);
        }
    };

    size_t current_row = current.getRow();

    if (const auto * mask = source_row_filter_masks[current.order])
    {
        if ((*mask)[current_row])
            merged_data.insertRow(current.all_columns, current_row, current.rows);

        write_row_source(!(*mask)[current_row]);
    }
    else
    {
        merged_data.insertRow(current.all_columns, current_row, current.rows);
        write_row_source(false);
    }
}

current.order is the index of the source stream the row came from. The merged algorithm already knows this — it is the whole reason it can merge — so emitting it costs one byte write per row. One byte per row of overhead on a merge that is about to rewrite gigabytes.

Note the ordering: merged_data.insertRow(...) happens first, then the row source is written. The write buffer is only non-null for a vertical merge — the merge step is constructed with the file name only when the vertical algorithm was chosen:

cpp
// src/Storages/MergeTree/MergeTask.cpp:3654
        auto merge_step = std::make_unique<MergePartsStep>(
            merge_parts_query_plan.getCurrentHeader(),
            sort_description,
            partition_and_sorting_required_columns,
            global_ctx->merging_params,
            (is_vertical_merge ? RowsSourcesTemporaryFile::FILE_ID : ""), /// rows_sources' temporary file is used only for vertical merge
            filter_column_name,
            (*merge_tree_settings)[MergeTreeSetting::merge_max_block_size],
            (*merge_tree_settings)[MergeTreeSetting::merge_max_block_size_bytes],
            max_dynamic_subcolumns,
            is_vertical_merge,
            sorting_queue_strategy,
            cleanup,
            global_ctx->time_of_merge);

so a horizontal merge pays nothing for the side channel.

The vertical stage: gathering one column at a time

Now the second stage. It does not merge anything. It gathers. For each non-key column, in turn:

  1. open a read stream for that column in every source part;
  2. union those streams;
  3. run a ColumnGathererTransform over them, which uses the row-source file to decide which source’s value belongs at each output position.

Here is the step that wires the gatherer into the plan:

cpp
// src/Storages/MergeTree/MergeTask.cpp:2172
        createReadFromPartStep(
            MergeTreeSequentialSourceType::Merge,
            *plan_for_part,
            *global_ctx->data,
            global_ctx->storage_snapshot,
            RangesInDataPart(global_ctx->future_part->parts[part_num], nullptr, part_num, part_starting_offset),
            global_ctx->alter_conversions[part_num],
            global_ctx->merged_part_offsets,
            Names{column_name},
            global_ctx->input_rows_filtered,
            apply_deleted_mask,
            /*filter=*/ std::nullopt,
            ctx->read_with_direct_io,
            ctx->use_prefetch,
            global_ctx->context,
            getLogger("VerticalMergeStage"));

Names{column_name} — exactly one column is read per part. The read plan for a vertical merge is a column read plan, not a row read plan. Every source part is opened, and only the column of interest is decoded.

Then the per-part streams are unioned, and a ColumnGathererStep is added to the plan. The gatherer is a merging transform, so it plugs into the same IMergingTransform machinery as the key merge: its input is one column per source part, its output is one merged column, and the row-source file is handed to it as a ReadBuffer by name.

The gather loop

The heart of it is ColumnGathererStream::gather. It reads the row-source bytes and copies values out of the per-part source columns:

cpp
// src/Processors/Transforms/ColumnGathererTransform.h:131
template <typename Column>
void ColumnGathererStream::gather(Column & column_res)
{
    row_sources_buf.nextIfAtEnd();
    RowSourcePart * row_source_pos = reinterpret_cast<RowSourcePart *>(row_sources_buf.position());
    RowSourcePart * row_sources_end = reinterpret_cast<RowSourcePart *>(row_sources_buf.buffer().end());

    if (next_required_source == -1)
    {
        /// Start new column.
        /// Actually reserve works only for fixed size columns.
        /// So it's safe to ignore preferred size in bytes and call reserve for number of rows.
        size_t size_to_reserve = std::min(static_cast<size_t>(row_sources_end - row_source_pos), block_preferred_size_rows);
        column_res.reserve(size_to_reserve);
    }

    next_required_source = -1;

    /// We use do ... while here to ensure there will be at least one iteration of this loop.
    /// Because the column_res.byteSize() could be bigger than block_preferred_size_bytes already at this point.
    do
    {
        if (row_source_pos >= row_sources_end)
            break;

        RowSourcePart row_source = *row_source_pos;
        size_t source_num = row_source.getSourceNum();
        Source & source = sources[source_num];
        bool source_skip = row_source.getSkipFlag();

        if (source.pos >= source.size) /// Fetch new block from source_num part
        {
            next_required_source = source_num;
            return;
        }

        ++row_source_pos;

        /// Consecutive optimization. TODO: precompute lengths
        size_t len = 1;
        size_t max_len = std::min(static_cast<size_t>(row_sources_end - row_source_pos), source.size - source.pos); // interval should be in the same block

        while (len < max_len && row_source_pos->data == row_source.data)
        {
            ++len;
            ++row_source_pos;
        }

        row_sources_buf.position() = reinterpret_cast<char *>(row_source_pos);

        if (!source_skip)
        {
            /// Whole block could be produced via copying pointer from current block
            if (source.pos == 0 && source.size == len)
            {
                /// If current block already contains data, return it.
                /// Whole column from current source will be returned on next read() iteration.
                source_to_fully_copy = &source;
                return;
            }
            if (len == 1)
                column_res.insertFrom(*source.column, source.pos);
            else
                column_res.insertRangeFrom(*source.column, source.pos, len);
        }

        source.pos += len;
    } while (column_res.size() < block_preferred_size_rows && column_res.byteSize() < block_preferred_size_bytes);
}

Three optimizations are hiding in there.

Run-length coalescing. The inner while loop scans forward over consecutive row-source bytes that are identical. Since the byte encodes “which source part”, a run of identical bytes means “these N output rows came from the same source part, consecutively” — which, because the source part’s own column is read in order, means “these N output rows are exactly source part’s rows [pos, pos+N)”. So the whole run is copied with a single insertRangeFrom call instead of N single-element inserts. The comment /// Consecutive optimization. TODO: precompute lengths is honest about the remaining inefficiency: the run length is rediscovered for every column, N times.

Whole-column pointer steal. If the run covers the entire remaining source column (source.pos == 0 && source.size == len), the output column can simply be the source column — no copy at all. The transform returns the source ColumnPtr directly and sets source_to_fully_copy, and the merge() method hands it out:

cpp
// src/Processors/Transforms/ColumnGathererTransform.cpp:114
        else
        {
            res.addColumn(source_to_fully_copy->column);
        }

For a merge where one part dominates — the common case after a few rounds of merging, where you merge one big part with one small one — most columns of the big part are copied by pointer. The “merge” of such a column is essentially free.

Sparse output. The source_skip flag comes from the lightweight-delete mask: rows deleted by a DELETE are recorded in the row-source stream with the skip bit set, and the gatherer simply does not copy them out. The horizontal stage does the same filtering, so the two stages agree on which rows exist without either having to re-evaluate the delete predicate.

Writing the gathered column

The gathered column is written by MergedColumnOnlyOutputStream — the vertical stage’s counterpart of MergedBlockOutputStream, except that it writes exactly one column. Its constructor carries the invariant that makes the whole thing safe:

cpp
// src/Storages/MergeTree/MergedColumnOnlyOutputStream.cpp:36
    /// Granularity is never recomputed while writing only columns.
    MergeTreeWriterSettings writer_settings(

The horizontal stage has already decided where the granule boundaries are (it wrote the marks for the key columns), and the payload columns must use the same boundaries, or the part would be corrupt.

When is it worth it?

Vertical merging is not always a win. It has costs: an extra pass over the row-source file per column, an extra file in the temporary space, and a pipeline per column. So chooseMergeAlgorithm() gates it behind a set of conditions:

cpp
// src/Storages/MergeTree/MergeTask.cpp:3760
MergeAlgorithm MergeTask::ExecuteAndFinalizeHorizontalPart::chooseMergeAlgorithm() const
{
    const size_t total_rows_count = global_ctx->merge_list_element_ptr->total_rows_count;
    const size_t total_size_bytes_uncompressed = global_ctx->merge_list_element_ptr->total_size_bytes_uncompressed;
    const auto & merge_tree_settings = global_ctx->data_settings;

    if (global_ctx->deduplicate)
        return MergeAlgorithm::Horizontal;
    if ((*merge_tree_settings)[MergeTreeSetting::enable_vertical_merge_algorithm] == 0)
        return MergeAlgorithm::Horizontal;
    if (ctx->need_remove_expired_values)
    {
        /// `TTLTransform` stops reading after the first block when the rows TTL has expired for the whole part,
        /// while a vertical merge reads every key row to write `rows_sources`.
        if (global_ctx->future_part->merge_type == MergeType::TTLDrop && global_ctx->metadata_snapshot->hasRowsTTL())
            return MergeAlgorithm::Horizontal;
        if (!canVerticalTTLDelete(*global_ctx))
            return MergeAlgorithm::Horizontal;
    }
    if (global_ctx->future_part->part_format.part_type != MergeTreeDataPartType::Wide)
        return MergeAlgorithm::Horizontal;
    if (global_ctx->cleanup)
        return MergeAlgorithm::Horizontal;

and then the quantitative thresholds:

cpp
// src/Storages/MergeTree/MergeTask.cpp:3793
    bool is_supported_storage =
        global_ctx->merging_params.mode == MergeTreeData::MergingParams::Ordinary ||
        global_ctx->merging_params.mode == MergeTreeData::MergingParams::Collapsing ||
        global_ctx->merging_params.mode == MergeTreeData::MergingParams::Replacing ||
        global_ctx->merging_params.mode == MergeTreeData::MergingParams::VersionedCollapsing;

    bool enough_ordinary_cols = global_ctx->gathering_columns.size() >= (*merge_tree_settings)[MergeTreeSetting::vertical_merge_algorithm_min_columns_to_activate];

    bool enough_total_rows = total_rows_count >= (*merge_tree_settings)[MergeTreeSetting::vertical_merge_algorithm_min_rows_to_activate];

    bool enough_total_bytes = total_size_bytes_uncompressed >= (*merge_tree_settings)[MergeTreeSetting::vertical_merge_algorithm_min_bytes_to_activate];

    bool no_parts_overflow = global_ctx->future_part->parts.size() <= RowSourcePart::MAX_PARTS;

    auto merge_alg = (is_supported_storage && enough_total_rows && enough_total_bytes && enough_ordinary_cols && no_parts_overflow) ?
                        MergeAlgorithm::Vertical : MergeAlgorithm::Horizontal;

    return merge_alg;
}

Read those conditions as a description of when the vertical algorithm is the right shape.

Wide parts only, because a Compact part stores all columns in one data file and you cannot read one column of it without reading the others. No deduplication, because deduplication compares whole rows and so needs all columns at once. Only Ordinary/Collapsing/Replacing/VersionedCollapsing merging modes, because Summing/Aggregating/Coalescing merge the values of equal keys and so have to merge the payload rather than gather it. At least 11 non-key columns (vertical_merge_algorithm_min_columns_to_activate) and at least 16·8192 rows (vertical_merge_algorithm_min_rows_to_activate) — below those, the extra machinery is not worth it. And at most 127 source parts, because RowSourcePart::MAX_PARTS is 0x7F and the one-byte encoding cannot express more.

The default thresholds live in MergeTreeSettings: enable_vertical_merge_algorithm = 1, vertical_merge_algorithm_min_rows_to_activate = 16 * 8192, vertical_merge_algorithm_min_bytes_to_activate = 0, and vertical_merge_algorithm_min_columns_to_activate = 11.

Note the framing of the setting description: “potentially at the cost of merge speed”. Vertical merging is a memory optimization, not a speed one. It trades IO locality (one column at a time, so each source file is opened and read once per column rather than once for all columns) for peak memory. For a table where one column dominates the size, that trade is usually right; for a table with many equally-sized columns, the re-reading can dominate.

The Wide-only restriction, and the escape hatch

The Wide restriction is the interesting one. A Compact part interleaves all columns of a row in a single .bin file, so “read only column X” is impossible — you would have to decode the whole file. The sources may still be Compact while the result is Wide, and that case is allowed by allow_vertical_merges_from_compact_to_wide_parts (default true):

cpp
// src/Storages/MergeTree/MergeTask.cpp:3784
    if (!(*merge_tree_settings)[MergeTreeSetting::allow_vertical_merges_from_compact_to_wide_parts])
    {
        for (const auto & part : global_ctx->future_part->parts)
        {
            if (!isWidePart(part))
                return MergeAlgorithm::Horizontal;
        }
    }

A Compact source is read once per column during the vertical stage, so its file gets decoded N times. That is expensive but it is bounded — and it buys the memory reduction for wide tables whose legacy parts happen to be Compact.

Progress accounting

A merge is a long-running background job, and system.merges shows a progress bar. For a vertical merge, “progress” spans two axes: first the key columns are merged (the horizontal stage), then each payload column is gathered (the vertical stage). The horizontal stage is weighted by the size of the key columns relative to the whole part; the vertical stage is weighted by the size of each individual column:

cpp
// src/Storages/MergeTree/ColumnSizeEstimator.h:9
/* Allow to compute more accurate progress statistics */
class ColumnSizeEstimator
{
    using ColumnToSize = std::map<String, UInt64>;
    ColumnToSize map;
public:

    /// Stores approximate size of columns in bytes
    /// Exact values are not required since it used for relative values estimation (progress).
    size_t sum_total = 0;
    size_t sum_index_columns = 0;
    size_t sum_ordinary_columns = 0;

    ColumnSizeEstimator(ColumnToSize && map_, const NamesAndTypesList & key_columns, const NamesAndTypesList & ordinary_columns)
        : map(std::move(map_))
    {
        for (const auto & [name, _] : key_columns)
            if (!map.contains(name)) map[name] = 0;
        for (const auto & [name, _] : ordinary_columns)
            if (!map.contains(name)) map[name] = 0;

        for (const auto & [name, _] : key_columns)
            sum_index_columns += map.at(name);

        for (const auto & [name, _] : ordinary_columns)
            sum_ordinary_columns += map.at(name);

        sum_total = std::max(static_cast<decltype(sum_index_columns)>(1), sum_index_columns + sum_ordinary_columns);
    }

    Float64 columnWeight(const String & column) const
    {
        return static_cast<Float64>(map.at(column)) / static_cast<Float64>(sum_total);
    }

    Float64 keyColumnsWeight() const
    {
        return static_cast<Float64>(sum_index_columns) / static_cast<Float64>(sum_total);
    }
};

The weights are proportional to bytes, not rows, so a merge that is 90% one giant String column reports 90% progress for most of its duration and then jumps. That’s the honest answer for a byte-oriented operation. The sizes come from the parts themselves (accumulateColumnSizes, a cheap per-part statistic), so the whole estimator costs one map construction per merge.

The horizontal stage’s contribution: the row count assertion

There is one more piece of rigor worth pointing out. After the horizontal stage finishes and the row-source file is closed, the code checks that the file contains exactly one byte per input row:

cpp
// src/Storages/MergeTree/MergeTask.cpp:2055
    size_t rows_sources_count = ctx->rows_sources_temporary_file->finalizeWriting();

    /// In special case, when there is only one source part, and no rows were skipped, we may have
    /// skipped writing rows_sources file. Otherwise rows_sources_count must be equal to the total
    /// number of input rows.
    /// Note that only one byte index is written for each row, so number of rows is equals to the number of bytes written.
    if ((rows_sources_count > 0 || global_ctx->future_part->parts.size() > 1) && sum_input_rows_exact != rows_sources_count + input_rows_filtered)
        throw Exception(
                        ErrorCodes::LOGICAL_ERROR,
                        "Number of rows in source parts ({}) excluding filtered rows ({}) differs from number "
                        "of bytes written to rows_sources file ({}). It is a bug.",
                        sum_input_rows_exact, input_rows_filtered, rows_sources_count);

This is a cheap, total consistency check on the side channel. If the horizontal stage and the vertical stage ever disagree about which rows exist, the merge fails loudly rather than producing a part whose payload columns are misaligned with its key — which would be a silent data corruption.

Note the subtlety in the condition: with exactly one source part and no filtered rows, the file may legitimately be empty (a single-source merge does not need to record where rows came from), so the check is skipped in that one case.

Prefetching the next column

Because the vertical stage reads one column at a time, and each column read is a separate pipeline over the source parts, the obvious optimization is to start reading column N+1 while column N is still being written. The code does exactly that, and only for remote filesystems, where the latency of the first read of a column is the dominant cost:

cpp
// src/Storages/MergeTree/MergeTask.cpp:2076
    bool all_parts_on_remote_disks = std::ranges::all_of(global_ctx->future_part->parts, [](const auto & part) { return part->isStoredOnRemoteDisk(); });
    ctx->use_prefetch = all_parts_on_remote_disks && storage_settings[MergeTreeSetting::vertical_merge_remote_filesystem_prefetch];

    if (ctx->use_prefetch && ctx->it_name_and_type != global_ctx->gathering_columns.end())
        ctx->prepared_pipeline = createPipelineForReadingOneColumn(ctx->it_name_and_type->name);

The “prefetching” is simply that building the pipeline — which opens the parts and issues the first read requests — starts the IO. It is not a separate prefetch thread; it is eager pipeline construction, one column ahead (prepareVerticalMergeForOneColumn builds the next column’s pipeline while the current one runs).

The whole flow

text
   source parts (Wide)
   ┌──────────────┐  ┌──────────────┐  ┌──────────────┐
   │ k, a, b, c   │  │ k, a, b, c   │  │ k, a, b, c   │
   └──────┬───────┘  └──────┬───────┘  └──────┬───────┘
          │                 │                 │
          └────────────┬────┴─────────────────┘
                       │  HORIZONTAL STAGE
                       │  k-way merge of the key columns
                       │  emits: merged k  +  rows_sources (1 byte/row)
                       ▼
        ┌──────────────────────────────────────────┐
        │ rows_sources: 00 00 01 01 02 00 01 ...   │
        └──────────────────────────────────────────┘
                       │
     ┌─────────────────┼──────────────────┐
     ▼                 ▼                  ▼
  gather col a     gather col b       gather col c      VERTICAL STAGE
  read a from     read b from        read c from       one column at a time
  each part,       each part,         each part,
  copy by          copy by            copy by
  rows_sources     rows_sources       rows_sources
     │                 │                  │
     ▼                 ▼                  ▼
     └────────────┬────┴──────────────────┘
                  ▼
        result part (Wide): k, a, b, c

What the hack buys you, and what it costs

It buys you peak memory proportional to the widest column, not the sum of the columns. For a table with a 40 MB String column and fifty UInt64 columns, a horizontal merge holds the payload of all the columns at once; a vertical merge holds one column at a time. That is the difference between a merge that OOMs and one that does not — and it is what makes the “wide part” format practical at all.

It costs you IO locality. Each column is a separate pass over the source parts, so a Compact source file is decoded once per column — which is why allow_vertical_merges_from_compact_to_wide_parts defaults to true but carries a warning in its description.

It costs you a temporary file. One byte per row of scratch space: 100 MB for a 100-million-row merge. Trivial compared to the data being merged, but not zero.

It costs you the ability to merge every kind of table. Summing, Aggregating and Coalescing merges have to combine the values of equal keys, which is a merge, not a gather. They stay horizontal, permanently.

It is not automatically faster. The setting description says it plainly: “potentially at the cost of merge speed”. The vertical algorithm optimizes memory, and only optimizes IO when a whole column happens to come from a single source part and can be stolen by pointer.

Seeing it in action

You can see which algorithm a merge used from the profile events:

sql
SELECT
    event,
    value
FROM system.events
WHERE event IN (
    'MergeVerticalStageExecuteMilliseconds',
    'MergeHorizontalStageTotalMilliseconds',
    'MergeVerticalStageTotalMilliseconds'
);

MergeVerticalStageExecuteMilliseconds is only non-zero for tables where the vertical algorithm actually ran. If it is zero on your wide table, check enable_vertical_merge_algorithm, the part format (Compact parts cannot be vertically merged), and the vertical_merge_algorithm_min_* thresholds.

And to see the tables that would benefit:

sql
SELECT
    database,
    table,
    formatReadableSize(sum(bytes_on_disk)) AS size,
    sum(rows) AS rows
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY sum(bytes_on_disk) DESC
LIMIT 10;

A table with hundreds of columns and a large String payload is exactly the shape the vertical merge was built for. A table with ten narrow columns is not, and the thresholds will keep it horizontal.

Wrapping up

The one thing to remember: the vertical merge is a memory optimization, not a speed one. It trades IO locality for peak memory, and it wins big exactly when one column dominates your table’s width.

The part people get wrong: the whole thing hinges on a one-byte-per-row side channel. If you ever see a merge fail with “It is a bug”, that assertion is the row-count check, and it means the two stages disagreed about which rows exist.

What to go do about it: count your columns. Under 11, or under 16·8192 rows, the vertical algorithm will never kick in — and that is deliberate, not a bug.

ClickhousePerformanceInternalsMerge treeStorage engine