ClickHouse Names a MergeTree Part After Its Partition and Block Range

A MergeTree part is a directory named partition_id_min_block_max_block_level, so a directory listing alone tells you which partition a part belongs to, which blocks it covers and how many merges deep it is.

TL;DR: A part’s directory name is <partition_id>_<min_block>_<max_block>_<level>[_<mutation>] — for example all_1_7_0 or 202401_42_57_3_9. The partition id is in the name, so ls on the data directory tells you which partition each part belongs to, which block range it covers, and how many merges it has been through — without opening a single file.

Every storage engine needs a way to answer “what do I have?”. For a MergeTree, the answer is a set of immutable directories of column files, and the question is where the index over those directories lives.

ClickHouse’s answer is that there is no separate index. The directory name is the index. It is a compact, human-readable encoding of the part’s identity, and every piece of the engine that needs to reason about parts — the merge selector, the partition pruner, the orphan sweeper, the replication protocol — reads it straight out of the name.

The name

text
<partition_id>_<minimum_block_number>_<maximum_block_number>_<level>_<data_version>

The engine’s own documentation of the system.parts.name column says exactly this:

cpp
// src/Storages/System/StorageSystemParts.cpp:71
        {"name",                                        std::make_shared<DataTypeString>(),    R"(
Name of the data part. The part naming structure can be used to determine many aspects of the data, ingest, and merge patterns. The part naming format is the following:

```text
<partition_id>_<minimum_block_number>_<maximum_block_number>_<level>_<data_version>
```

* Definitions:
  - `partition_id` - identifies the partition key
  - `minimum_block_number` - identifies the minimum block number in the part. ClickHouse always merges continuous blocks
  - `maximum_block_number` - identifies the maximum block number in the part
  - `level` - incremented by one with each additional merge on the part. A level of 0 indicates this is a new part that has not been merged. It is important to remember that all parts in ClickHouse are always immutable
  - `data_version` - optional value, incremented when a part is mutated (again, mutated data is always only written to a new part, since parts are immutable)
)"},

And here is the entire writer:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.cpp:223
String MergeTreePartInfo::getPartNameV1() const
{
    WriteBufferFromOwnString wb;

    writeString(partition_id, wb);
    writeChar('_', wb);
    writeIntText(min_block, wb);
    writeChar('_', wb);
    writeIntText(max_block, wb);
    writeChar('_', wb);
    if (use_legacy_max_level)
    {
        chassert(level == MAX_LEVEL);
        writeIntText(LEGACY_MAX_LEVEL, wb);
    }
    else
    {
        writeIntText(level, wb);
    }

    if (mutation)
    {
        writeChar('_', wb);
        writeIntText(mutation, wb);
    }

    return wb.str();
}

Thirty lines, no length prefixes, no checksum, no escaping. The name is the metadata, and it round-trips through a parser that is equally simple:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.cpp:64
std::optional<MergeTreePartInfo> MergeTreePartInfo::tryParsePartName(
    std::string_view part_name, MergeTreeDataFormatVersion format_version)
{
    ReadBufferFromString in(part_name);

    String partition_id;

    if (format_version < MERGE_TREE_DATA_MIN_FORMAT_VERSION_WITH_CUSTOM_PARTITIONING)
    {
        UInt32 min_yyyymmdd = 0;
        UInt32 max_yyyymmdd = 0;

        if (!tryReadIntText(min_yyyymmdd, in)
            || !checkChar('_', in)
            || !tryReadIntText(max_yyyymmdd, in)
            || !checkChar('_', in))
        {
            return std::nullopt;
        }

        partition_id = toString(min_yyyymmdd / 100);
    }
    else
    {
        while (!in.eof())
        {
            char c = 0;
            readChar(c, in);
            if (c == '_')
                break;

            partition_id.push_back(c);
        }
    }

    /// Sanity check
    if (partition_id.empty())
        return std::nullopt;

    Int64 min_block_num = 0;
    Int64 max_block_num = 0;
    UInt32 level = 0;
    UInt32 mutation = 0;

    if (!tryReadIntText(min_block_num, in)
        || !checkChar('_', in)
        || !tryReadIntText(max_block_num, in)
        || !checkChar('_', in)
        || !tryReadIntText(level, in))
    {
        return std::nullopt;
    }

    /// Sanity check
    if (min_block_num > max_block_num)
        return std::nullopt;

    if (!in.eof())
    {
        if (!checkChar('_', in)
            || !tryReadIntText(mutation, in)
            || !in.eof())
        {
            return std::nullopt;
        }
    }

The partition id is “everything up to the first underscore”. That is why partition ids are restricted to alphanumerics and dashes:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.cpp:51
    else
    {
        auto is_valid_char = [](char c) { return c == '-' || isAlphaNumericASCII(c); };
        if (!std::all_of(partition_id.begin(), partition_id.end(), is_valid_char))
            throw Exception(ErrorCodes::INVALID_PARTITION_VALUE, "Invalid partition format: {}", partition_id);
    }

No underscore in a partition id. Ever. That invariant is what makes the name parseable without a separator that needs escaping.

The partition id

The partition id is a string derived from the partition key’s value. How it is derived depends on the types involved:

cpp
// src/Storages/MergeTree/MergeTreePartition.cpp:231
String MergeTreePartition::getID(const Block & partition_key_sample) const
{
    if (value.size() != partition_key_sample.columns())
        throw Exception(ErrorCodes::LOGICAL_ERROR, "Invalid partition key size: {}", value.size());

    if (value.empty())
        return "all"; /// It is tempting to use an empty string here. But that would break directory structure in ZK.

    /// In case all partition fields are represented by integral types, try to produce a human-readable ID.
    /// Otherwise use a hex-encoded hash.
    /// NOTE It will work in unexpected way if some partition key column is Nullable:
    /// are_all_integral will be false if some value is NULL. Maybe we should fix it.
    bool are_all_integral = true;
    for (const Field & field : value)
    {
        if (field.getType() != Field::Types::UInt64 && field.getType() != Field::Types::Int64 && field.getType() != Field::Types::IPv4)
        {
            are_all_integral = false;
            break;
        }
    }

Three cases:

No partition key at all → the literal string all. This is where the familiar all_1_7_0 comes from — not from “wide parts”, as is often assumed, but from a table with no partition expression. The comment explains why it is not the empty string: “It is tempting to use an empty string here. But that would break directory structure in ZK.”

All-integral partition key → a human-readable id. A Date column becomes YYYYMMDD, so PARTITION BY event_date gives you 20240115; a toYYYYMM(event_date) gives 202401; a UInt64 gives the number itself. Multiple columns are joined with -:

cpp
// src/Storages/MergeTree/MergeTreePartition.cpp:253
    if (are_all_integral)
    {
        String result;
        FieldVisitorToString to_string_visitor;

        for (size_t i = 0; i < value.size(); ++i)
        {
            if (i > 0)
                result += '-';

            if (typeid_cast<const DataTypeDate *>(partition_key_sample.getByPosition(i).type.get()))
                result += toString(
                    DateLUT::serverTimezoneInstance().toNumYYYYMMDD(
                        DayNum(static_cast<DayNum::UnderlyingType>(value[i].safeGet<UInt64>()))));
            else if (typeid_cast<const DataTypeIPv4 *>(partition_key_sample.getByPosition(i).type.get()))
                result += toString(value[i].safeGet<IPv4>().toUnderType());
            else
                result += applyVisitor(to_string_visitor, value[i]);

            /// It is tempting to output DateTime as YYYYMMDDhhmmss, but that would make partition ID
            /// timezone-dependent.
        }

        return result;
    }

Note the last comment: a DateTime partition value is not rendered as a timestamp, because that would make the partition id depend on the server’s timezone — and partition ids are written to ZooKeeper and to directory names, so they must not change when the timezone does.

Anything else → a hex-encoded SipHash-128 of the partition value. So PARTITION BY city gives you a3f2b1... — 32 hex characters.

The name is enough to list a table

Here is the payoff. When ClickHouse starts and attaches a MergeTree table, it walks the data directory and parses the names:

cpp
// src/Storages/MergeTree/MergeTreeData.cpp:3319
        runner.enqueueAndKeepTrack([&expected_parts, &unexpected_disk_parts, &disk_parts, this, disk_ptr, component_name = Coordination::getCurrentComponent()]()
        {
            auto local_component_guard = Coordination::setCurrentComponent(component_name);
            for (auto it = disk_ptr->iterateDirectory(relative_data_path); it->isValid(); it->next())
            {
                /// Skip temporary directories, file 'format_version.txt' and directory 'detached'.
                if (startsWith(it->name(), "tmp")
                    || it->name() == MergeTreeData::FORMAT_VERSION_FILE_NAME
                    || it->name() == DETACHED_DIR_NAME)
                    continue;

                if (auto part_info = MergeTreePartInfo::tryParsePartName(it->name(), format_version))
                {
                    if (expected_parts && !expected_parts->contains(it->name()))
                        unexpected_disk_parts.emplace_back(*part_info, it->name(), disk_ptr);
                    else
                        disk_parts.emplace_back(*part_info, it->name(), disk_ptr);
                }
            }
        }, Priority{0});

A readdir, a name parse, and a MergeTreePartInfo struct. No file is opened, no columns.txt is read, no checksum is verified. The engine knows, from the name alone:

  • which partition the part belongs to (and therefore can prune it),
  • the range of block numbers it covers (and therefore can decide whether two parts overlap),
  • the merge level (and therefore how “deep” it is),
  • the mutation version (and therefore which mutations have been applied).

That last point is why the name needs the mutation suffix. A mutated part has the same block range as its parent but a different data version, and the engine has to be able to tell them apart:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.h:82
    /// Get block number that can be used to determine which mutations we still need to apply to this part
    /// (all mutations with version greater than this block number).
    Int64 getDataVersion() const { return mutation ? mutation : min_block; }

The name encodes the merge rule

The most interesting consequence of putting the block range and the level in the name is that the overlap rule becomes a string comparison. Two parts overlap if and only if their names say they do:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.h:86
    /// True if contains rhs (this part is obtained by merging rhs with some other parts or mutating rhs)
    bool contains(const MergeTreePartInfo & rhs) const
    {
        /// Containing part may have equal level iff block numbers are equal (unless level is MAX_LEVEL)
        /// (e.g. all_0_5_2 does not contain all_0_4_2, but all_0_5_3 or all_0_4_2_9 do)
        bool strictly_contains_block_range = (min_block == rhs.min_block && max_block == rhs.max_block)
            || level > rhs.level
            || level == MAX_LEVEL
            || level == LEGACY_MAX_LEVEL;

        return partition_id == rhs.getPartitionId()        /// Parts for different partitions are not merged
            && min_block <= rhs.min_block
            && max_block >= rhs.max_block
            && level >= rhs.level
            && mutation >= rhs.mutation
            && strictly_contains_block_range;
    }

Read the comment: “all_0_5_2 does not contain all_0_4_2, but all_0_5_3 or all_0_4_2_9 do”. The level bump is what makes a merge result strictly contain its inputs; a mutation (the _9 suffix) bumps the version instead.

And the disjointness test, which is the hot path of the merge selector:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.h:120
    /// True if parts do not intersect in any way.
    bool isDisjoint(const MergeTreePartInfo & rhs) const
    {
        return partition_id != rhs.getPartitionId()
            || min_block > rhs.max_block
            || max_block < rhs.min_block;
    }

No part sets, no range trees, no indexes. Two MergeTreePartInfo structs and four comparisons.

Which means you can read the merge tree of a table straight out of the names, without opening anything:

sql
SELECT name, partition_id, level, rows, min_block_number, max_block_number
FROM system.parts
WHERE database = 'default' AND table = 'events' AND active
ORDER BY partition_id, min_block_number;
text
all_1_1_0      level 0   1 row
all_2_2_0      level 0   1 row
all_3_3_0      level 0   1 row
all_1_3_1      level 1   3 rows

The last line is the merge of the first three: same partition, block range 1..3, level 1. The merge selector will next look for another level-1 part with an adjacent block range.

Patch parts: a partition id with a prefix

Patch parts — the parts created by lightweight UPDATE — need a partition id of their own, because they live in their own partition (they are applied to the regular parts at read time). The partition id is a prefix plus a hash of the patch’s structure:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.h:167
    static constexpr std::string_view PATCH_PART_PREFIX = "patch-";
    /// The full prefix of patch part is "patch-<hash>-".
    /// The size of hash is 32 chars plus 1 char for extra dash.
    static constexpr UInt64 PATCH_PART_PREFIX_SIZE = PATCH_PART_PREFIX.size() + 32 + 1;

The hash is computed by a dedicated internal SQL function, which is where the “patch-” prefix and the 32 hex characters come from:

cpp
// src/Functions/patchPartitionID.cpp:51
        String patch_prefix{MergeTreePartInfo::PATCH_PART_PREFIX};
        patch_prefix += column_structure_hash->getDataAt(0);
        patch_prefix += "-";

        if (patch_prefix.size() != MergeTreePartInfo::PATCH_PART_PREFIX_SIZE)
            throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid prefix of patch part's partition id: {}", patch_prefix);

And the kind of a part — regular or patch — is derived from the partition id, not stored anywhere:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.h:34
    static Kind getKind(const String & partition_id)
    {
        return partition_id.starts_with(PATCH_PART_PREFIX) ? Kind::Patch : Kind::Regular;
    }

So a patch part’s directory name looks like patch-8f14e45fceea167a5a36dedd4bea2543-202401_1_2_0, and the engine knows it is a patch part, and which partition it patches, purely from the name.

The legacy format

Before custom partitioning keys, the name was YYYYMMDD_YYYYMMDD_min_max_level — the min and max dates of the part:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.cpp:253
String MergeTreePartInfo::getPartNameV0(DayNum left_date, DayNum right_date) const
{
    const auto & date_lut = DateLUT::serverTimezoneInstance();

    /// Directory name for the part has form: `YYYYMMDD_YYYYMMDD_N_N_L`.

    unsigned left_date_id = date_lut.toNumYYYYMMDD(left_date);
    unsigned right_date_id = date_lut.toNumYYYYMMDD(right_date);

This is the historical origin of the “min date” that people remember from part names. The V0 format was tied to a PARTITION BY toYYYYMM(...) table and encoded the month directly, which meant the name was not usable for arbitrary partition expressions. The V1 format generalised it: the partition id is just the partition id, and a Date partition key still gives you a date-looking prefix.

Reading a V0 name is still supported, and the parser branches on the format version:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.cpp:71
    if (format_version < MERGE_TREE_DATA_MIN_FORMAT_VERSION_WITH_CUSTOM_PARTITIONING)
    {
        UInt32 min_yyyymmdd = 0;
        UInt32 max_yyyymmdd = 0;

        if (!tryReadIntText(min_yyyymmdd, in)
            || !checkChar('_', in)
            || !tryReadIntText(max_yyyymmdd, in)
            || !checkChar('_', in))
        {
            return std::nullopt;
        }

        partition_id = toString(min_yyyymmdd / 100);
    }

20240115_20240131_0_5_1 parses back to partition id 202401.

The LEGACY_MAX_LEVEL scar

There is one truly ugly corner in the format, and it is worth reading because it shows what “immutable on-disk format” really costs.

Until 21.6, ClickHouse had two different “maximum levels”, and parts written with the old one could intersect parts written with the new one. The fix was not to renumber the old parts — that would have meant rewriting millions of directory names on disk — but to detect the old value at parse time and normalise it:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.cpp:137
    if (level == LEGACY_MAX_LEVEL)
    {
        /// We (accidentally) had two different max levels until 21.6 and it might cause logical errors like
        /// "Part 20170601_20170630_0_2_999999999 intersects 201706_0_1_4294967295".
        /// So we replace unexpected max level to make contains(...) method and comparison operators work
        /// correctly with such virtual parts. On part name serialization we will use legacy max level to keep the name unchanged.
        part_info.use_legacy_max_level = true;
        level = MAX_LEVEL;
    }

and the writer puts the old value back, so the name on disk does not change:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.cpp:233
    if (use_legacy_max_level)
    {
        chassert(level == MAX_LEVEL);
        writeIntText(LEGACY_MAX_LEVEL, wb);
    }

The two constants:

cpp
// src/Storages/MergeTree/MergeTreePartInfo.h:161
    static constexpr UInt32 MAX_LEVEL = 999999999;
    /// The maximum block number cannot be higher than the maximum sequence number that Keeper can handle.
    /// The sequence number was Int32 originally, it was recently changed to Int64 (https://github.com/ClickHouse/ClickHouse/pull/99120).
    /// To be safe, we keep it as Int32, but more than doubled than the old maximum block number (999999999).
    /// Once we are sure bigger block numbers don't cause any issues, we can increase it even higher.
    static constexpr UInt32 MAX_BLOCK_NUMBER = static_cast<UInt32>(std::numeric_limits<Int32>::max());

Temporary names, and the one place a random suffix appears

Live parts have the canonical name. Everything else is prefixed:

prefixmeaning
tmp_insert_a part being written by an INSERT
tmp_merge_a part being written by a merge
tmp_mutation_a part being written by a mutation
tmp-fetch_a part being fetched from a replica
delete_tmp_a part being deleted (renamed first, then removed)
<reason>_a detached part: broken_, unexpected_, noquorum_, ignored_, broken-on-start_, clone_, attaching_, deleting_, tmp-fetch_, covered-by-broken_, …

The delete prefix is applied by renaming the part first, so that a crash during the (slow, recursive) deletion leaves a recognisable directory rather than a half-deleted part:

cpp
// src/Storages/MergeTree/DataPartStorageOnDiskBase.cpp:827
    /// NOTE We rename part to delete_tmp_<relative_path> instead of delete_tmp_<name> to avoid race condition
    /// when we try to remove two parts with the same name, but different relative paths,
    /// for example all_1_2_1 (in Deleting state) and tmp_merge_all_1_2_1 (in Temporary state).
    fs::path from = fs::path(root_path) / part_dir;
    // TODO directory delete_tmp_<name> is never removed if server crashes before returning from this function

and the recovery sweeper knows which prefixes to clean up:

cpp
// src/Storages/MergeTree/MergeTreeData.h:1115
    /// Root-level temporary directory prefixes used by startup/drop recovery and by ownership checks.
    /// Includes `delete_tmp_` because it can be left by interrupted part removal.
    static constexpr std::array<std::string_view, 3> ROOT_TEMPORARY_DIRECTORY_PREFIXES_FOR_RECOVERY = {"tmp_", "delete_tmp_", "tmp-fetch_"};

Now, the “random suffix” — it exists in exactly one place, and the comment explaining it is one of the best in the codebase:

cpp
// src/Storages/MergeTree/MergeTreeData.cpp:11856
    /// 48 random bits, written as 12 hexadecimal digits, are kept out of the generated UUID, so the
    /// whole basename is a fixed `tmp_merge_dr_<12 hex>`: 25 bytes of the filename limit, 36 once
    /// `IMergeTreeDataPart::remove` prepends `delete_tmp_`, which is 6 bytes more than the merge of
    /// the shortest possible pair of parts takes and no more than the merge of any part whose name
    /// is 15 characters or longer. The temporary directory of a dry run lives only for that one query, and
    /// there are at most a handful of them at a time, so 48 bits leave the collision probability at
    /// nothing.
    const String dry_run_suffix = String(MergeTask::DRY_RUN_TEMP_INFIX)
        + getHexUIntLowercase(UUIDHelpers::getLowBytes(UUIDHelpers::generateV4())).substr(4);

Read the constraint: the random suffix has to be short (because the filename limit is 255 bytes and delete_tmp_ eats 10 of them) but collision-free (because two dry runs sharing a directory name would collide). 48 bits is the answer, and it is derived from a UUID — but from the middle of it, so the prefix has a fixed length.

This is the opposite of the part name itself, which is completely deterministic. The distinction is deliberate: a live part’s name must be reproducible (the merge selector computes it, the replication protocol transmits it, the directory name must match the ZK entry), while a temporary directory only has to be unique among the processes currently alive on that disk.

The part UUID

The name is not unique: two parts on two different servers can have the same name. So there is a separate UUID, off by default:

cpp
// src/Storages/MergeTree/MergeTreeSettings.cpp:2289
    DECLARE(Bool, assign_part_uuids, false, R"(
When enabled, a unique part identifier will be assigned for every new part.
Before enabling, check that all replicas support UUID version 4.
)", 0) \

Assigned once, at part creation:

cpp
// src/Storages/MergeTree/MergeTreeData.cpp:15131
    if ((*settings)[MergeTreeSetting::assign_part_uuids])
        new_data_part->uuid = UUIDHelpers::generateV4();

and generated with a fast, non-cryptographic RNG — the same one used for query ids:

cpp
// src/Core/UUID.cpp:11
    UUID generateV4()
    {
        UUID uuid;
        getHighBytes(uuid) = (thread_local_rng() & 0xffffffffffff0fffull) | 0x0000000000004000ull;
        getLowBytes(uuid) = (thread_local_rng() & 0x3fffffffffffffffull) | 0x8000000000000000ull;

        return uuid;
    }

The masks set the version nibble (0x4000 → version 4) and the variant bits (0x8000... → RFC 4122 variant), so the value is a well-formed UUIDv4 even though the entropy comes from thread_local_rng() rather than /dev/urandom.

The UUID is written to uuid.txt next to the data, and it is what the delete-bitmap cache uses as its identity:

cpp
// src/Storages/MergeTree/IMergeTreeDataPart.cpp:2124
std::string IMergeTreeDataPart::getDeleteBitmapCacheIdentity() const
{
    if (uuid == UUIDHelpers::Nil)
        return getDataPartStorage().getDiskName() + ":" + getRelativePathOfActivePart();
    return toString(uuid);
}

Note the fallback: with no UUID, the identity is the disk name plus the path. With a UUID, it is the UUID alone — which means the identity survives the part being moved between disks.

The whole picture

text
   partition key value
          |
          |  MergeTreePartition::getID()
          |    no key          -> "all"
          |    all integral   -> "202401" / "20240115" / "7"
          |    otherwise      -> sipHash128 hex
          v
    partition_id
          |
          |  + min_block, max_block, level, [mutation]
          v
    MergeTreePartInfo::getPartNameV1()
          |
          v
   "all_1_7_0"      "202401_42_57_3"      "202401_42_57_3_9"
   "patch-8f14...-202401_1_2_0"
          |
          v
   directory name on disk  ==  directory name in ZooKeeper  ==  part->name
          |
          |  tryParsePartName() at startup: readdir + parse, no file opened
          v
   MergeTreePartInfo  ->  partition pruning, overlap detection, merge selection

Trade-offs

What putting the index in the name buys you:

  • Listing a table is a readdir. No separate manifest, no catalogue file, no chance of the catalogue and the filesystem disagreeing. The filesystem is the catalogue. If a part is on disk, it is in the list.
  • Crash recovery is trivial. A part that was renamed into place is there; a part that was not is not. There is no “committed but not recorded” state, because there is no separate record.
  • The name is self-describing. ls on the data directory tells you which partition, which blocks, which level. You can debug a merge by looking at a directory listing.
  • The merge rule is a comparison. Overlap detection is four integer comparisons on structs that were parsed from strings.

What it costs:

  • The name is a compatibility surface. A change to the format is a change to every existing part on every existing server. That is why LEGACY_MAX_LEVEL exists, and why the MAX_LEVEL / LEGACY_MAX_LEVEL distinction is preserved on the write path so the name never changes.
  • The partition id must be filename-safe. No underscores, no slashes, limited to alphanumerics and dashes. A partition key that produces something else gets hashed — which loses readability but keeps safety.
  • The hash loses information. PARTITION BY city gives you 32 hex characters, not berlin. You cannot tell from the name which partition a part belongs to; you have to open the part (or read partition.dat).
  • Deterministic names need a global counter. Block numbers come from a per-table counter, and the merge level from the merge tree’s structure. Getting a unique name for a temporary directory, in contrast, needs randomness — and the randomness has to be short enough to fit in a filename. The tmp_merge_dr_<12 hex> comment is a whole essay in twelve characters.

Wrapping up

The one thing to remember: a ClickHouse part’s directory name is <partition_id>_<min_block>_<max_block>_<level>[_<mutation>], and it is a complete, parseable index. The engine reads the name to decide what to prune, what to merge and what to replicate — and it does that at startup without opening a single file of the part.

The part people get wrong: the all in all_1_7_0 is not about wide parts and not about “all columns”. It is the partition id of a table with no partition key. And the date-looking prefix in 202401_42_57_3 is a partition id that happens to be readable because the partition key was integral.

What to go do about it: look at the directory.

sql
SELECT name, partition_id, level, rows FROM system.parts WHERE table = 'your_table' AND active;

then compare with ls of the table’s directory. Every name matches, and you can now predict what the merge selector will do next: it will merge the parts of the same partition whose block ranges are adjacent and whose levels are equal.

ClickhouseInternalsMergetreeStorage