EVAL: running the SQL your SQL wrote
I asked for an EVAL that executes the statements a query generates. Two years later a deliberately narrower eval table function shipped in 26.7: SELECT only. Why the narrow version came first says a lot about where ClickHouse draws the line on code-as-data.
Source: github.com
Anyone who operates ClickHouse for a while ends up generating SQL with SQL. The system tables describe what is wrong, and a SELECT format(...) turns the description into the statements that fix it. Then comes the awkward step: copy the generated text out and run it somewhere else. In April 2024 I filed #62748, “New command: EVAL”, to close that gap. A narrower relative landed in 26.7, and the way it narrowed is the lesson.
The problem: one logical step across three tools
My example was detached parts that had been ignored and should be deleted. This is the generator, as written in the issue (syntax-checked with clickhouse format; it needs a server to return rows):
SELECT format($$ALTER TABLE {0}.{1} DROP DETACHED PART '{2}' SETTINGS allow_drop_detached=1;$$,
database, `table`, name) AS drop
FROM system.detached_parts
WHERE startsWith(name, 'ignored')
INTO OUTFILE 'drop-ignored-parts.sql' FORMAT TSVRaw
and then, in the shell, clickhouse-client --echo --queries-file drop-ignored-parts.sql, and then the same again on every node. Generate, leave the client, run, repeat: one logical step (“run what this query computed”) spread across a query, a file and a shell.
The same loop runs on 26.4 with clickhouse local and a data path standing in for a server. Three tables, two of which should get a comment:
SELECT format('ALTER TABLE {0}.{1} MODIFY COMMENT \'audited\';', database, name) AS stmt
FROM system.tables WHERE database = 'default' AND name LIKE 'a%' ORDER BY name
FORMAT TSVRaw
ALTER TABLE default.a1 MODIFY COMMENT 'audited';
ALTER TABLE default.a2 MODIFY COMMENT 'audited';
Redirect that into a file, feed the file to a second process with --multiquery, and the effect shows:
a1 audited
a2 audited
b1
That is the whole workflow, and it works. It is just manual, and the manual part is where mistakes happen: the file is stale by the time it runs, or goes to the wrong node, or nobody reads it between steps. I covered the sibling problem, writes that cannot hand anything back, in The write that cannot answer; this one is the opposite direction, results that cannot be executed.
The request
An eval that takes a query, runs it, and executes each resulting string as a statement:
eval(SELECT format($$ALTER TABLE {0}.{1} DROP DETACHED PART '{2}' ...$$, database, `table`, name)
FROM system.detached_parts WHERE startsWith(name, 'ignored'))
I also asked for ON CLUSTER, so a statement generated from node-specific data could run everywhere, and I said I was unsure whether the argument should be a string or a query, as with the view function. The first reply, from andrea-s, was that he had also thought about it many times and that “eval” might be a good name. After that, two years of silence.
The design tension: what is text allowed to become?
A query that executes the output of another query is code generated from data. It is useful, and it is also the shape of every injection problem. Each place that sees a ClickHouse query, the parser, the access check, the query log, the query cache, tools that analyze queries before they run, assumes the statement text is the statement. Once a query can compute a statement, that stops being true.
The maintainers did not reject the idea. They shrank it until the assumption mostly holds again. The route is visible in three places.
The narrow request. On 2026-03-31 alexey-milovidov filed his own issue, #101293, “Table function eval”. It asks for a table function that evaluates a constant expression or a one-cell subquery to a string and runs that string as a subquery: SELECT * FROM eval('SEL' || 'ECT 1'). It states its limits up front: “It deliberately does not evaluate multiple queries”, and, because it “complicates the analysis of queries”, it should sit behind a setting.
The first attempt, then a simplification. A contributor, niyue, sent PR #104396. It was closed on 2026-07-13, the day PR #110132 merged. The merged PR describes itself as “a maximally simplified implementation” of what was requested in review on the first one. Its description lists what it does not touch compared to #104396: ApplyWithSubqueryVisitor, QueryNormalizer, StorageView, StorageDistributed, SelectStreamFactory and the ITableFunction interface. The cost of the wider design was a change in the machinery that rewrites and distributes queries.
The limits in the merged version. From the PR text:
- the argument is evaluated with
evaluateConstantExpression, and the result is parsed as aSELECTwith the union-select parser, so any non-SELECTstatement or output option is a syntax error; - the generated query is wrapped in a
StorageView, the same mechanism asview, and is resolved in its own scope: it cannot see names from the outer query; - the generated query cannot call
evalagain, andevalcannot be an argument of another table function; to run generated SQL on a remote server you wrap it inview:remote('host', view(SELECT * FROM eval(...))); - the old analyzer is not supported;
- it is filed under “Experimental Feature” and disabled by default behind the setting
allow_experimental_eval_table_function.
The PR also lists a known limitation it shares with view: a generated query with an unnamed scalar subquery column such as eval('SELECT (SELECT 42)') fails because the generated column name is not stable between schema inference and execution; aliasing it works.
It was merged on 2026-07-13 and first shipped in 26.7.1.842.
What the maintainer said about my request
When I pinged the issue on 2026-06-10, a reader pointed at a different issue, #88799, “Import/export AST as JSON”. On 2026-07-25 alexey-milovidov replied to mine:
We implemented a table function `eval` which has a more limited scope: it generates only a single SELECT
query, and it is available only in queries accepting table functions. This PR will remain open for
further consideration...
Read the sentence by its parts: only a single SELECT, only in queries accepting table functions, and (his words say “PR”, meaning my request) it “will remain open for further consideration”. It is an honest reply to my request: a smaller thing shipped, the larger one is neither promised nor refused.
#88799 is the other end of the same design space. It proposes a JSON form of the AST with parseQueryToJSON and formatQueryFromJSON functions, so that tools can manipulate queries as data rather than as strings. Its body states the obstacle in one line: “we can’t guarantee compatibility of the AST”. It is closed, and the AST JSON serialization PR (#100412) merged on 2026-07-30. I have not looked at what it exposes in SQL. It does fit a pattern: text-in, text-out (eval) and structured (AST) are the two ways to make queries programmable, and the project is working on both from the safe end.
Why a table function, not a statement
A table function is a good place to start for reasons that only show up once you try to build the alternative. It lives inside a SELECT, which is already planned, analyzed, access-checked and logged as one statement. Its result is a table with a schema, so it composes: joins, filters, INSERT ... SELECT. A statement-level EVAL has to answer questions that a table function never meets. What is the result of running 400 ALTERs: a table of statuses, an error, nothing? What happens when statement 200 fails? What does ON CLUSTER mean when the statement list is itself computed per node? Does the user who may run eval need every privilege that any generated statement needs, and is that checked before or after generation?
None of those is unanswerable, but each one is a design, and SELECT-only avoids all of them: there are no side effects to order, to roll back, or to authorize separately.
Workarounds, ranked by how close they get
On 26.4 eval does not exist:
SELECT * FROM eval('SELECT 1 AS x');
Code: 46. DB::Exception: Unknown table function eval. (UNKNOWN_FUNCTION)
Nor does the sugar form eval(SELECT ...) parse; clickhouse format on 26.4 rejects SELECT * FROM eval(SELECT 'SELECT 1') with a syntax error, while the string form eval('SEL' || 'ECT 1') and the remote('host', view(SELECT * FROM eval('SELECT 1 AS x'))) wrapper parse fine and fail only at execution on a build without the function. Until you are on 26.7 or later, the options in order of preference:
-
Avoid generating SQL when a function does the job. For “the same query over a family of tables”,
mergeis a real table function. On the three-table example:SELECT _table, count() FROM merge('default', '^a') GROUP BY _table ORDER BY _table;a1 2 a2 1 -
Query parameters for the identifier you would otherwise concatenate.
SELECT count() FROM {t:Identifier}with--param_t=a2returns1on 26.4. It keeps the statement text constant, which is the propertyevalgives up, and is safe against injection by construction. -
For per-node fan-out, a cluster table function such as
clusterAllReplicas('my_cluster', system.detached_parts)reads the system table on every replica in one query (syntax-checked here; it needs a cluster). It does not execute what you generate, but it lets you see what to generate for every node at once. -
Generate, review, run. The loop above. For dropping detached parts specifically see the drop detached parts snippet. The review step is a feature when the statements are destructive. For running the result unattended, see running SQL scripts in batch.
What it teaches about ClickHouse’s boundaries
ClickHouse treats query text as a static, analyzable artifact, and the speed of its planner and the safety of its access control depend on that. The first generator that ships is therefore the one that cannot do harm, a SELECT that produces a SELECT, behind an experimental setting. DDL generated from data is a harder question than it looks from the operator’s seat, and I expect it to arrive, if at all, through an explicit and separately authorized form, not an eval that just runs strings.
I am marking this partially implemented, as it is: the SELECT-only sibling shipped in 26.7, the administrative EVAL with ON CLUSTER did not. For why a request like this is worth filing even when the answer is narrower than the ask, see feature requests worth making and table functions as a first-class abstraction.
Discussion
Comments live in GitHub Discussions. Sign in with GitHub to join in.