feat(sqlalchemy): support materialized CTEs (WITH ... AS MATERIALIZED) - #905
Merged
joe-clickhouse merged 5 commits intoAug 5, 2026
Conversation
xoelop
force-pushed
the
xoelop/sqlalchemy-support-materialized-ctes-with-.-as-m
branch
from
July 29, 2026 12:30
fec0283 to
6c65f96
Compare
ClickHouse inlines a CTE by default, so a CTE referenced more than once has
its body executed once per reference. ClickHouse 26.3 added the explicit
`WITH <name> AS MATERIALIZED (...)` syntax, which the dialect had no way to
emit: SQLAlchemy's `Select.cte()` only accepts name, recursive, and nesting,
and the dialect rendered MATERIALIZED only for column definitions.
`cc_sqlalchemy.select(...).cte("name", materialized=True)` now emits the
keyword, and the module-level `cc_sqlalchemy.cte(statement, "name",
materialized=True)` does the same for a statement built with the standard
`sqlalchemy.select`, matching the existing final()/prewhere()/limit_by()
helper shape.
The keyword is attached as a dialect-scoped CTE prefix, which SQLAlchemy
renders between the name and the body, exactly where ClickHouse expects it,
so a statement shared with another backend compiles unchanged there.
The server materializes the CTE only when the `enable_materialized_cte`
setting is also enabled for the query. Verified against 26.4.1: keyword alone
and setting alone both leave the CTE inlined, together it is computed once.
`AS NOT MATERIALIZED` is a syntax error on the server, so it is not exposed.
Closes ClickHouse#900
xoelop
force-pushed
the
xoelop/sqlalchemy-support-materialized-ctes-with-.-as-m
branch
from
July 30, 2026 09:41
6c65f96 to
52d51e0
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
Adds ClickHouse SQLAlchemy dialect support for materialized common table expressions by letting users emit WITH <name> AS MATERIALIZED (...) from both the cc_sqlalchemy.select() path and a new module-level helper for plain sqlalchemy.select() statements. This addresses ClickHouse's default CTE inlining behavior (and the associated performance pitfall when a CTE is referenced multiple times) while keeping the keyword scoped to the ClickHouse dialect.
Changes:
- Added
materialized: bool = Falsesupport toClickHouseSelect.cte(...), implemented via a dialect-scoped CTE prefix so other backends compile unchanged. - Added module-level
cc_sqlalchemy.cte(statement, name, ..., materialized=...)mirroringSelect.cte()for statements built with standard SQLAlchemy constructs (includingValues). - Added unit + integration coverage, type-check smoke updates, docs, and a
CHANGELOG.mdentry (including the requiredenable_materialized_ctesetting and the server's silent no-op behavior when it is off).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| clickhouse_connect/cc_sqlalchemy/sql/init.py | Implements dialect-scoped MATERIALIZED CTE prefixing and exposes materialized= on ClickHouseSelect.cte() plus a module-level cte() helper. |
| clickhouse_connect/cc_sqlalchemy/init.py | Re-exports the new cte helper at the package level. |
| tests/unit_tests/test_sqlalchemy/test_materialized_cte.py | Unit coverage for rendering, scoping to ClickHouse dialect only, cache key separation, and helper behavior (including Values). |
| tests/integration_tests/test_sqlalchemy/test_materialized_cte.py | Integration coverage against a real server, version-gated to 26.3+, including the “silent no-op without setting” case. |
| tests/type_check/sqlalchemy_select_smoke.py | Type-check smoke coverage for both entry points returning sa.CTE. |
| docs/sqlalchemy.mdx | Documents materialized CTE usage, required setting, and dialect scoping behavior. |
| CHANGELOG.md | Records the user-facing SQLAlchemy feature addition and constraints. |
joe-clickhouse
approved these changes
Aug 5, 2026
joe-clickhouse
left a comment
Contributor
There was a problem hiding this comment.
@xoelop thanks for the contribution! Everything pretty much looks good. I applied a few follow-ups:
- I added a client-side check that rejects
recursive=Truewithmaterialized=Truesince the server does not support recursive materialized CTEs. Values.cte()requires SQLAlchemy 2.0.42 or later so I added a checkin the tests.- I added the new rendering tests to SQLAlchemy 1.4 CI.
- I documented the experimental setting, analyzer requirement, recursive restriction, and SQLAlchemy version boundary.
Thanks again!
…with-.-as-m Signed-off-by: Joe Spadola <joe.spadola@clickhouse.com>
…port-materialized-ctes-with-.-as-m
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #900.
ClickHouse inlines a CTE by default, so one referenced more than once has its body executed per reference. 26.3 added
WITH <name> AS MATERIALIZED (...), but the dialect had no way to emit it: SQLAlchemy'sSelect.cte()only takesname,recursive, andnesting.The module-level form takes the statement as its first argument, matching the existing
final()/prewhere()/limit_by()helpers, and accepts anyHasCTEso the dialect'sValuesCTE support keeps working.Implementation
The keyword is attached as a CTE prefix scoped to the
clickhousedbdialect. SQLAlchemy renders CTE prefixes between the name and the body, exactly where ClickHouse expects the keyword, so novisit_cteoverride is needed, and a statement shared with another backend compiles unchanged there.AS NOT MATERIALIZEDis not implemented, the server rejects it as a syntax error.The keyword alone is a silent no-op
The server also needs
enable_materialized_cte, and with it off it ignores the keyword rather than erroring. Checked on 26.4.1: HTTP 200, no exception, nothing insystem.warnings, and the plan simply inlines the body:So forgetting the setting costs performance without failing, which the docs now call out explicitly and an integration test pins. The API deliberately does not set it for you; it goes through the normal
execution_options(settings=...)path.Integration tests are gated on
min_version("26.3").Tests
tests/unit_tests/test_sqlalchemy/test_materialized_cte.py: rendering, default-off, the module-level form on a plainsqlalchemy.selectand on aValuesconstruct,TypeErroron a statement without CTE support, no leakage to other dialects, the issue's join +INpattern, distinct compiled-statement cache keys, andrecursive+materializedtogether.tests/integration_tests/test_sqlalchemy/test_materialized_cte.py: runs the issue's late-materialization pattern against a real server, parametrized materialized/not, plus the silent-no-op case above.tests/type_check/sqlalchemy_select_smoke.pywith both entry points.Docs and
CHANGELOG.mdupdated. Ran locally: unit suite,tests/integration_tests/test_sqlalchemyagainst 26.4.5,ruff,mypy, and the consumer-install checks (both--strictsmoke tests, plus the pyright ratchet, which reports the same count as an untouchedmain).🤖 Generated with Claude Code
https://claude.ai/code/session_01VRFJAmiTn7AVS6jaeovP6u