Blog· Tested on ClickHouse 26.4

A table that is only another name for a table

ClickHouse had persistent column aliases but no persistent table alias. I asked for CREATE ALIAS; the maintainers turned it into the Alias table engine (25.10). Why an engine, why the first implementation was called fragile, what the existing workarounds cannot do, and the atomic cutover that works today.

Source: github.com

ClickHouse has column aliases that persist, the ALIAS column kind, and table aliases that don’t: FROM long_table_name AS t1 lives for one query. There was no way to say “target is another name for target_v2” in the catalogue. I filed #68152 asking for CREATE ALIAS target FOR TABLE target_v2, an ALTER ALIAS ... MODIFY FOR TABLE to repoint it, and an EXCHANGE variant to make the repointing atomic. What shipped is different in shape, and the differences are the interesting part.

The use case, and what already existed

The case that sent me to the tracker was a materialized view writing to a target table whose PARTITION BY turned out to be wrong. A partition key cannot be altered, so the fix is a new table and a cutover, and the view is bound to the name of the old one.

Here is what the existing tools do with that, all run on clickhouse local 26.4.

A plain View is read-only, so it cannot be the target of anything:

Code: 48. DB::Exception: Method write is not supported by storage View. (NOT_IMPLEMENTED)

A Merge table is the answer the community gave when someone asked the same question later (#85735, where den-crane pointed at the Merge engine and the merge table function). It fans reads out over a regexp of table names, which is a real alias for reading. It does not write:

Code: 48. DB::Exception: Method write is not supported by storage Merge. (NOT_IMPLEMENTED)

A Distributed table can forward writes, but it needs a cluster definition to say “same node, other name”. I could not run that variant on clickhouse local; it needs a server.

That leaves EXCHANGE TABLES, which I had already put in the issue, and which I should have credited more. It swaps two names atomically in an Atomic database, and a materialized view stores its target by name, so it follows the swap:

CREATE TABLE per_type     (event_type LowCardinality(String), n UInt64)
ENGINE = SummingMergeTree PARTITION BY cityHash64(event_type) % 4 ORDER BY event_type;
CREATE TABLE per_type_new (event_type LowCardinality(String), n UInt64)
ENGINE = SummingMergeTree ORDER BY event_type;
CREATE MATERIALIZED VIEW per_type_mv TO per_type
AS SELECT event_type, count() AS n FROM events GROUP BY event_type;

INSERT INTO events VALUES (now(), 1, 'click', '/a', 10, 'TR');
EXCHANGE TABLES per_type AND per_type_new;
INSERT INTO events VALUES (now(), 2, 'click', '/b', 10, 'TR');

After the exchange per_type has no partition key and one row, and per_type_new holds the old partitioned table with the old row. So for the narrow case in my issue, the swap was already possible. What it does not give you is a name that stays put: the physical tables trade names, so the thing called per_type is a different table afterwards, and the old data is now sitting under a name that says “new”. If you version tables (_v1, _v2) and want consumers to see one stable name, you want a layer that points, not a pair of names that swap.

Why an engine and not a statement

The maintainer reply came the next day. alexey-milovidov wrote:

Better

CREATE TABLE table ALIAS another_table

implemented with

CREATE TABLE table ENGINE = Alias(db.another_table)

That is a design decision in two lines. A new top-level statement (CREATE ALIAS) would need its own parser node, its own catalogue object kind, its own SHOW, DROP, RENAME, grants, backup handling and replication through ON CLUSTER and Replicated databases. An engine gets all of that by being a table. The alias shows up in system.tables, takes DROP TABLE and RENAME TABLE, can be granted on, and backed up, because it is a table whose storage happens to be a pointer.

The price is that every place in the server that handles tables now has to handle a table that is not really one.

The first attempt was merged and then called fragile

The first piece was #76569, “Basic implementation of StorageAlias”, by RinChanNOWWW, which cites this issue. Its description is candid about the approach: “To simplify the codes, I don’t fully overwrite IStorage. Instead, I only implement a minimal part of IStorage, and replace the alias table with the referenced table after getting the storage ptr from database catalog.” DROP, DETACH and SHOW CREATE TABLE were special cases that still saw the alias itself. It merged on 2025-08-28, reviewed by hanfei1991. Late in review he wrote that, because the DDL guard is keyed on table name, he forbade ALTER TABLE on an alias: “the users should modify the original table by themselves”.

On 2025-09-24 alexey-milovidov commented on that PR: “This PR will be reverted. The implementation is unnecessarily fragile.” A resolve-and-substitute design means every code path that can see a table before the substitution is a potential bug, and the rest of the server was not written with that in mind. I did not verify whether a literal revert was merged. What I can say from the tracker is that the engine that shipped is a reimplementation by nauu: #87965, “Add Alias Engine”, merged 2025-10-07 and included in the 25.10 stable tag (I checked the tag against the merge commit). There the alias is a real IStorage that forwards operations to its target, and the PR’s table of what is proxied (SELECT, INSERT, ALTER ... ADD COLUMN, partition operations, mutations, OPTIMIZE, TRUNCATE) versus what touches only the alias (DROP, RENAME) is the design in miniature. The earlier “no ALTER” restriction is gone.

Review of #87965 shows what an indirection costs. vitlibar asked about backups: RESTORE recreates tables in no guaranteed order, so an alias can be restored before its target, which needed code in the dependency visitor. azat pointed out that the write path skipped transaction checks and could mismatch types, and asked for writes to go through InterpreterInsertQuery, as Buffer does. And the first question, from vitlibar, was about access control: “If you don’t have access to some table, but you make an alias for it. Will it give you the access to it?” nauu added an access check in the PR. That was not the end of the question, as the security section below shows.

What shipped, on 26.4

On 26.4 the engine is still gated:

Code: 344. DB::Exception: Experimental Alias table engine is not enabled
(turn on setting 'allow_experimental_alias_table_engine'). (SUPPORT_IS_DISABLED)

With the setting on, it forwards reads and writes, and SHOW CREATE shows the alias, not the target:

SET allow_experimental_alias_table_engine = 1;
CREATE TABLE t1 (x UInt8) ENGINE = MergeTree ORDER BY x;
CREATE TABLE a ENGINE = Alias(t1);
INSERT INTO a VALUES (7);
ALTER TABLE a ADD COLUMN y String DEFAULT 'q';
SELECT * FROM t1;
7	q

The ALTER through the alias changed t1. A few boundaries I checked:

  • system.tables reports engine = Alias and engine_full = Alias('t1'). A target in another database is written Alias(other.t) or Alias('other', 't'); both worked.
  • The target must exist when the alias is created (Table default.nope does not exist), and an alias cannot point at another alias: Alias table cannot refer to another Alias table. (BAD_ARGUMENTS).
  • An alias cannot be EXCHANGEd with its own target: Cannot exchange ... because it will lead to cyclic dependencies.
  • There is no ALTER TABLE a MODIFY ENGINE. ALTER TABLE per_type MODIFY ENGINE Alias(per_type_v1) is a syntax error, whose expected-token list is STATISTICS, COLUMN, ORDER BY, ... SQL SECURITY, DEFINER, REFRESH, COMMENT.

The cutover I thought was missing

My original text listed an atomic repoint as part of the request. The shipped engine has no ALTER ... MODIFY, so repointing looks like drop and create. Done naively, that opens a gap, and the gap is not harmless. With the materialized view pointed at the alias, I dropped the alias and inserted into the source table:

Code: 60. DB::Exception: Target table 'default.per_type' of view 'default.events' doesn't exists. (UNKNOWN_TABLE)

The INSERT into events failed as a whole; events had zero rows afterwards. A gap in the target is an outage of the source table’s ingestion.

But the pieces compose. #88233 (nauu, merged 2025-10-08, also in 25.10) fixed EXCHANGE TABLES for aliases. Before it, an exchange left the alias’s metadata mismatched and failed with FILE_DOESNT_EXIST; the changelog entry says the engine “now stores the target table as database and table names instead of a constant storage id”. With that, you build the next alias under a temporary name and swap it in:

CREATE TABLE per_type_next ENGINE = Alias(per_type_v2);
EXCHANGE TABLES per_type AND per_type_next;

I ran this with the view live. SHOW CREATE TABLE per_type afterwards reads ENGINE = Alias('per_type_v2'), and the next insert into events produced click 1 in per_type_v2, while per_type_v1 kept its earlier click 2, view 1. A second exchange to a per_type_v3 worked the same way. The view was never re-created and no insert failed. This is the atomic cutover, and it came out of two independent PRs rather than the one statement I had sketched. The caveat is that “atomic” here means atomic in the catalogue: I did not race inserts against the exchange, and I would not promise anything about statements already in flight.

An alias is a second door

The same property that makes an alias convenient, that it forwards to something else, makes it an access-control surface. After #87965 the tracker shows a series of fixes that are all the same question asked again:

  • #114511, closed by #114596: an alias leaked its target’s schema and row count to a user with no grant on the target. The changelog entry calls it an RBAC bypass.
  • #116290: aliases ignored row policies defined on their target. An alias now combines the policies of both.
  • Smaller leaks closed later: hasColumnInTable and the loop table function revealed a target’s columns.

The remaining design question is open as #116125: should the alias read as the invoker or as a definer? I wrote that one up in SQL SECURITY for the Alias engine.

Where it stands

The gate came off in 26.5: #103488, “Make the Alias table engine non-experimental”, whose version marker says merged into 26.5.1.833. The setting is now obsolete rather than removed, so profiles that still set it parse. In 26.7 the engine gained parallel-replica reads when the target is a MergeTree-family table (#107830). My local binary is 26.4, so everything executed above ran with the experimental setting on; on 26.5 and later it is not needed.

I count the issue as implemented: persistent table-level aliasing exists, reads and writes go through, and a no-gap repoint is possible. What differs from the request is the spelling, ENGINE = Alias(...) instead of CREATE ALIAS, and the mechanism for repointing, EXCHANGE TABLES over a spare alias instead of ALTER ALIAS.

What it teaches about ClickHouse’s boundaries

ClickHouse tends to answer a new capability with a new engine rather than a new kind of object. Merge, Buffer, Distributed, View and now Alias are all “a table that is really something else”, and they share the DDL, grants and catalogue of ordinary tables. That is why a one-line feature request became a table engine in one reply. It also explains the year of follow-up: each of those engines is a place where the server’s assumptions about “a table has storage of its own” bend, and an alias is the purest case, because it has nothing of its own at all. Backup ordering, name-keyed DDL locks, access checks, row policies and EXCHANGE all had to learn about it.

If you want to use it for versioned tables, plan the cutover with EXCHANGE TABLES and a spare alias from the start, give consumers only the alias name, and test your grants and row policies through the alias rather than assuming they carry over. For the mechanics of materialized-view targets see Materialized views: the basics, and for the atomic-swap family of statements see Asking ClickHouse for REPLACE TABLE.

feature-requestddltable-engines

Discussion

Comments live in GitHub Discussions. Sign in with GitHub to join in.