<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Data Engineering Guide</title>
    <description>The latest articles on DEV Community by Data Engineering Guide (dataengineering).</description>
    <link>https://dev.to/dataengineering</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14171%2F4acdea2c-8004-4e2f-8ace-ec9c6e0e2732.png</url>
      <title>DEV Community: Data Engineering Guide</title>
      <link>https://dev.to/dataengineering</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dataengineering"/>
    <language>en</language>
    <item>
      <title>When to denormalize, when to join: A ClickHouse guide (2026)</title>
      <dc:creator>Manveer Chawla</dc:creator>
      <pubDate>Mon, 29 Jun 2026 06:17:28 +0000</pubDate>
      <link>https://dev.to/dataengineering/clickhouse-denormalization-join-guide-2026-59lg</link>
      <guid>https://dev.to/dataengineering/clickhouse-denormalization-join-guide-2026-59lg</guid>
      <description>&lt;p&gt;Denormalization has been the standard approach to analytical data modeling for good reason. Moving joins, lookups, and business rules out of query time and into ingestion gives you the fastest possible reads for a known access pattern. For most of the past decade, it was often the practical default for latency-sensitive analytics. Earlier columnar engines and distributed query processors could execute joins, but many workloads paid for them through higher latency, higher compute cost, spill-to-disk, or distributed coordination overhead.&lt;/p&gt;

&lt;p&gt;That constraint has loosened. Modern columnar databases with advanced join algorithms have reduced the cost of runtime joins enough that normalization is now a genuinely viable option for many analytical workloads. Denormalization still delivers faster reads, but normalization can bring operational benefits: simpler pipelines, flexible schemas, and cleaner governance. Engineers can now make the decision based on their actual workload characteristics, rather than being forced into one approach by engine limitations.&lt;/p&gt;

&lt;p&gt;This guide is a decision framework for making that choice in ClickHouse. It starts with why denormalization became the default, explains what has changed in join performance, then compares the tradeoffs on both sides so you can decide where to denormalize, where to join, and where to use ClickHouse primitives that bridge the gap.&lt;/p&gt;

&lt;p&gt;For a broader evaluation framework covering latency, concurrency, ingest throughput, SQL flexibility, and cost across real-time OLAP options, see our &lt;a href="https://clickhouse.com/resources/engineering/how-to-choose-a-database-for-real-time-analytics-in-2026" rel="noopener noreferrer"&gt;guide to choosing a database for real-time analytics in 2026&lt;/a&gt;. For a deeper comparison of how ClickHouse executes star schema joins against Druid, Pinot, and cloud DWHs, see our &lt;a href="https://clickhouse.com/resources/engineering/real-time-analytics-star-schema-joins" rel="noopener noreferrer"&gt;star schema and fast joins guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Denormalization and normalization are both valid modeling strategies. The right choice depends on your workload.
&lt;/li&gt;
&lt;li&gt;Denormalization's tradeoffs are primarily &lt;strong&gt;operational&lt;/strong&gt;: pipeline complexity, write-path overhead, data freshness lag, backfill burden, and semantic drift.
&lt;/li&gt;
&lt;li&gt;Modern real-time OLAP engines (ClickHouse most prominently) have made &lt;strong&gt;normalized joins performant enough&lt;/strong&gt; for many analytical workloads, using parallel/grace hash joins, merge joins, join reordering, runtime bloom filters, and dictionary-based direct joins.
&lt;/li&gt;
&lt;li&gt;Denormalization still wins on &lt;strong&gt;raw read performance&lt;/strong&gt; for a known access pattern. Scanning one pre-joined table with efficient filters is almost always faster than scanning multiple tables and joining at runtime.
&lt;/li&gt;
&lt;li&gt;The tradeoff: denormalization optimizes &lt;strong&gt;read cost&lt;/strong&gt; at the expense of &lt;strong&gt;write-path complexity, schema flexibility, and governance&lt;/strong&gt;. Normalization preserves those operational qualities but adds &lt;strong&gt;join overhead at query time&lt;/strong&gt;, including higher per-query CPU and memory use, especially under concurrency.
&lt;/li&gt;
&lt;li&gt;Use the decision framework below to evaluate which approach fits each part of your workload.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why denormalization became the default, and what changed in join performance
&lt;/h2&gt;

&lt;p&gt;Data engineering practice has long followed a strict split: normalize for transactional writes, denormalize for analytical reads. Engineers adopted denormalization because it made analytical read latency more predictable, especially when joins required large distributed shuffles, disk spill, or careful query tuning.&lt;/p&gt;

&lt;p&gt;The constraints were real and came from multiple directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory limitations.&lt;/strong&gt; Early columnar engines executed hash joins purely in memory. When the right-hand side of a join exceeded available RAM, the options were bad: out-of-memory errors, or spilling to disk with severe performance penalties that made queries unpredictably slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distributed coordination overhead.&lt;/strong&gt; The MPP and MapReduce architectures that dominated the 2010s could work around memory limits by going wide, distributing join work across many nodes. But this introduced network shuffles, coordination overhead, and multi-step job execution that made joins slow and expensive. Today, many traditional cloud data warehouses still follow that design and will complete a massive join, but they may spend significant time and credits doing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Primitive optimizers.&lt;/strong&gt; Legacy query planners couldn't dynamically reorder join graphs based on cardinality estimates, and they couldn't push predicates down efficiently. Engineers couldn't trust the optimizer to find a good plan, so they did the optimization themselves at ingestion time.&lt;/p&gt;

&lt;p&gt;Given these constraints, denormalization was the rational engineering choice: pay the compute cost once at ingestion to guarantee predictable read performance. That calculus made sense, and for many workloads it still does.&lt;/p&gt;

&lt;p&gt;What's changed is the engine side. Modern real-time OLAP engines have substantially closed the join performance gap, with ClickHouse investing heavily in join execution. Standard hash joins remain the default for fast, memory-resident operations. When intermediate state exceeds memory, &lt;a href="https://clickhouse.com/blog/clickhouse-fully-supports-joins-hash-joins-part2" rel="noopener noreferrer"&gt;grace hash joins spill intermediate state to disk&lt;/a&gt; without requiring pre-sorted data, allowing the query to continue instead of failing purely because the hash table no longer fits in RAM. Parallel hash joins use multiple CPU cores to accelerate execution. If tables are already sorted, full and partial merge joins can reduce or avoid the hashing phase, requiring less memory. For ultra-low-latency dimension lookups, ClickHouse's &lt;a href="https://clickhouse.com/blog/clickhouse-fully-supports-joins-direct-join-part4" rel="noopener noreferrer"&gt;direct dictionary joins&lt;/a&gt; function as key-value lookups, delivering up to 25x speedup over hash joins in published benchmarks. All standard SQL join types are supported (INNER, LEFT, RIGHT, FULL, CROSS), plus SEMI, ANTI, and ASOF joins for analytical patterns spanning time windows or selectivity-driven filtering.&lt;/p&gt;

&lt;p&gt;Enhanced &lt;a href="https://clickhouse.com/blog/clickhouse-release-25-09" rel="noopener noreferrer"&gt;global join reordering&lt;/a&gt; allows cost-based optimizers to restructure complex join graphs using cardinality estimates. On a six-table TPC-H query (scale factor 100), naive ordering without statistics took 3,903 seconds and ~100 GiB of peak memory. Enabling global join reordering with column statistics brought the same query to 2.7 seconds with under 4 GiB of memory: a 1,450x speedup and 25x memory reduction on the same hardware, data, and SQL. &lt;a href="https://clickhouse.com/blog/clickhouse-release-25-10" rel="noopener noreferrer"&gt;Runtime bloom filters&lt;/a&gt;, where the build side of a join passes filter conditions to the probe side before the join executes, delivered an additional 2.1x speedup and 7x memory reduction in ClickHouse's published TPC-H example.&lt;/p&gt;

&lt;p&gt;Append-only event stores like Druid and Pinot often favor wide event tables because their architectures are optimized around immutable segments, ingestion-time indexing, and lookup or broadcast-style joins. Cloud data warehouses like Snowflake and BigQuery can execute complex joins, but the latency and cost profile is different from a purpose-built real-time OLAP engine, especially for high-concurrency serving workloads.&lt;/p&gt;

&lt;p&gt;The bottom line: joins are no longer a constraint that automatically forces your modeling decisions. They are a cost you can now evaluate against the tradeoffs of denormalization for your specific workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why denormalization is still the right choice for many workloads
&lt;/h2&gt;

&lt;p&gt;Before talking about costs, it's worth stating the positive case clearly: denormalization works. If a workload has a dominant query path, a stable schema, and tight latency requirements, flattening the data is often the most direct way to make reads fast and predictable.&lt;/p&gt;

&lt;p&gt;A denormalized table eliminates join overhead from the serving path. The engine can filter, aggregate, and return results from one physical table without building hash tables, probing dictionaries, or managing intermediate join state. Under high concurrency, that simplicity matters. Hundreds or thousands of simultaneous queries against a well-designed wide table are easier to reason about than the same traffic pattern repeatedly executing joins.&lt;/p&gt;

&lt;p&gt;Denormalization also improves ergonomics for consumers. BI tools, embedded analytics, and application queries often work better against a table where the relevant attributes are already present. Fewer joins means fewer opportunities for analysts to pick the wrong key, apply the wrong join type, or accidentally change metric semantics.&lt;/p&gt;

&lt;p&gt;This is why the right framing is not "normalize instead of denormalize." It is: denormalize when the read path is stable, latency-sensitive, and valuable enough to justify the extra work on the write path. Use joins when flexibility, freshness, and semantic clarity matter more than shaving every millisecond from a known query pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs of denormalization
&lt;/h2&gt;

&lt;p&gt;Denormalization optimizes read performance for known access patterns. That optimization has real tradeoffs on the write side and operational side. These tradeoffs don't make denormalization wrong, but they should be weighed explicitly against the read-time benefits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pipeline complexity and write-path overhead
&lt;/h3&gt;

&lt;p&gt;Denormalization pushes join logic into the ingestion path. That extra work can live outside the database or inside it. Outside the database, joining streams before ingestion means managing stateful stream processors like &lt;a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/checkpoints/" rel="noopener noreferrer"&gt;Flink&lt;/a&gt;, with their checkpoint state management, recovery delays, and late-arriving data handling. This operational surface area grows with the complexity of your denormalization logic.&lt;/p&gt;

&lt;p&gt;Inside the database, materialized views that maintain precomputed results, including rollups or denormalized target tables, create write amplification. An incremental materialized view acts like an insert trigger on the source table. Each insert generates additional work for the target view, and high-frequency inserts can outpace the engine's background merge capacity, leading to throttled writes once partitions hit &lt;a href="https://clickhouse.com/docs/operations/settings/merge-tree-settings" rel="noopener noreferrer"&gt;active-part thresholds&lt;/a&gt;. For denormalized joins, incremental materialized views only react to inserts on the source table and need additional handling when joined dimension tables change. ClickHouse Cloud can mitigate this with compute-compute separation: read-write services handle inserts and background merges while read-only services run user-facing queries against the same underlying data. &lt;/p&gt;

&lt;p&gt;Dimension updates surface the tradeoff clearly. Updating a customer's country in a normalized model touches one row in the customer table. In ClickHouse, &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-3-benchmarks" rel="noopener noreferrer"&gt;lightweight updates (Patch Parts)&lt;/a&gt;, when appropriate for the update size and table design, write a compact patch containing only the changed columns and rows, with roughly 40 bytes of uncompressed overhead per updated row. The patch part is created immediately when the UPDATE returns; the physical merge into the underlying data happens asynchronously in background merges. Benchmarks show this running up to 1,000x faster than classic ClickHouse mutations and up to &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-3-benchmarks" rel="noopener noreferrer"&gt;4,000x faster than PostgreSQL on bulk cold updates&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The same update against a denormalized flat table involves more work. If the predicate column isn't part of the table's ordering key, the engine must scan parts to identify where affected rows are located, then write potentially many sparse patch parts, followed by additional merge work to consolidate them. This is manageable for infrequent updates, but becomes a consideration when dimension updates are frequent or contend with the same compute serving user-facing queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data freshness lag
&lt;/h3&gt;

&lt;p&gt;Pre-joining bounds your analytical freshness to your slowest updating dimension. If a transaction stream arrives in real-time but the customer enrichment batch job runs hourly, your flattened table is artificially delayed. Late-arriving events can land, but derived wide-table columns remain stale until the pipeline resolves the discrepancy and rewrites the affected records.&lt;/p&gt;

&lt;p&gt;In a normalized model, the dimension table updates independently, and queries against the current state reflect the latest values at join time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage and scan considerations
&lt;/h3&gt;

&lt;p&gt;Columnar storage achieves strong compression by grouping values of the same type together, letting codecs like LZ4 and ZSTD exploit patterns in the data. On typical fact tables, ClickHouse delivers &lt;a href="https://clickhouse.com/resources/engineering/database-compression" rel="noopener noreferrer"&gt;10x to 20x compression&lt;/a&gt; using dictionary encoding, run-length encoding, and general-purpose codecs.&lt;/p&gt;

&lt;p&gt;Denormalization's impact on storage depends on the cardinality of the dimensions being flattened. Dimensions are typically low-cardinality: a country column might have 200 distinct values, a subscription tier might have 5. Flattening these into a billion-row fact table duplicates those values, but ClickHouse's &lt;a href="https://clickhouse.com/docs/sql-reference/data-types/lowcardinality" rel="noopener noreferrer"&gt;LowCardinality&lt;/a&gt; column type mitigates this by storing the unique values once in a dictionary and using small integer pointers for each row. The pointers still take space, and you need to remember to declare the type, but the storage overhead is manageable for genuinely low-cardinality dimensions.&lt;/p&gt;

&lt;p&gt;Where storage can suffer is when dimension columns aren't part of the table's sort order. Columnar compression works best when adjacent values are similar. Dimension values that are randomly distributed relative to the sort key compress less effectively regardless of their cardinality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema rigidity and backfill burden
&lt;/h3&gt;

&lt;p&gt;Schema changes cascade differently in normalized and denormalized models.&lt;/p&gt;

&lt;p&gt;A concrete case: security asks to hash or redact customer names under a new privacy policy. In a normalized model, that's one column transformation on a 100k-row customer table. Future writes only need to hash when new customer rows are created.&lt;/p&gt;

&lt;p&gt;In a denormalized model, the same request requires backfilling the hash across billions of historical fact rows, and reconfiguring the denormalization pipeline to apply the hash on every future fact row (whether it's a new customer or not). In any schema, downstream consumers (dashboards, alerts, reverse-ETL jobs) need verification that the change didn't break filters or joins. But the backfill scope is larger in the denormalized case, which translates to more compute, longer execution windows, and more risk to ongoing ingestion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consistency and semantic drift
&lt;/h3&gt;

&lt;p&gt;Duplicating data duplicates business meaning. Flattened tables force implicit decisions about slowly changing dimensions.&lt;/p&gt;

&lt;p&gt;SCD Type 1 attributes (overwrite the current value) and Type 2 (preserve versioned history) need different handling. Denormalizing them forces a decision about whether historical fact rows reflect the "as-was" state (what was true when the event happened) or the "as-is" state (what is currently true).&lt;/p&gt;

&lt;p&gt;If a user upgrades their subscription tier, separate the two reporting questions explicitly. For "as-is" reporting, keep the current tier in a dimension table and join to it at query time. For "as-was" reporting, either model the dimension as SCD Type 2 and join by the event timestamp and effective date range, or intentionally record the tier at the point of the transaction in the fact table. The important part is deciding which meaning each column represents before downstream teams build metrics on top of it.&lt;/p&gt;

&lt;p&gt;In a denormalized model, maintaining both views requires either rewriting historical rows when the dimension changes or accepting that the flat table reflects only one perspective. Teams that skip the rewrite can end up with divergence between the flat table and the dimension table, where each reports different values for the same logical attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs of normalization
&lt;/h2&gt;

&lt;p&gt;Normalization has its own tradeoffs. These are often underweighted in discussions that focus on denormalization's downsides, so they're worth stating explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query-time overhead and concurrency cost
&lt;/h3&gt;

&lt;p&gt;Every query that joins tables at runtime does more work than scanning a single pre-joined table. Depending on the join algorithm, the engine may build hash tables, probe lookup structures, spill intermediate state, or merge sorted streams. Under high concurrency, this overhead compounds: each concurrent query executing joins consumes more CPU and memory than the equivalent scan against a wide table. For latency-critical serving workloads with hundreds or thousands of concurrent queries, this overhead can be the deciding factor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query complexity for consumers
&lt;/h3&gt;

&lt;p&gt;Normalized models push join logic to query time, which means analysts and application developers need to understand the schema relationships and write (or generate) correct joins. A denormalized table with clear column names is easier to query correctly, especially for less technical consumers or BI tools that generate SQL automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optimizer dependency
&lt;/h3&gt;

&lt;p&gt;Normalized models rely on the query optimizer to find efficient join plans. A bad plan, whether from stale statistics, a complex join graph, or an optimizer limitation, can cause large performance regressions. Denormalized models sidestep this risk for the access patterns they serve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Aggregate query performance
&lt;/h3&gt;

&lt;p&gt;For aggregation-heavy workloads, denormalized tables let the engine apply filters and group-bys in a single pass without join overhead. Normalized models may require joining before aggregating, which increases intermediate data volumes and processing time.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to join vs. denormalize in an analytical database
&lt;/h2&gt;

&lt;p&gt;The choice isn't binary, and it shouldn't be made as a blanket architectural decision. Different parts of your workload may warrant different approaches. A common layered pattern keeps raw events in a bronze tier, cleaned and conformed data in a silver tier, dimensional and semantic models for reusable definitions, and denormalized serving tables for specific hot dashboards. In that setup, denormalized tables serve known access patterns while dimensional and semantic models remain available for workloads that need flexibility.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://clickhouse.com/docs/integrations/dbt" rel="noopener noreferrer"&gt;dbt&lt;/a&gt; is a common orchestration tool for this layered model. The ClickHouse dbt adapter supports incremental materializations for append-only facts and full-refresh for dimensions, with all models version-controlled in git.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evaluate the tradeoff for your workload
&lt;/h3&gt;

&lt;p&gt;Before flattening a schema, run your workload through these questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the path strictly latency-critical?&lt;/strong&gt; Sub-second SLA requirements, like ad-tech routing or fraud detection, favor flattening because eliminating join overhead provides the most predictable latency.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How volatile are the dimensions?&lt;/strong&gt; Frequently updated dimensions increase the write-path cost of keeping a denormalized table current. Stable, append-only dimensions are cheap to flatten.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How many access patterns does the data serve?&lt;/strong&gt; A single dominant query pattern is the sweet spot for denormalization. Multiple diverse patterns mean the flat table is optimized for one path and suboptimal for the rest, while a normalized model can support more patterns without duplicating the same attributes into multiple serving tables.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the table well-filtered by partition and ordering keys?&lt;/strong&gt; Strong pruning makes runtime joins efficient by reducing the data volumes involved.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can schema changes be backfilled safely?&lt;/strong&gt; If backfills are slow enough to interfere with ingestion, require careful operational windows, or risk consistency issues, the schema rigidity cost of denormalization is high.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it a hierarchical relationship?&lt;/strong&gt; Deeply nested JSON often warrants selective extraction or, in ClickHouse, using the &lt;a href="https://clickhouse.com/docs/sql-reference/data-types/newjson" rel="noopener noreferrer"&gt;native JSON type&lt;/a&gt;, which shreds JSON into dynamic sub-columns with column-level compression and no upfront schema.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Quick reference: when each approach fits
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Denormalization fits when...&lt;/th&gt;
&lt;th&gt;Normalization fits when...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Query pattern&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single dominant access pattern with tight latency SLA&lt;/td&gt;
&lt;td&gt;Multiple diverse query patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dimension volatility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dimensions are stable, rarely updated&lt;/td&gt;
&lt;td&gt;Dimensions change frequently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Read performance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lowest possible latency is non-negotiable&lt;/td&gt;
&lt;td&gt;Interactive latency is acceptable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Write-path complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ingestion pipeline complexity is manageable&lt;/td&gt;
&lt;td&gt;Simpler ingestion pipelines are a priority&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schema evolution&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Schema is stable, changes are rare&lt;/td&gt;
&lt;td&gt;Schema evolves frequently, backfills must be cheap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Governance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single team owns the data, meaning is unambiguous&lt;/td&gt;
&lt;td&gt;Multiple teams consume the data, semantic consistency matters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  ClickHouse primitives that bridge the gap
&lt;/h3&gt;

&lt;p&gt;ClickHouse provides several primitives that let you get closer to denormalized read performance while maintaining normalized source data. These aren't all forms of denormalization themselves; they're different mechanisms that reduce the need to choose.&lt;/p&gt;

&lt;h4&gt;
  
  
  Dictionary-based lookups (direct joins) for fast dimension enrichment
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://clickhouse.com/docs/dictionary" rel="noopener noreferrer"&gt;Dictionaries&lt;/a&gt; load dimensional data into an optimized key-value structure. The flat layout provides array-offset lookups, delivering &lt;a href="https://clickhouse.com/blog/clickhouse-fully-supports-joins-direct-join-part4" rel="noopener noreferrer"&gt;access speeds up to 25x faster than hash joins and 15x faster than parallel hash joins&lt;/a&gt; in published benchmarks. You keep your dimensions in a separate table and get near-denormalized lookup speed at query time without physically duplicating dimension columns in your fact table. Dictionaries work best for one-to-one or many-to-one lookups where a key maps to a single authoritative value; they are not appropriate for one-to-many or many-to-many relationships that require preserving multiple matches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DICTIONARY&lt;/span&gt; &lt;span class="n"&gt;customer_tiers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="n"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;
&lt;span class="k"&gt;SOURCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClickHouse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="s1"&gt;'customers'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;LAYOUT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FLAT&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;LIFETIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MIN&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Materialized views for pre-aggregation
&lt;/h4&gt;

&lt;p&gt;Materialized views let the database maintain pre-computed aggregations as data arrives, without requiring external pipeline infrastructure. They process incoming data blocks automatically and store the results in a target table. This is aggregation, not denormalization: you're pre-computing rollups, not flattening relationships.&lt;/p&gt;

&lt;p&gt;Materialized views aren't free. They create write amplification (each insert generates parts for both the source and target tables). But that cost is usually smaller than running a parallel Flink or Kafka Streams pipeline externally, both in compute and in operational surface area.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;hourly_sales_mv&lt;/span&gt;
  &lt;span class="n"&gt;ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SummingMergeTree&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shop_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;shop_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;toStartOfHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_revenue&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_sales&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;shop_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Projections for alternate access patterns
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://clickhouse.com/docs/data-modeling/projections" rel="noopener noreferrer"&gt;Projections&lt;/a&gt; maintain alternate physical sort orders of your base table's data. They're not a form of denormalization; they're a way to optimize multiple query patterns against the same underlying data. The optimizer automatically routes queries to a more efficient projection.&lt;/p&gt;

&lt;p&gt;Since ClickHouse 25.6, &lt;a href="https://clickhouse.com/blog/projections-secondary-indices" rel="noopener noreferrer"&gt;lightweight projections&lt;/a&gt; can store only their sorting key plus a &lt;code&gt;_part_offset&lt;/code&gt; pointer back into the base table, rather than duplicating full rows. In the benchmark discussed in ClickHouse's projection post, this used roughly half the storage of traditional projections and reduced query time by 90%. That makes lightweight projections a practical middle ground when you need better query performance on non-primary access patterns without duplicating every projected column.&lt;/p&gt;

&lt;h3&gt;
  
  
  When you do denormalize: guardrails
&lt;/h3&gt;

&lt;p&gt;For workloads where explicit denormalization is the right choice, apply these guardrails to keep costs contained.&lt;/p&gt;

&lt;h4&gt;
  
  
  Separate point-in-time facts from current-state dimensions
&lt;/h4&gt;

&lt;p&gt;When flattening data, capture the dimension value at transaction time in the fact table for "as-was" reporting. For "as-is" reporting, keep the current state in a dimension table and join at query time. In ClickHouse, dictionaries can make this lookup fast when the current-state mapping is one-to-one or many-to-one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;historical_tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;dictGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer_tiers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tier'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;current_tier&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;historical_tier&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;dictGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'customer_tiers'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tier'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Backfill incrementally
&lt;/h4&gt;

&lt;p&gt;Avoid one-shot population-style backfills when creating a materialized view on a live production table with active writes. Backfill by partition or time range to bound memory and merge pressure. This reduces contention with incoming real-time streams and helps the database engine manage part merges without throttling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Denormalization and normalization are both valid engineering choices. Neither option  is &lt;em&gt;universally&lt;/em&gt; better. The choice must fit the specific requirements of each part of your workload.&lt;/p&gt;

&lt;p&gt;Denormalization gives you the fastest possible reads for a known access pattern. Normalization preserves schema flexibility, simplifies writes, and keeps business meaning in one place. &lt;/p&gt;

&lt;p&gt;The best analytical systems let you make the choice per workload. Use normalized or partially normalized models where operational flexibility and governance matter. Denormalize the specific serving paths where read latency is the binding constraint. Review the &lt;a href="https://clickhouse.com/docs/guides/joining-tables" rel="noopener noreferrer"&gt;ClickHouse join documentation&lt;/a&gt; to see how the optimizer selects between algorithms in production.&lt;/p&gt;

&lt;p&gt;The fastest test uses your own data and your own access patterns. &lt;a href="https://clickhouse.com/cloud" rel="noopener noreferrer"&gt;Spin up a free ClickHouse Cloud trial&lt;/a&gt;, load a representative slice of your fact and dimension tables, and run the joins that matter to you. For a reproducible join benchmark you can run yourself, explore the &lt;a href="https://github.com/ClickHouse/coffeeshop-benchmark" rel="noopener noreferrer"&gt;coffeeshop benchmark&lt;/a&gt;. The only latency number that matters for your build-or-flatten decision is the one your queries produce on your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions about denormalization in analytical databases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is denormalization a bad practice in modern analytical databases?
&lt;/h3&gt;

&lt;p&gt;No. Denormalization is a specialized optimization that excels for latency-critical, read-heavy serving layers with known access patterns. It's a valid choice when the read-time benefits outweigh the pipeline complexity, schema rigidity, and governance overhead it introduces.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does columnar storage eliminate the need for denormalization?
&lt;/h3&gt;

&lt;p&gt;Not entirely. Columnar compression, block pruning, and vectorized execution make normalized star schemas much faster than legacy row-stores, which raises the bar for when denormalization is actually required. But scanning a single pre-filtered wide table is still generally faster than joining multiple tables at runtime. Columnar storage shifts the breakeven point; it doesn't eliminate the tradeoff.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are joins slow in modern columnar databases?
&lt;/h3&gt;

&lt;p&gt;Not necessarily. Modern engines, such as ClickHouse, use join reordering, parallel/grace hash joins, merge joins, and runtime bloom filters to make normalized star-schema joins fast and predictable at scale. Joins still have overhead compared to scanning a single table, but that overhead has decreased enough to be acceptable for many analytical workloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should I denormalize in an analytical database?
&lt;/h3&gt;

&lt;p&gt;Denormalize when you have a single dominant query pattern with tight latency SLAs (ad-tech bidding, real-time personalization, fraud detection), the dimensions are stable, and the schema is unlikely to change frequently. The operational tradeoffs of denormalization are lowest in that scenario.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the biggest operational tradeoffs of denormalization?
&lt;/h3&gt;

&lt;p&gt;Pipeline complexity (stateful stream processors, materialized view write or refresh overhead), data freshness lag (bounded by your slowest dimension update), backfill burden when schemas change, and semantic drift when duplicated business logic diverges from the dimension tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the best alternative to denormalizing for fast dimension lookups?
&lt;/h3&gt;

&lt;p&gt;Dictionary-based lookups (direct joins) in ClickHouse. They load dimension data into an optimized key-value structure, delivering up to 25x the speed of hash joins in published benchmarks. You keep your dimensions normalized and get near-denormalized lookup performance at query time for one-to-one or many-to-one relationships.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use materialized views instead of denormalizing upstream in ETL?
&lt;/h3&gt;

&lt;p&gt;Materialized views can replace external pipeline work for pre-aggregation use cases, and refreshable materialized views can support some denormalized serving-table patterns. They reduce operational surface area by keeping transformation logic inside the database. They add write or refresh overhead, but that may still be simpler than running a separate streaming pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I handle slowly changing dimensions (SCD) if I denormalize?
&lt;/h3&gt;

&lt;p&gt;Store point-in-time attribute values in the fact table only when you intentionally want that denormalized "as-was" view. Another valid option is an SCD Type 2 dimension joined by event time and effective range. For "as-is" values, keep the current state in a dimension table and join at query time. In ClickHouse, dictionaries can make this fast for one-to-one or many-to-one lookups.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I backfill safely after adding a new column to a wide table?
&lt;/h3&gt;

&lt;p&gt;Backfill incrementally by partition or time range to bound memory and merge pressure. Avoid one-shot population-style backfills on live write-heavy tables to reduce consistency and throttling risks.  &lt;/p&gt;

</description>
      <category>clickhouse</category>
      <category>database</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <item>
      <title>Build vs Buy a Managed Streaming Platform for Real-Time RAG in 2026</title>
      <dc:creator>Manveer Chawla</dc:creator>
      <pubDate>Mon, 15 Jun 2026 22:31:39 +0000</pubDate>
      <link>https://dev.to/dataengineering/build-vs-buy-a-managed-streaming-platform-for-real-time-rag-in-2026-2im</link>
      <guid>https://dev.to/dataengineering/build-vs-buy-a-managed-streaming-platform-for-real-time-rag-in-2026-2im</guid>
      <description>&lt;p&gt;Moving a retrieval-augmented generation (RAG) prototype from a Python notebook into production isn't an API orchestration challenge. It's a distributed systems problem. For engineering managers and data platform leads, the build-versus-buy decision on streaming infrastructure will dictate your artificial intelligence (AI) feature velocity for the next three to five years.&lt;/p&gt;

&lt;p&gt;This guide assumes you've already prototyped a RAG pipeline. The question we tackle here is what changes when you put it in front of customers, where the real cost lives, and how to choose a streaming foundation that won't trap your team in maintenance work for the next decade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Executive Summary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; Production real-time RAG is a streaming-systems problem, not an API-orchestration problem. DIY pipelines accumulate an integration tax that compounds over time, slowing AI feature velocity to a crawl.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The recommendation.&lt;/strong&gt; For most enterprises, buying an unified managed streaming platform that delivers stream, connect, process, and govern under a single service-level agreement (SLA) is the correct choice. It should ship with AI-native primitives built in: in-flight embedding generation, Streaming Agents, and context served via the Model Context Protocol (MCP).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The evidence.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single production change data capture (CDC) connector typically takes three to six engineering months to build and stabilize
&lt;/li&gt;
&lt;li&gt;DIY paths break against the serverless ceiling (e.g., AWS Lambda's 15-minute execution limit) and bleed cross-availability zone (AZ) egress at $0.01 per GB
&lt;/li&gt;
&lt;li&gt;Confluent customers like Henry Schein One, Notion, and Palmerston North City Council credit the platform for moving high-quality data fast enough to power production AI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The build.&lt;/strong&gt; A production-grade platform powered by the Kora engine (GBps+ throughput, 99.99% SLA, fully compatible with Apache Kafka® APIs), more than 120 connectors with more than 80 fully managed (PostgreSQL Debezium, Oracle CDC and XStream, Snowflake, S3), Confluent Cloud for Apache Flink® with &lt;code&gt;ML_PREDICT&lt;/code&gt; and &lt;code&gt;AI_COMPLETE&lt;/code&gt; for in-flight embeddings, Stream Governance (Schema Registry, Data Contracts, Stream Catalog, Stream Lineage), and Confluent Intelligence (Streaming Agents, Real-Time Context Engine, and built-in ML functions) for agentic AI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope.&lt;/strong&gt; This guide is for engineering managers and data platform leads weighing build versus buy for a real-time RAG initiative. Build is still the right answer if you're air-gapped, have extreme customization needs, or have a large platform team to staff ongoing operations.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Real-Time RAG Looks Like in Production
&lt;/h2&gt;

&lt;p&gt;Production RAG is never just a stateless app calling a vector database. When you shift from static file uploads to enterprise real-time context, the architecture becomes a persistent, stateful streaming data problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time RAG data flow architecture:&lt;/strong&gt;  &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3peb1pwcff1lrmsbkkq5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3peb1pwcff1lrmsbkkq5.png" alt="Architecture diagram showing change data capture from source databases through CDC connectors, stream processing, embedding generation, and idempotent upserts into a vector database." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The invisible components in this diagram demand continuous synchronization. CDC ingestion from operational databases translates complex, high-throughput row-level updates into event streams. Those change events need to be normalized, chunked, and routed to embedding APIs (OpenAI, Cohere, Amazon Bedrock, Voyage AI, or self-hosted models). The generated vectors must then be securely upserted into your vector database (Pinecone, Weaviate, Milvus, or PostgreSQL using pgvector) while you continuously monitor end-to-end freshness.&lt;/p&gt;

&lt;p&gt;Operating this pipeline exposes teams to demanding day two distributed system operations. You need to handle late-arriving data via precise stream watermarking without corrupting the vector index. You need to gracefully process upstream schema changes, like a suddenly dropped column, without breaking downstream &lt;a href="https://thestackreview.com/practical-guide-to-data-chunking-rag-applications" rel="noopener noreferrer"&gt;chunking logic&lt;/a&gt;. And when your AI team upgrades their foundation model, you face the challenge of dual-writing to new indexes and re-embedding millions of historical records without triggering application downtime.&lt;/p&gt;

&lt;p&gt;These aren't problems you can solve with simple Python scripts or basic batch cron jobs. They require handling continuous database updates, maintaining strict idempotency to prevent duplicate embeddings, and executing high-throughput writes. If you don't treat RAG synchronization as a hardened data layer reality, you'll end up with index bloat, stale context, and degraded AI output quality.&lt;/p&gt;

&lt;p&gt;Faced with these realities, teams pick one of two paths. Build is the natural starting point. Here's why it usually doesn't end there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building Real-Time RAG Pipelines: Hidden TCO and the Integration Tax
&lt;/h2&gt;

&lt;p&gt;Engineering teams initially lean toward building their own streaming infrastructure for valid reasons. Extreme customizability, specialized networking protocols, strict air-gapped GovCloud compliance, and a mandate to avoid perceived vendor lock-in often drive the decision to assemble raw open source components.&lt;/p&gt;

&lt;p&gt;But these architectures rapidly hit the "serverless ceiling."&lt;/p&gt;

&lt;p&gt;Initial RAG pipelines built on serverless functions or batch jobs buckle under continuous CDC ingestion. Standard serverless limits, such as &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html" rel="noopener noreferrer"&gt;AWS Lambda's strict 15-minute execution limit&lt;/a&gt;, break long-running streaming state. Lambda's Kafka Event Source Mapping (ESM) handles polling for free, but you still pay &lt;a href="https://aws.amazon.com/lambda/pricing/" rel="noopener noreferrer"&gt;$0.0000166667 per GB-second&lt;/a&gt; plus request fees on every invocation, and the stateless invocation model leaves no room for the stateful joins, watermarks, or exactly-once guarantees that production CDC pipelines need.&lt;/p&gt;

&lt;p&gt;The architectural breaking point arrives when your team stops shipping differentiated AI features and starts maintaining fragile infrastructure. Highly paid engineers spend their sprints tuning Kafka partitions, managing distributed dead letter queues (DLQs), rewriting broken connector scripts, and orchestrating complex re-embedding workflows when a large language model (LLM) is upgraded.&lt;/p&gt;

&lt;p&gt;This operational drag is the "integration tax."&lt;/p&gt;

&lt;p&gt;Stitching together best-of-breed raw cloud components comes with an ever-growing maintenance burden that stalls feature velocity. Building and stabilizing a single production-grade CDC connector typically consumes three to six engineering months of labor. That's because building a connector involves navigating single-threaded snapshot bottlenecks, handling complex state management, and overcoming performance barriers. For example, the Debezium PostgreSQL connector is &lt;a href="https://debezium.io/documentation/reference/1.9/connectors/postgresql.html" rel="noopener noreferrer"&gt;architecturally limited to one streaming task&lt;/a&gt;, meaning a single thread captures all changes in order. Under high write volumes, this causes lag and requires multiple connectors to scale, adding to the complexity of partitioning and reassembly.&lt;/p&gt;

&lt;p&gt;The total cost of ownership (TCO) formula has three components: infrastructure (compute, storage, network), operations (labor), and hidden costs (downtime, opportunity cost, cross-AZ traffic). Self-managed deployments also incur a "state tax." Managing Flink requires &lt;a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/large_state_tuning" rel="noopener noreferrer"&gt;tuning RocksDB block caches&lt;/a&gt; and remote durable storage for checkpoints. Multi-AZ open source Kafka deployments silently rack up massive AWS cross-AZ data transfer fees at &lt;a href="https://aws.amazon.com/blogs/networking-and-content-delivery/optimizing-data-transfer-costs-when-using-aws-network-load-balancer/" rel="noopener noreferrer"&gt;$0.01 per GB&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The table below maps each of those three buckets to where DIY teams pay versus what a unified managed platform absorbs.&lt;/p&gt;

&lt;h3&gt;
  
  
  TCO Comparison by Cost Component: Custom Build vs Unified Managed Platform
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost component&lt;/th&gt;
&lt;th&gt;Self-managed (open source Kafka,  Flink, and connectors)&lt;/th&gt;
&lt;th&gt;Unified managed platform (e.g., Confluent)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Broker infrastructure&lt;/td&gt;
&lt;td&gt;Self-managed VMs, 24/7 on-call, multi-AZ egress at $0.01 per GB&lt;/td&gt;
&lt;td&gt;Fully managed, 99.99% SLA, optimized cross-AZ paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connectors&lt;/td&gt;
&lt;td&gt;Three to six engineering months per source for the first version, plus ongoing schema-drift fixes&lt;/td&gt;
&lt;td&gt;More than 80 fully managed connectors out of the box, no source-side maintenance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream processing&lt;/td&gt;
&lt;td&gt;Self-managed Flink: RocksDB tuning, checkpoint storage, JVM upgrades&lt;/td&gt;
&lt;td&gt;Serverless Flink, billed per Confluent Unit for Flink (CFU) consumed, hard spending caps available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding tier&lt;/td&gt;
&lt;td&gt;Separate fleet of Python embedding workers, plus queue and retry logic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ML_PREDICT&lt;/code&gt; and &lt;code&gt;AI_COMPLETE&lt;/code&gt; inside the stream processor, no separate worker tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Governance and lineage&lt;/td&gt;
&lt;td&gt;Build your own schema registry, lineage tracker, and role-based access control (RBAC) layer&lt;/td&gt;
&lt;td&gt;Schema Registry, Data Contracts, Stream Catalog, Stream Lineage included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational labor&lt;/td&gt;
&lt;td&gt;0.5 to 2 dedicated platform FTEs at small or medium scale, multiple teams at enterprise&lt;/td&gt;
&lt;td&gt;Capacity reclaimed for AI feature work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Specific dollar values vary widely by workload, region, and data volume. Anyone who hands you a single annual figure without your topology in hand is selling you a number. Forrester's &lt;a href="https://www.confluent.io/resources/report/forrester-economic-impact-confluent-cloud/" rel="noopener noreferrer"&gt;Total Economic Impact study of Confluent Cloud&lt;/a&gt; is a defensible starting point for benchmarking your own scenario against a self-managed open source build, and Confluent's &lt;a href="https://www.confluent.io/pricing/cost-estimator" rel="noopener noreferrer"&gt;public cost estimator&lt;/a&gt; lets you size a workload directly.&lt;/p&gt;

&lt;p&gt;Generating embeddings natively inside the stream processor eliminates the need to provision, scale, and monitor a separate fleet of Python embedding workers, reducing both your cloud bill and operational headcount.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Evaluate Managed Streaming Platforms for Real-Time RAG in 2026
&lt;/h2&gt;

&lt;p&gt;With the cost of building mapped, the next question is what a managed alternative actually needs to deliver to absorb that complexity. Evaluating managed streaming platforms for RAG workloads requires moving beyond basic throughput benchmarks. In 2026, production-grade data streaming infrastructure must natively execute four foundational capabilities: stream, connect, process, and govern. On top of those four, it needs dedicated AI-native primitives (in-flight embedding, MCP-served context, agent runtime) under a single SLA.&lt;/p&gt;

&lt;p&gt;The four subsections below cover the foundational capabilities. The fifth covers the AI-native layer that sits on top of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stream: Throughput, Latency, and Uptime Requirements
&lt;/h3&gt;

&lt;p&gt;Your foundational messaging layer must support GBps+ throughput, ultra-low tail latency, and a 99.99% uptime SLA, without manual partition rebalancing.&lt;/p&gt;

&lt;p&gt;Modern cloud-native engines, like the &lt;a href="https://www.confluent.io/confluent-cloud/kora/" rel="noopener noreferrer"&gt;Kora engine&lt;/a&gt;, which powers Confluent cloud, decouple compute from storage to deliver 10x faster autoscaling and 10x lower tail latencies than self-managed Kafka while staying fully compatible with Apache Kafka® at the protocol level. Your existing producers and consumers keep working as they are. Cluster Linking creates real-time replicas of existing Kafka data and metadata for zero-downtime migration when you move away from open-source Kafka. The decoupled architecture means a cluster absorbs sudden ingestion spikes (common during a backfill or re-embedding window) without you having to lift a finger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect: Fully Managed CDC and Connector Coverage
&lt;/h3&gt;

&lt;p&gt;Evaluate platforms strictly on the breadth and depth of their fully managed connector ecosystem. You need out-of-the-box support for complex CDC workloads, software-as-a-service (SaaS) applications, and object storage.&lt;/p&gt;

&lt;p&gt;A platform offering &lt;a href="https://www.confluent.io/product/connectors/" rel="noopener noreferrer"&gt;more than 120 connectors&lt;/a&gt;, where more than 80 are fully managed (including complex integrations like Postgres Debezium, Oracle CDC, and Snowflake), lets your engineers provision reliable data pipelines in minutes rather than dedicating months to custom development.&lt;/p&gt;

&lt;h3&gt;
  
  
  Process: Stateful Stream Processing and In-Flight Embeddings
&lt;/h3&gt;

&lt;p&gt;Stream processing must be serverless, support stateful joins, and execute in-flight machine learning (ML) inference. Transforming a text column into a vector embedding directly inside the stream processor simplifies your architecture.&lt;/p&gt;

&lt;p&gt;Engines like &lt;a href="https://docs.confluent.io/cloud/current/flink/reference/functions/model-inference-functions.html" rel="noopener noreferrer"&gt;Confluent Cloud for Apache Flink&lt;/a&gt; ship SQL functions like &lt;code&gt;ML_PREDICT&lt;/code&gt; and &lt;code&gt;AI_COMPLETE&lt;/code&gt; that replace a separate embedding worker tier. Your data engineer writes one ANSI SQL statement to turn a text column in a Kafka topic into a continuous stream of vector embeddings, and the platform handles batching, retries, and rate limits against the embedding API. The same engine supports Python and Java for cases where SQL isn't expressive enough, useful for custom chunking strategies or hybrid retrieval logic. &lt;/p&gt;

&lt;p&gt;What's distinctive about Confluent Cloud for Apache Flink is the combination of three languages, native AI functions, and a managed runtime sharing one SLA with the broker. The closest AWS path pairs Amazon Managed Streaming for Apache Kafka (MSK) with Amazon Managed Service for Apache Flink (MSF), which delivers a real Flink runtime supporting SQL, Python, and Java but ships no ML_PREDICT or AI_COMPLETE equivalent and sits on a separate SLA from MSK. MSK paired with Lambda is simpler for short enrichment, but Lambda's 15-minute execution wall breaks long-running streaming state. Open source Flink demands deep Java fluency and a self-managed cluster, and Redpanda has no native Flink at all (its in-broker WebAssembly transforms are sandboxed and limited, by Redpanda's own admission, to "trivial and stateless" cases).&lt;/p&gt;

&lt;p&gt;The processing engine must guarantee exactly-once semantics. Without advanced two-phase commit protocols, retry loops will push duplicate embeddings or miss delete commands, permanently corrupting your RAG context.&lt;/p&gt;

&lt;p&gt;The processor must also offer robust failure handling (configurable backpressure, buffer debloating, exponential retries, and dead letter queues) to safely navigate strict API rate limits from LLM embedding providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Govern: Data Contracts, Catalog, Lineage, and Access Control for RAG
&lt;/h3&gt;

&lt;p&gt;AI outputs are only as trustworthy as their inputs. You need enterprise-grade governance to keep RAG indexes secure, traceable, and accurate.&lt;/p&gt;

&lt;p&gt;Start with a &lt;a href="https://docs.confluent.io/platform/current/schema-registry/index.html" rel="noopener noreferrer"&gt;Schema Registry&lt;/a&gt; that enforces strict Data Contracts, preventing an upstream database change from silently breaking your downstream embedding pipeline. Pair it with a Stream Catalog that organizes Kafka topics as discoverable data products with metadata tagging, search, and self-service access requests, so AI teams can find and adopt trusted streams without bottlenecking on a central data engineering team.&lt;/p&gt;

&lt;p&gt;Stream Lineage gives you the audit trail every AI agent's context source needs, answering "where did this RAG document come from, and what schema version produced its embedding?" RBAC, client-side field-level encryption (CSFLE), and masking ensure personally identifiable information (PII) is masked before it ever reaches the vector database.&lt;/p&gt;

&lt;h3&gt;
  
  
  AI-Native: Streaming Agents, MCP Context, and Built-In ML
&lt;/h3&gt;

&lt;p&gt;A modern streaming platform must speak the language of agentic AI. The four foundational capabilities above keep your data plane reliable. The AI-native layer on top is what turns it into a substrate for production agents.&lt;/p&gt;

&lt;p&gt;Confluent Intelligence is the dedicated AI layer of the data streaming platform and ships three components on top of Kafka and Flink:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming Agents.&lt;/strong&gt; Agents that run as Flink jobs inside the stream processing pipeline, with always-on state, tool calling via MCP and Agent2Agent (A2A), and replayable, governed event flows. Because they are Flink jobs, the same exactly-once and lineage guarantees apply to agent decisions.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-Time Context Engine.&lt;/strong&gt; A fully managed service that serves structured context to AI apps and agents over the &lt;a href="https://modelcontextprotocol.io/specification/2025-03-26" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;, with built-in authentication, RBAC, and audit logging. MCP integrations include LangChain, Amazon Bedrock, Salesforce Agentforce, and Anthropic Claude.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in ML functions.&lt;/strong&gt; Native Flink SQL functions for embedding, anomaly detection, fraud prevention, forecasting, and sentiment analysis, with hooks to invoke remote AI/ML models or custom ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://www.confluent.io/product/tableflow/" rel="noopener noreferrer"&gt;Tableflow&lt;/a&gt; extends these same Kafka topics into open table formats (Apache Iceberg™ and Delta Lake), so the streams that feed your real-time RAG pipeline form the bronze and silver layers of an analytics medallion stack. Tableflow eliminates separate ETL pipelines and shifts processing and governance left, an approach Confluent reports &lt;a href="https://www.confluent.io/shift-left/processing-governance/" rel="noopener noreferrer"&gt;cuts analytical compute costs by up to 30% and reduces data quality issues by up to 60%&lt;/a&gt;, while giving AI agents readily queryable historical context alongside their real-time streams.&lt;/p&gt;




&lt;h2&gt;
  
  
  Streaming Platform Comparison: Custom Build, MSK, Redpanda, Confluent
&lt;/h2&gt;

&lt;p&gt;Apply those evaluation criteria to the market, and the practical streaming choices for a real-time RAG initiative are narrowed to four. You can roll your own with open source components, lean on a hyperscaler-managed broker like MSK, pick a Kafka-compatible alternative like Redpanda, or buy a complete data streaming platform like Confluent. Each has a defensible use case. Only one was designed end-to-end for production agentic AI.&lt;/p&gt;

&lt;h3&gt;
  
  
  At a Glance: How Each Option Covers the Four Capabilities Plus AI-Native Primitives
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Stream&lt;/th&gt;
&lt;th&gt;Connect&lt;/th&gt;
&lt;th&gt;Process&lt;/th&gt;
&lt;th&gt;Govern&lt;/th&gt;
&lt;th&gt;AI-native&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Custom build&lt;/strong&gt; (self-managed Kafka, Flink, and connectors)&lt;/td&gt;
&lt;td&gt;Self-managed&lt;/td&gt;
&lt;td&gt;Self-managed&lt;/td&gt;
&lt;td&gt;Self-managed&lt;/td&gt;
&lt;td&gt;Self-managed&lt;/td&gt;
&lt;td&gt;DIY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AWS MSK + Glue + MSF/Lambda&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✓ Managed broker, 99.9% SLA (infrastructure only)&lt;/td&gt;
&lt;td&gt;Bring your own connectors, limited managed CDC&lt;/td&gt;
&lt;td&gt;Bolt-on via MSF (separate SLA from MSK, no &lt;code&gt;ML_PREDICT&lt;/code&gt;/&lt;code&gt;AI_COMPLETE&lt;/code&gt;) or Lambda (15-min cap)&lt;/td&gt;
&lt;td&gt;Piecemeal (Glue Schema Registry is primarily Java-focused, no unified catalog or lineage)&lt;/td&gt;
&lt;td&gt;Bring your own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Redpanda&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✓ C++ Kafka-compatible broker, 99.99% multi-zone / 99.5% single-zone, bring your own cloud (BYOC) option&lt;/td&gt;
&lt;td&gt;More than 10 fully managed connectors&lt;/td&gt;
&lt;td&gt;No native Flink (in-broker WebAssembly only)&lt;/td&gt;
&lt;td&gt;Basic schema registry, no Stream Catalog or Stream Lineage&lt;/td&gt;
&lt;td&gt;Bring your own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Confluent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✓ Kora engine, 99.99% SLA covering infrastructure and Kafka software&lt;/td&gt;
&lt;td&gt;✓ More than 120 connectors, more than 80 fully managed&lt;/td&gt;
&lt;td&gt;✓ Serverless Flink with &lt;code&gt;ML_PREDICT&lt;/code&gt; and &lt;code&gt;AI_COMPLETE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✓ Schema Registry, Data Contracts, Stream Catalog, Stream Lineage, CSFLE, bring your own key (BYOK)&lt;/td&gt;
&lt;td&gt;✓ Confluent Intelligence (Streaming Agents, Real-Time Context Engine, built-in ML functions)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The subsections below give a profile of the best-fit and trade-offs for each option. The decision matrix later in the article maps these options to specific organizational profiles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom Build: Self-managed Kafka, Flink, andConnectors
&lt;/h3&gt;

&lt;p&gt;The traditional self-managed approach involves provisioning open source Kafka, managing KRaft (or legacy ZooKeeper) quorums, deploying Flink clusters, and writing custom Python workers for chunking and vector embeddings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; massive enterprises with dedicated, heavily staffed infrastructure teams, extensive legacy on-premises deployments, unique networking constraints, and extreme customization requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; you assume the maximum possible operational burden and get zero vendor SLAs on integrations, which means your team handles all edge cases, schema evolutions, and scaling events. This path incurs the highest hidden labor costs and delays time-to-market for AI features.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS MSK: AWS-Native Broker With Bolt-On Processing
&lt;/h3&gt;

&lt;p&gt;MSK provides a managed broker experience. Teams often pair MSK with MSF or Lambda for processing and AWS Glue for schema management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; organizations under strict mandates to use only native AWS services for billing consolidation, or teams already deeply entrenched in the AWS ecosystem and willing to absorb significant day 2 operational burden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; for production real-time RAG, the gaps add up fast.&lt;/p&gt;

&lt;p&gt;First, the ZooKeeper-to-KRaft migration. Apache Kafka removed ZooKeeper entirely in Kafka 4.0. For any MSK customer still running on a ZooKeeper-based cluster (which covers most clusters spun up before AWS added KRaft support to MSK), this is a forced cluster rebuild: MSK has no in-place upgrade path from ZooKeeper to KRaft, so those customers must spin up a new cluster and migrate their data and applications. The technical effort to migrate from ZooKeeper-based MSK to KRaft-based MSK is roughly the same as migrating to Confluent Cloud.&lt;/p&gt;

&lt;p&gt;Second, the SLA gap is structural. MSK provides 99.9% uptime covering infrastructure only, with Kafka and ZooKeeper software failures explicitly excluded. That works out to 7.9 additional hours (or more due to exclusions) of potential downtime per year compared to Confluent Cloud's 99.99%, which covers both infrastructure and Kafka software. For a real-time RAG pipeline feeding production AI, the gap of nearly eight hours is the difference between a minor incident and a stale-context outage.&lt;/p&gt;

&lt;p&gt;Third, the hidden costs compound. MSK's apparent low price expands once you account for monitoring beyond CloudWatch's basic tier (topic-level metrics cost extra), a Kafka UI (MSK ships none), Cruise Control for partition rebalancing on Standard clusters, schema registry self-management (Glue Schema Registry primarily supports Java clients), proxy infrastructure, and a Private Certificate Authority for mTLS. Layer on a processing tier you assemble yourself: MSF runs on its own SLA separate from MSK and ships no &lt;code&gt;ML_PREDICT&lt;/code&gt; or &lt;code&gt;AI_COMPLETE&lt;/code&gt; equivalents, and Lambda is bound by a 15-minute execution wall that breaks long-running streaming state. Add a piecemeal governance story across Glue, Identity and Access Management (IAM), and CloudWatch with no unified Stream Catalog or Stream Lineage equivalent, and you're stitching multiple disparate services together with no single SLA, no Kafka-specific support, and AWS-only deployment with no multi-cloud or hybrid path.&lt;/p&gt;

&lt;p&gt;Companies like Square, Instacart, iFood, SmartThings, and SecurityScorecard switched from MSK to Confluent because the operational burden and feature gaps became intolerable at scale. SecurityScorecard alone reports &lt;a href="https://www.confluent.io/compare/confluent-cloud-vs-amazon-msk/#kafka-cost-of-ownership--msk-vs-confluent" rel="noopener noreferrer"&gt;more than $1 million in savings&lt;/a&gt; after switching from MSK to Confluent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redpanda: Kafka-Compatible Broker Without a Full RAG Platform
&lt;/h3&gt;

&lt;p&gt;Redpanda is a C++ Kafka clone with high (but not 100%) Kafka API compatibility, packaged across community on-premises, BYOC, dedicated, and serverless tiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; small teams running simple event logging or edge workloads where C++ thread-per-core architecture and broker-level p99 latency are the primary constraints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Redpanda is a broker, not a data streaming platform, and the platform gap matters most for production RAG.&lt;/p&gt;

&lt;p&gt;First, it isn’t fully compatible with Kafka API. Partial compatibility means edge cases break with tools that the open-source Kafka community treats as standard. Redpanda's "225 connectors" headline counts processors, which are equivalent to Kafka's single-message transforms (SMTs). The genuine production-ready connector count is a fraction of that figure, none of which are offered as a managed service, compared with Confluent's more than 120 connectors, with more than 80 fully managed.&lt;/p&gt;

&lt;p&gt;Second, performance claims deserve scrutiny. Redpanda's "10x faster than Kafka" headline holds in synthetic, single-producer benchmarks. It degrades in real production workloads with larger producer groups, record keys, and long-running tests. Confluent's Kora engine, on production-shaped workloads, has been measured up to 10x faster than self-managed Kafka and delivers GBps+ throughput with elastic scaling rather than tier-based manual sizing.&lt;/p&gt;

&lt;p&gt;Third, compliance and reliability are uneven. Redpanda lists two production-grade certifications (SOC 2 and GDPR readiness, plus a recent HIPAA self-attestation) against Confluent's 10 (SOC 1/2/3, ISO 27001/27701, PCI DSS, CSA Star, TISAX, HITRUST, HIPAA). The single-zone Redpanda BYOC and Dedicated SLA is 99.5%, equivalent to approximately 43 more hours of potential downtime per year than Confluent Cloud. Redpanda BYOC additionally requires installing an agent inside your virtual private cloud (VPC) with break-glass support access for Redpanda engineers, a model that enterprise security teams with strict data sovereignty requirements may find concerning.&lt;/p&gt;

&lt;p&gt;Stream processing is bolt-on. Redpanda's in-broker WebAssembly transforms are sandboxed and, by Redpanda's own admission, limited to "&lt;a href="https://www.redpanda.com/blog/comparing-flink-vs-redpanda-data-transforms#:~:text=using%20Apache%20Flink-,Your%20operations%20are%20complex%20and%20stateful,your%20transformation%20is%20data%2Dintensive." rel="noopener noreferrer"&gt;trivial and stateless&lt;/a&gt;" cases. There is no native Flink, no &lt;code&gt;ML_PREDICT&lt;/code&gt; or &lt;code&gt;AI_COMPLETE&lt;/code&gt; equivalent, no Stream Lineage, no Stream Catalog, no client-side field level encryption, and no BYOK. Customers building real-time RAG end up assembling external processing and governance, which puts them back at the integration tax we already mapped.&lt;/p&gt;

&lt;p&gt;Real customer migrations underscore the gap. Elemental Cognition, an AI digital native, switched from Redpanda to Confluent Cloud for &lt;a href="https://www.confluent.io/blog/data-streaming-powers-trustworthy-AI/" rel="noopener noreferrer"&gt;mission-critical real-time workloads&lt;/a&gt;. &lt;/p&gt;

&lt;h3&gt;
  
  
  Confluent: Unified Streaming Platform for Real-Time RAG
&lt;/h3&gt;

&lt;p&gt;Confluent delivers a complete data streaming platform that encompasses the Kora engine, Confluent Cloud for Apache Flink, more than 120 managed connectors, Stream Governance, Tableflow, and Confluent Intelligence under one SLA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; enterprises that need to stream, connect, process, and govern data under a single 99.99% SLA covering both infrastructure and Kafka software, and especially for teams building production-grade agentic AI applications who want first-class AI primitives natively integrated into the data plane.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Confluent's list price can feel premium for basic, low-volume logging use cases. For complex, multi-source RAG architectures, the consolidated ecosystem typically yields the lowest TCO once connector development time, embedding worker tier consolidation, and avoided governance build-out are included. Forrester's &lt;a href="https://www.confluent.io/resources/report/forrester-economic-impact-confluent-cloud/" rel="noopener noreferrer"&gt;Total Economic Impact study&lt;/a&gt; reports 257% ROI and $2.58M in savings over self-managed Apache Kafka, and Confluent's &lt;a href="https://www.confluent.io/blog/cost-of-kafka-migration/" rel="noopener noreferrer"&gt;migration cost analysis&lt;/a&gt; shows up to 60% TCO reduction.&lt;/p&gt;

&lt;p&gt;The Confluent advantage stack is concrete. Kora delivers GBps+ throughput with full Kafka protocol compatibility, so your existing producers and consumers don't change. Cluster Linking gives you a zero-downtime migration path from MSK or self-managed Kafka. Stream Governance bundles Schema Registry, Data Contracts, Stream Catalog, and Stream Lineage into a single suite, and CSFLE and BYOK lock down PII before it reaches the vector index.&lt;/p&gt;

&lt;p&gt;The people and the AI layer round it out. Confluent was founded by the original co-creators of Apache Kafka. It’s one of the largest contributors to the Apache Kafka open source project, and offers committer-led support with a 60-minute contractual P1 response. On top of that foundation, Confluent Intelligence ships Streaming Agents, the Real-Time Context Engine, and built-in ML functions as native primitives, which is exactly the surface area a production RAG pipeline needs.&lt;/p&gt;

&lt;p&gt;Customer evidence backs the position. &lt;a href="https://www.youtube.com/watch?v=nc2JaR4czRc&amp;amp;t=230s" rel="noopener noreferrer"&gt;Henry Schein One&lt;/a&gt; frames it directly: "Everyone wants AI, but the hard part is getting high-quality data moving in real time. The Confluent data streaming platform makes that possible for us." &lt;a href="https://www.confluent.io/customers/notion/" rel="noopener noreferrer"&gt;Notion&lt;/a&gt; attributes its ability to keep AI tools fed with up-to-the-second context to Confluent's managed connector and streaming layer. The &lt;a href="https://www.confluent.io/customers/pncc/" rel="noopener noreferrer"&gt;Palmerston North City Council&lt;/a&gt; team summarizes the AI-data dependency clearly: "Good AI needs good data. Confluent is our trusted source of truth. The data streaming platform provides context and orchestration for our AI agents to automate workflows and accelerate our smart city transformation." &lt;a href="https://www.confluent.io/customers/securityscorecard/" rel="noopener noreferrer"&gt;SecurityScorecard&lt;/a&gt; reports more than $1 million in savings after switching from MSK to Confluent. The pattern is consistent: when teams move from a piecemeal stack to a unified platform, the AI roadmap unlocks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision Matrix: Which Streaming Approach Fits Your Real-Time RAG Needs?
&lt;/h2&gt;

&lt;p&gt;Choosing the right streaming infrastructure requires an assessment of your organizational constraints, existing engineering headcount, and strategic AI goals.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Organizational constraints and engineering profile&lt;/th&gt;
&lt;th&gt;Recommended approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;If you have:&lt;/strong&gt; Strict air-gapped environments, unique networking protocols, a dedicated team of more than 20 infrastructure engineers, and a mandate to avoid commercial software.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Choose: Custom build.&lt;/strong&gt; The heavy integration tax and high labor costs are justified by absolute architectural control.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;If you have:&lt;/strong&gt; Predominantly simple event logging needs, low data volume, edge or single-zone deployments where the 99.5% single-zone SLA is acceptable, and a preference for a C++ broker.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Choose: Redpanda.&lt;/strong&gt; Redpanda provides a low-footprint Kafka-compatible broker for targeted workloads, though you sacrifice platform completeness, governance, and a managed connector ecosystem.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;If you have:&lt;/strong&gt; A strict mandate to consolidate cloud billing within AWS, existing expertise in AWS Glue, AWS-only deployment with no multi-cloud or hybrid plans, and a willingness to absorb a forced ZooKeeper-to-KRaft migration.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Choose: AWS MSK.&lt;/strong&gt; MSK offers native billing integration, provided you accept the 99.9% infrastructure-only SLA, several categories of hidden costs, and heavier orchestration overhead.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;If you have:&lt;/strong&gt; Multiple complex data sources, strict enterprise data governance requirements, the need to inject real-time context into AI agents, and a strategic mandate to ship fast.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Choose: Confluent.&lt;/strong&gt; Confluent eliminates the integration tax, delivers stream, connect, process, govern, and AI-native primitives under one 99.99% SLA, and supports zero-downtime migration from MSK or self-managed Kafka via Cluster Linking.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Build vs Buy: Making the Call
&lt;/h2&gt;

&lt;p&gt;Real-time RAG is a streaming systems problem before it is an AI problem. That single reframe is what separates teams who ship production AI from teams who stall in pilot purgatory.&lt;/p&gt;

&lt;p&gt;The case for building is narrow and well-defined. If you operate in an air-gapped or sovereign environment, have unique networking constraints, or already staff a team of more than 20 engineers dedicated to Kafka and Flink operations, the upfront flexibility of open source components can justify the integration tax.&lt;/p&gt;

&lt;p&gt;For most enterprises, that case doesn't apply. The cost math in this article is not subtle: three to six engineering months per CDC connector, a serverless ceiling that breaks long-running streaming state, and cross-AZ egress fees that compound silently. None of those costs show up in a vendor proposal. They show up two years in, when your AI roadmap is being held hostage by day two operations on infrastructure your team didn't set out to own.&lt;/p&gt;

&lt;p&gt;A unified managed streaming platform shifts that math. Stream, connect, process, and govern collapse into one SLA. The embedding worker tier disappears into Confluent Cloud for Apache Flink. Schema Registry, Data Contracts, and Stream Lineage replace governance you would otherwise build yourself. And on top of those four foundational capabilities, AI-native primitives (Streaming Agents, Real-Time Context Engine, and built-in ML functions) give your agent teams a substrate they can actually ship against.&lt;/p&gt;

&lt;p&gt;If your organization is building agentic AI and needs continuous, trusted context, Confluent is the streaming foundation that absorbs the integration tax instead of charging you for it. To go deeper, explore &lt;a href="https://docs.confluent.io/cloud/current/flink/reference/functions/model-inference-functions.html" rel="noopener noreferrer"&gt;Confluent's &lt;code&gt;ML_PREDICT&lt;/code&gt; and &lt;code&gt;AI_COMPLETE&lt;/code&gt; model-inference functions&lt;/a&gt; inside Confluent Cloud for Apache Flink, or &lt;a href="https://www.confluent.io/pricing/cost-estimator" rel="noopener noreferrer"&gt;model your own infrastructure savings&lt;/a&gt; with Confluent's cost estimator.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is "real-time RAG" and why does it require streaming infrastructure?
&lt;/h3&gt;

&lt;p&gt;Real-time RAG continuously syncs changes from operational systems into a vector index so LLM responses use fresh context. That requires CDC ingestion, stateful processing, and reliable delivery, not periodic batch jobs.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you keep a vector database in sync with Postgres or Oracle changes?
&lt;/h3&gt;

&lt;p&gt;Use CDC connectors to capture inserts, updates, and deletes, process events to chunk text and generate embeddings, then apply upserts and deletes to the vectors database to prevent drift.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the "integration tax" in a DIY RAG pipeline?
&lt;/h3&gt;

&lt;p&gt;The integration tax is the ongoing engineering cost of stitching together and operating connectors, stream processing, retries and dead letter queues (DLQs), schema evolution handling, and re-embedding workflows. It often dwarfs the initial build effort.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where do real-time analytics databases fit in a real-time RAG architecture?
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.linkedin.com/pulse/what-best-real-time-analytics-database-2026-buyers-guide-chawla-gt83c/?trackingId=4mmssiWbRFqnxsuriT1CcA%3D%3D" rel="noopener noreferrer"&gt;Real-time analytics databases&lt;/a&gt; serve a different role from streaming platforms. The streaming platform handles ingestion, processing, governance, and delivery. A real-time analytics database sits downstream as a query engine, powering sub-second dashboards, operational monitoring, and ad-hoc investigation over the same governed event streams. In architectures that use Tableflow, the analytics engine can query Kafka topics directly as Iceberg tables without a separate ETL pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does it take to build a production-grade CDC connector?
&lt;/h3&gt;

&lt;p&gt;Commonly, three to six engineering months per connector, once you include snapshots, backfills, failure handling, schema changes, and operational runbooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do exactly-once semantics matter for embeddings and vector upserts?
&lt;/h3&gt;

&lt;p&gt;Without exactly-once semantics, retries can create duplicate embeddings or miss deletes, corrupting the vector index and leading to stale or incorrect retrieval results.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when the source schema changes (schema evolution)?
&lt;/h3&gt;

&lt;p&gt;Pipelines can break or silently produce wrong embeddings unless schemas are governed with contracts and a registry, and downstream processors are compatible with additive and breaking changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you handle re-embedding when you change models or chunking logic?
&lt;/h3&gt;

&lt;p&gt;You typically dual-write to a new index, backfill historical records, and cut over once parity is verified. This requires orchestration, lineage, and careful rollback planning.&lt;/p&gt;

&lt;h3&gt;
  
  
  When is "build" the right choice for real-time RAG streaming?
&lt;/h3&gt;

&lt;p&gt;When you must run in air-gapped or sovereign environments, need extreme customization, or already have a large platform team to own Kafka, Flink, connectors, and 24/7 operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is AWS MSK enough for production real-time RAG?
&lt;/h3&gt;

&lt;p&gt;MSK can cover the broker layer, but teams often still need to assemble connectors, processing, governance, and reliability patterns across multiple services. That raises operational complexity.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should I look for in a managed streaming platform for RAG in 2026?
&lt;/h3&gt;

&lt;p&gt;Native support for stream, connect, process, and govern, plus AI-ready capabilities like in-flight embedding generation, strong SLAs, schema governance, lineage, and secure PII handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does a unified platform reduce cost compared to separate embedding workers?
&lt;/h3&gt;

&lt;p&gt;If embeddings are generated within the stream processor, you can eliminate the need for a separate fleet of Python workers and the associated scaling, monitoring, retries, and queue management overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you prevent PII from entering the vector database?
&lt;/h3&gt;

&lt;p&gt;Apply governance controls (RBAC, masking, data minimization) and enforce policies in-stream before embedding or upserting, so sensitive fields never reach the index.  &lt;/p&gt;

</description>
      <category>agents</category>
      <category>rag</category>
      <category>kafka</category>
      <category>eventdriven</category>
    </item>
    <item>
      <title>How to Build Compliant AI Agents With Stateful Stream Processing (EU AI Act-Ready Architecture Guide)</title>
      <dc:creator>Manveer Chawla</dc:creator>
      <pubDate>Mon, 15 Jun 2026 22:15:11 +0000</pubDate>
      <link>https://dev.to/dataengineering/how-to-build-compliant-ai-agents-with-stateful-stream-processing-eu-ai-act-ready-architecture-38h1</link>
      <guid>https://dev.to/dataengineering/how-to-build-compliant-ai-agents-with-stateful-stream-processing-eu-ai-act-ready-architecture-38h1</guid>
      <description>&lt;p&gt;The &lt;a href="https://artificialintelligenceact.eu/" rel="noopener noreferrer"&gt;EU AI Act&lt;/a&gt;'s general provisions are already in force, and high-risk AI system obligations apply from August 2026. The &lt;a href="https://nvlpubs.nist.gov/nistpubs/ai/nist.ai.100-1.pdf" rel="noopener noreferrer"&gt;National Institute of Standards and Technology (NIST) AI Risk Management Framework&lt;/a&gt; and its &lt;a href="https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.600-1.pdf" rel="noopener noreferrer"&gt;Generative AI Profile&lt;/a&gt; set the baseline for what auditors expect, framing governance around four functions: identify, measure, manage, and monitor. Deploying artificial intelligence (AI) agents in regulated environments isn't a sandbox experiment anymore. It's a strict governance challenge.&lt;/p&gt;

&lt;p&gt;Modern regulatory frameworks &lt;a href="https://artificialintelligenceact.eu/article/12" rel="noopener noreferrer"&gt;mandate automatic, lifetime event logging for high-risk AI systems&lt;/a&gt;, and stateless, chat-style agent frameworks typically can't satisfy that requirement. Replaying their decisions verbatim for auditors is rarely straightforward. Side effects like financial transactions can fire more than once during application retries. Audit trails get painstakingly reconstructed from fragmented application logs days after the fact. And sensitive personally identifiable information (PII) can scatter across vector stores, prompt caches, and external model providers with no centralized lineage and no client-side encryption.&lt;/p&gt;

&lt;p&gt;Regulators don't just want to block bad answers. They expect you to reconstruct exactly why an agent made a decision months later, using the exact data, model weights, and logic available at that precise microsecond.&lt;/p&gt;

&lt;p&gt;This guide gives Compliance Tech Leads and Enterprise Architects the architectural blueprint to evaluate agent runtimes and design legally defensible AI systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Executive Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Regulated AI agents can't typically be built as stateless chat apps. Auditors require lifetime, tamper-evident logging, exact traceability, and replayable decisions.
&lt;/li&gt;
&lt;li&gt;Model agents as event-driven, stateful workflows on a streaming-native runtime where &lt;a href="https://kafka.apache.org/" rel="noopener noreferrer"&gt;Apache Kafka®&lt;/a&gt; and &lt;a href="https://flink.apache.org/" rel="noopener noreferrer"&gt;Apache Flink®&lt;/a&gt; form the deterministic system of control, and the large language model (LLM) is the probabilistic reasoning engine.
&lt;/li&gt;
&lt;li&gt;Maintain seven distinct states (case, regulatory obligation, evidence, model version, consent, risk, audit log) so every decision is grounded in a durable, auditable context.
&lt;/li&gt;
&lt;li&gt;Apply four streaming patterns: event sourcing for an immutable Agent Decision Record, stateful policy gates to block unsafe actions, windowed monitoring for drift and bias, and state-based replay for verifiable audits.
&lt;/li&gt;
&lt;li&gt;Add client-side field level encryption (CSFLE), schema-level data contracts, and end-to-end lineage so sensitive data stays governed from source system to model output.
&lt;/li&gt;
&lt;li&gt;Streaming-native runtimes (Apache Kafka and Apache Flink on Confluent Cloud) are the architectural category that puts deterministic control and probabilistic reasoning under a single governed backbone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Seven Types of State Compliant AI Agents Must Maintain
&lt;/h2&gt;

&lt;p&gt;For regulatory compliance, stateful processing goes well beyond maintaining chat memory or a rolling window of conversation history. It captures the durable, multi-dimensional context required to make a legally binding or financially impactful decision.&lt;/p&gt;

&lt;p&gt;To build a defensible system, architects must capture and manage seven distinct states. The taxonomy below synthesizes the logging, traceability, and governance obligations of frameworks like the NIST AI Risk Management Framework, EU AI Act Article 12, and the IETF Agent Audit Trail draft into a unified state model for agent runtimes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case State
&lt;/h3&gt;

&lt;p&gt;Case state tracks exactly where a review, application, or claim stands within its lifecycle: which step of the workflow is active, what's been completed, and what remains pending. It's the agent's working understanding of "where are we" on a specific business process.&lt;/p&gt;

&lt;h3&gt;
  
  
  Regulatory Obligation State
&lt;/h3&gt;

&lt;p&gt;Obligation state binds each case to applicable regulatory rules, statutory deadlines, and required escalation paths. If a suspicious transaction is flagged, the obligation state tracks the strict &lt;a href="https://www.ecfr.gov/current/title-31/subtitle-B/chapter-X/part-1022/subpart-C/section-1022.320" rel="noopener noreferrer"&gt;30-day window required to file a Suspicious Activity Report (SAR)&lt;/a&gt;. The agent prioritizes tasks based on compliance deadlines, not arbitrary queue ordering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Evidence State
&lt;/h3&gt;

&lt;p&gt;Evidence state captures immutable snapshots of the documents, user inputs, and exact vector database retrieval corpus used to ground the prompt at execution time. Without the precise state of the retrieval corpus at the millisecond the decision was made, a verifiable reconstruction of the decision context becomes impossible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model Version State
&lt;/h3&gt;

&lt;p&gt;Model state locks in the exact model versions, prompt template versions, and generation parameters deployed during the inference step. Combined with the evidence state, it gives auditors a complete snapshot of the conditions present when the agent acted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consent State
&lt;/h3&gt;

&lt;p&gt;Consent state enforces attribute-based and role-based access controls, tracking user permissions and data processing expirations. It prevents the agent from using data or invoking tools beyond the scope that a user (or a specific regulatory basis) has authorized.&lt;/p&gt;

&lt;h3&gt;
  
  
  Risk State
&lt;/h3&gt;

&lt;p&gt;Risk state maintains rolling anomaly windows and dynamically calculated risk scores, allowing the system to monitor for model drift or emergent bias and trigger escalations the moment thresholds are crossed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Audit Log State
&lt;/h3&gt;

&lt;p&gt;Audit state forms the immutable event log itself. It's the foundational ledger that guarantees non-repudiation and supports full replayability of the entire state machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four Streaming Patterns for Compliant, Auditable AI Agents
&lt;/h2&gt;

&lt;p&gt;To transform these state definitions into a defensible, auditable system, architects must apply specific distributed streaming patterns. These patterns dictate how data moves, how rules are enforced, and how history is preserved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1: Event Sourcing to Create an Immutable Agent Decision Record
&lt;/h3&gt;

&lt;p&gt;Event sourcing means every input, vector retrieval, policy check, tool call, human override, and final action becomes a distinct, immutable event stored in a highly available Kafka topic. This forms the foundation for the audit, evidence, and model states.&lt;/p&gt;

&lt;p&gt;The tangible output is the Agent Decision Record: a structured event stream that logs every step of the agent workflow with reason codes, evidence references, and rule citations attached. The schema draws from emerging proposals like the &lt;a href="https://datatracker.ietf.org/doc/draft-sharif-agent-audit-trail/" rel="noopener noreferrer"&gt;Internet Engineering Task Force (IETF) Agent Audit Trail draft&lt;/a&gt;, which specifies a tamper-evident cryptographic chain using a previous-hash field encoded in SHA-256 alongside digitally signed records to guarantee non-repudiation.&lt;/p&gt;

&lt;p&gt;By capturing the exact prompt, retrieval citations, tool execution results, and policy gate evaluations in a tamper-evident ledger, organizations directly satisfy EU AI Act requirements for automatic logging and lifetime traceability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2: Stateful Policy Gates to Enforce Compliance Before Actions
&lt;/h3&gt;

&lt;p&gt;In a compliant architecture, an agent typically can't directly execute a real-world action. Deterministic business rules get evaluated against the agent's accumulated state immediately before a proposed action can create a real-world side effect.&lt;/p&gt;

&lt;p&gt;The language model only suggests. The stateful policy gate decides.&lt;/p&gt;

&lt;p&gt;This acts primarily on the case, obligation, consent, and risk states. For instance, a policy gate queries the case state to determine whether an insurance claim remains within its legally mandated 30-day review period. It queries the risk state to check if a customer's rolling anomaly score exceeds the threshold for autonomous approval.&lt;/p&gt;

&lt;p&gt;If the probabilistic output violates the deterministic policy, the gate blocks the transaction and safely routes the event to a human-in-the-loop dead letter queue. Policy gates also enforce segregation of duties (preventing the same agent identity from both proposing and approving a high-value action) and provide the system-wide kill switch that disables autonomous actuation while preserving intake, routing, and audit logging.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3: Windowed Monitoring to Detect Drift, Bias, and Emerging Risk
&lt;/h3&gt;

&lt;p&gt;Regulators require continuous monitoring for bias and performance degradation. Windowed monitoring computes real-time analytics over event-time windows to detect drift, bias, or runaway agent loops instantly. You don't wait for an end-of-month batch report.&lt;/p&gt;

&lt;p&gt;This pattern continuously queries the risk state, applying statistical change detection algorithms like &lt;a href="https://hanj.cs.illinois.edu/cs412/bk3/KL-divergence.pdf" rel="noopener noreferrer"&gt;Kullback-Leibler divergence&lt;/a&gt; or the Page-Hinkley test over sliding time windows. The system instantly recalculates rolling risk scores and fraud probabilities.&lt;/p&gt;

&lt;p&gt;It also monitors the case and obligation states to track service level agreements (SLAs), detect processing bottlenecks, and alert compliance teams if a queue of automated decisions approaches a statutory deadline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 4: State-Based Replay to Reproduce Decisions for Auditors
&lt;/h3&gt;

&lt;p&gt;Auditors demand proof, not promises.&lt;/p&gt;

&lt;p&gt;By combining the immutable Agent Decision Record with versioned state backends, you can create reproducible decision traces. Supply the same input events alongside the exact same evidence, model, and case state snapshots, and the system reconstructs exactly what the agent knew, what context it operated on, and what decision was logged, giving auditors a complete, verifiable record.&lt;/p&gt;

&lt;p&gt;Achieving this requires the model state to include a retrieval snapshot identifier that points to a specific backup or versioned instance of the vector database. This identifier ensures the exact retrieval corpus can be reloaded into the context window.&lt;/p&gt;

&lt;p&gt;Verifiable reconstruction proves to an auditor precisely what the agent did, what it knew, and why it acted. That's the highest standard of regulatory verifiability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference Architecture for Compliant AI Agents Using Confluent
&lt;/h2&gt;

&lt;p&gt;To achieve these patterns in production, enterprise architects need a streaming-native infrastructure stack. The following reference architecture positions Confluent as the deterministic system of control, wrapping the probabilistic interactions of the language model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliant AI agent reference architecture:&lt;/strong&gt;  &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyhr7ml1o1z2mo8oha21e.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyhr7ml1o1z2mo8oha21e.jpg" alt="Reference architecture for compliant AI agents using stateful stream processing: external sources flow through managed connectors into a Kafka topic, then Apache Flink stateful processing, a policy gate, and an LLM reasoning layer to audited downstream sinks, all wrapped in stream governance." width="800" height="993"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A compliant implementation relies on a clear, unidirectional flow of events. External event sources feed into the system via managed connectors. Events land in an immutable Kafka topic that acts as the central nervous system of the architecture. A stream processor ingests these events, maintaining the seven states in local durable storage.&lt;/p&gt;

&lt;p&gt;When an agent action is proposed, the stream processor routes the context to a stateful policy gate. If approved, the agent interacts with the language model layer. The model's response is validated, logged to the Agent Decision Record topic, and finally routed to a downstream audited sink for execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ingest and Connect Event Sources
&lt;/h3&gt;

&lt;p&gt;The architecture begins by capturing events via the Kafka protocol. One of the easiest ways to run a Kafka cluster is Confluent Cloud, powered by the  &lt;a href="https://www.confluent.io/confluent-cloud/kora/" rel="noopener noreferrer"&gt;the Kora engine&lt;/a&gt;, which  delivers a 99.99% uptime SLA and holds SOC 2, ISO 27001, &lt;a href="https://www.pcisecuritystandards.org/" rel="noopener noreferrer"&gt;PCI DSS&lt;/a&gt;, and HIPAA compliance attestation.&lt;/p&gt;

&lt;p&gt;Data flows in through &lt;a href="https://www.confluent.io/product/confluent-connectors/" rel="noopener noreferrer"&gt;more than 120 fully managed connectors&lt;/a&gt; for critical systems of record, including PostgreSQL via &lt;a href="https://debezium.io/" rel="noopener noreferrer"&gt;Debezium&lt;/a&gt;, and Oracle via change data capture (CDC) and XStream for transactional events, plus Snowflake for analytical context and Amazon S3 for document evidence. In regulated environments, those upstream systems include claims platforms, Know Your Customer (KYC) providers, electronic health records, and human resources information systems (HRIS).&lt;/p&gt;

&lt;p&gt;Crucially, this layer supports &lt;a href="https://docs.confluent.io/cloud/current/security/encrypt/csfle/overview.html" rel="noopener noreferrer"&gt;client-side field level encryption (CSFLE)&lt;/a&gt;. By defining encryption rules at the schema level, sensitive PII is encrypted before it ever leaves the source system. The data remains encrypted in motion and at rest within the broker, so sensitive information never travels in clear text to the agent or the model provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Process Events With Stateful Stream Processing (Apache Flink)
&lt;/h3&gt;

&lt;p&gt;Confluent Cloud for Apache Flink serves as the brain of the control flow, holding the seven critical states across multi-step agent workflows using highly scalable &lt;a href="https://rocksdb.org/" rel="noopener noreferrer"&gt;&lt;strong&gt;RocksDB&lt;/strong&gt;&lt;/a&gt; state backends. Teams can express logic in ANSI SQL, Python, or Java, matching the existing skill mix of data, platform, and compliance engineering.&lt;/p&gt;

&lt;p&gt;Flink provides exactly-once processing semantics through its &lt;a href="https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/" rel="noopener noreferrer"&gt;two-phase commit sink functions&lt;/a&gt;. A real-world side effect, like an approved financial transfer or a sent email, fires exactly one time even if the application crashes or the network forces a retry, though this guarantee applies to the stream-processing layer only. LLM API calls are non-transactional HTTP side effects and require separate idempotency handling.&lt;/p&gt;

&lt;p&gt;This eliminates the duplicate execution risks inherent in stateless agent frameworks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Govern Schemas, Data Contracts, and Lineage
&lt;/h3&gt;

&lt;p&gt;Governance is enforced at the broker level using &lt;a href="https://docs.confluent.io/platform/current/schema-registry/fundamentals/data-contracts.html" rel="noopener noreferrer"&gt;Schema Registry and Data Contracts&lt;/a&gt;. Malformed inputs, hallucinated schema structures, or missing required fields are rejected before they can corrupt the state machine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.confluent.io/cloud/current/stream-governance/stream-catalog.html" rel="noopener noreferrer"&gt;Stream Catalog&lt;/a&gt; lets compliance teams discover and request access to trusted agent-input streams without depending on tribal knowledge. &lt;a href="https://docs.confluent.io/cloud/current/stream-governance/stream-lineage.html" rel="noopener noreferrer"&gt;Stream Lineage&lt;/a&gt; provides an interactive, visual topology of the data flow, so architects can trace which specific schema version, input topic, and model pipeline produced a given automated approval.&lt;/p&gt;

&lt;h3&gt;
  
  
  AI Agent Reasoning Layer
&lt;/h3&gt;

&lt;p&gt;The reasoning layer is managed through &lt;a href="https://www.confluent.io/product/confluent-intelligence/" rel="noopener noreferrer"&gt;Confluent Intelligence&lt;/a&gt;, which runs &lt;a href="https://www.confluent.io/product/streaming-agents/" rel="noopener noreferrer"&gt;Streaming Agents&lt;/a&gt; directly as Flink jobs. Tool calling is coordinated through the &lt;a href="https://modelcontextprotocol.io/specification/2025-03-26" rel="noopener noreferrer"&gt;Model Context Protocol (MCP)&lt;/a&gt;, and agent-to-agent coordination uses the emerging &lt;a href="https://a2a-protocol.org/latest/specification/" rel="noopener noreferrer"&gt;A2A protocol&lt;/a&gt; to safely expose external APIs and other agents to the reasoning engine.&lt;/p&gt;

&lt;p&gt;Confluent’s Real-Time Context Engine serves as the bridge, providing privacy-aware context to the language model over MCP. Built-in machine learning functions handle embeddings, anomaly detection, and forecasting directly from the stream, so feature pipelines and model calls live in the same governed runtime as the agent itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regulated AI Agent Use Cases by Industry
&lt;/h2&gt;

&lt;p&gt;The separation of probabilistic reasoning and deterministic stream processing isn't theoretical. Leading organizations across highly regulated sectors currently use this blueprint to deploy agentic workflows safely. The patterns also extend cleanly to insurance underwriting and HR/workforce decisioning, where similar evidence, consent, and replay obligations apply.&lt;/p&gt;

&lt;h3&gt;
  
  
  Financial Services Use Case: AML and KYC Agents
&lt;/h3&gt;

&lt;p&gt;In the financial sector, autonomous agents review transaction alerts and orchestrate Anti-Money Laundering (AML) and KYC data gathering. These agents maintain a continuously rolling customer risk state.&lt;/p&gt;

&lt;p&gt;As new transactions stream in, Flink updates the risk profile in real time. Stateful policy gates enforce hard regulatory boundaries. Any customer whose risk score exceeds the acceptable threshold is blocked from autonomous approval. The agent must route the Agent Decision Record to a human compliance officer.&lt;/p&gt;

&lt;p&gt;This architecture mirrors the real-time risk platforms used by institutions like &lt;a href="https://www.confluent.io/blog/unlocking-real-time-fraud-detection-with-data-streaming/" rel="noopener noreferrer"&gt;Capital One&lt;/a&gt;, where high-throughput stream processing supports real-time banking for more than 100 million customers, including risk scoring and fraud detection without sacrificing operational latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Healthcare Use Case: Prior Authorization and Claims Agents
&lt;/h3&gt;

&lt;p&gt;Healthcare claims and clinical decision-support agents operate under the strict privacy constraints of &lt;a href="https://www.hhs.gov/hipaa/" rel="noopener noreferrer"&gt;HIPAA&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In this blueprint, the case state tracks active medical reviews, managing the complex routing required for human-in-the-loop approvals from medical directors. CSFLE ensures that protected health information (PHI) is cryptographically protected within the event stream.&lt;/p&gt;

&lt;p&gt;Organizations like &lt;a href="https://www.linkedin.com/posts/confluent_from-legacy-to-cutting-edge-henry-schein-activity-7307781318665228288-SHte" rel="noopener noreferrer"&gt;Henry Schein One&lt;/a&gt; use the Confluent data streaming platform to modernize legacy healthcare workflows, proving that streaming platforms can handle the integration and governance requirements of highly sensitive clinical data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Public Sector Use Case: Benefits Eligibility Orchestration Agents
&lt;/h3&gt;

&lt;p&gt;Government benefit orchestration agents must enforce strict data sovereignty rules and calculate exact-time eligibility windows.&lt;/p&gt;

&lt;p&gt;If a citizen applies for municipal assistance, the agent must evaluate their eligibility based on a precise snapshot of their financial data and the legal statutes active on that specific day.&lt;/p&gt;

&lt;p&gt;Public sector entities, such as the &lt;a href="https://www.confluent.io/customers/pncc/" rel="noopener noreferrer"&gt;Palmerston North City Council&lt;/a&gt;, use real-time streaming architectures to orchestrate complex citizen services. Automated determinations stay transparent, legally sound, and immune to processing delays.&lt;/p&gt;

&lt;h3&gt;
  
  
  Privacy Operations Use Case: DSAR Handling (GDPR and CCPA)
&lt;/h3&gt;

&lt;p&gt;Managing &lt;a href="https://gdpr.eu/" rel="noopener noreferrer"&gt;General Data Protection Regulation (GDPR)&lt;/a&gt; and &lt;a href="https://oag.ca.gov/privacy/ccpa" rel="noopener noreferrer"&gt;California Consumer Privacy Act (CCPA)&lt;/a&gt; operations requires careful precision.&lt;/p&gt;

&lt;p&gt;Agents deployed to handle Data Subject Access Requests (DSARs) track the state of identity verification and manage the strict 30-day regulatory deadline for compliance. This is distinct from the financial services 30-day SAR window above, but it's enforced through the same windowed-deadline pattern. Flink timers monitor these deadlines, automatically escalating cases at risk of a breach.&lt;/p&gt;

&lt;p&gt;For erasure requests, the immutable event log uses tombstone records and cryptographic shredding. The user's data is irretrievably destroyed while preserving the integrity of the tamper-evident audit chain. You can prove to regulators that the deletion was executed correctly and on time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Evaluate AI Agent Architectures for Compliance
&lt;/h2&gt;

&lt;p&gt;When designing systems for highly regulated environments, architects need a clear rubric. The following four-dimensional scorecard separates architectures that can carry a high-risk workload from those that can't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent-runtime properties:&lt;/strong&gt; Always-on durable state versus reactive stateless invocation. Exactly-once execution of side effects. Replay capability. Version pinning across model, prompt, policy, and retrieval corpus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Governance properties:&lt;/strong&gt; Data contracts at the broker. Lineage from the source system to the model output. Role-based access control (RBAC) and CSFLE. Retention and deletion alignment with privacy obligations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connector and identity coverage:&lt;/strong&gt; CDC against systems of record. KYC and identity feeds. HRIS integration. Coverage of the actual systems that hold regulated data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI primitives:&lt;/strong&gt; MCP-served context. A2A coordination. Stateful policy gates. Kill-switch support that disables autonomous action while preserving intake, routing, and audit logging.&lt;/p&gt;

&lt;p&gt;Applied to today's market, four categories emerge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Platform Comparison Across the Four Dimensions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Closed agent platforms (Agentforce, Copilot Studio, )&lt;/th&gt;
&lt;th&gt;Open source frameworks (LangChain, LangGraph, LlamaIndex)&lt;/th&gt;
&lt;th&gt;Workflow orchestrators (Temporal, AWS Step Functions)&lt;/th&gt;
&lt;th&gt;Streaming-native runtimes (Apache Kafka and Apache Flink on Confluent Cloud)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent-runtime properties&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Black-box state; replay and version pinning are typically not exposed&lt;/td&gt;
&lt;td&gt;No native durable state; replay depends on bolted-on storage&lt;/td&gt;
&lt;td&gt;Durable execution assumes deterministic code; LLM side effects break replay&lt;/td&gt;
&lt;td&gt;Always-on durable state, exactly-once side effects, replayable with full version pinning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Governance properties&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vendor-managed; limited lineage, no broker-level data contracts&lt;/td&gt;
&lt;td&gt;Application-level only; audit trails fragmented across logs and external databases&lt;/td&gt;
&lt;td&gt;Workflow-level audit; no schema enforcement at the data plane&lt;/td&gt;
&lt;td&gt;Broker-level data contracts, end-to-end lineage, RBAC, CSFLE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Connector and identity coverage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tied to vendor ecosystem&lt;/td&gt;
&lt;td&gt;DIY connectors; no managed CDC&lt;/td&gt;
&lt;td&gt;Bring-your-own integrations&lt;/td&gt;
&lt;td&gt;More than 120 managed connectors including CDC for Postgres, Oracle, Snowflake, and S3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI primitives&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Proprietary tool catalog; limited extensibility&lt;/td&gt;
&lt;td&gt;Strong prototyping primitives; no stateful policy gates or kill switch&lt;/td&gt;
&lt;td&gt;No native AI primitives; LLM is just another step&lt;/td&gt;
&lt;td&gt;MCP-served context, A2A coordination, stateful policy gates, kill switch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Closed Agent Platforms
&lt;/h3&gt;

&lt;p&gt;Proprietary platforms like Salesforce Agentforce, and Microsoft Copilot Studio offer rapid time-to-value for low-regulation, horizontal use cases such as basic customer support or internal knowledge retrieval.&lt;/p&gt;

&lt;p&gt;For regulated workloads, however, they don't expose the deep, customizable event lineage, cryptographic audit trailing, and raw data control needed when an auditor demands a byte-for-byte reconstruction of a custom financial or clinical workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open Source Agent Frameworks
&lt;/h3&gt;

&lt;p&gt;Open source libraries such as LangChain, LangGraph, and LlamaIndex have transformed developer productivity and excel as tools for prototyping language model interactions. LangGraph adds native checkpointing, but these frameworks remain application-level abstractions that lack exactly-once execution guarantees, and the enterprise-grade governance required to prevent data loss during catastrophic system failures.&lt;/p&gt;

&lt;p&gt;These frameworks rely heavily on external databases and application logs, which produces fragmented audit trails that struggle to demonstrate non-repudiation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workflow Orchestrators
&lt;/h3&gt;

&lt;p&gt;Standard workflow orchestrators like Temporal and AWS Step Functions excel for long-running, human-driven processes. They provide durable execution by replaying deterministic code against an event history.&lt;/p&gt;

&lt;p&gt;The non-deterministic nature of language models is harder for them. If an LLM side effect isn't perfectly isolated and idempotent, orchestrators risk duplicate executions or non-determinism errors on replay. They're also not designed to handle massive, continuous event-time windowing or the high-throughput streaming integration required to calculate rolling risk metrics in real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming-Native Runtimes
&lt;/h3&gt;

&lt;p&gt;A streaming-native runtime built on Apache Kafka and Apache Flink, delivered through Confluent Cloud, unifies the system of control and the system of reasoning under a single governed backbone.&lt;/p&gt;

&lt;p&gt;Kafka's immutable log provides the durable event backbone. Flink's checkpointing and Kafka-transaction integration close the loop with exactly-once semantics within the pipeline. For external side-effects, the architecture pairs at-least-once delivery with idempotent sinks to achieve effectively-once end-to-end behavior. Compliance teams get authority over data lineage, policy enforcement, and cryptographic auditing. The agent stays tethered to deterministic enterprise rules.&lt;/p&gt;

&lt;p&gt;For low-regulation horizontal use cases, the closed and open-source options remain valid. For workloads where auditability and replay are non-negotiable, streaming-native runtimes are a stronger fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phased Rollout Plan for Compliant AI Agents
&lt;/h2&gt;

&lt;p&gt;Transitioning from stateless prototypes to compliant, event-driven agent programs requires a disciplined, iterative approach. Enterprise architects should adopt a three-phase rollout strategy to mitigate risk and establish foundational governance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Pilot One Regulated Workflow With a Stateful Agent
&lt;/h3&gt;

&lt;p&gt;Start by selecting a single, well-defined regulated use case, like initial claims triage or document classification.&lt;/p&gt;

&lt;p&gt;Implement the core streaming architecture on Confluent Cloud's managed Kafka and Flink, focusing entirely on establishing the Agent Decision Record schema and enforcing CSFLE.&lt;/p&gt;

&lt;p&gt;During this phase, disable autonomous actuation. Rely heavily on human-in-the-loop thresholds. Use the agent strictly as a decision-support tool while auditors validate the integrity and completeness of the tamper-evident event log.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2: Scale to Cross-Workflow Orchestration With Shared Governance
&lt;/h3&gt;

&lt;p&gt;Once auditors verify the audit trail, expand the architecture to orchestrate multiple cooperative agents. Implement a centralized Schema Registry to enforce data contracts between different agent domains.&lt;/p&gt;

&lt;p&gt;Abstract the stateful policy gates into versioned, manageable rule sets.&lt;/p&gt;

&lt;p&gt;This phase introduces automated side effects for low-risk decisions, using Flink's exactly-once sinks to guarantee transactional integrity while routing medium and high-risk cases to human operators.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 3: Run Fully Automated, Continuously Monitored Regulated Agents
&lt;/h3&gt;

&lt;p&gt;In the final maturity phase, organizations achieve continuous, real-time oversight.&lt;/p&gt;

&lt;p&gt;Implement complex windowed monitoring for instant drift detection and rolling risk scoring. Wire the kill switch into the operations console so compliance leaders can suspend autonomous actuation across the agent fleet without disrupting intake or audit logging. The architecture now supports fully automated, replayable backtesting.&lt;/p&gt;

&lt;p&gt;Data science teams can simulate new prompt templates or model versions against historical, versioned state snapshots to demonstrate compliance before deploying updates to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion and Next Steps
&lt;/h2&gt;

&lt;p&gt;For highly regulated enterprise workloads, robust auditability and verifiable reconstruction are not optional. They are mandates. You cannot bolt compliance onto a stateless prototype after the fact. It must be engineered into the foundational fabric of the system from day one.&lt;/p&gt;

&lt;p&gt;Modern AI legislation requires a paradigm shift in how we architect autonomous systems. You need a clear boundary where deterministic policy and immutable state, driven by stream processing, tightly wrap and constrain the probabilistic reasoning of large language models.&lt;/p&gt;

&lt;p&gt;If you are building AI agents under strict regulatory, financial, or clinical compliance requirements, the path forward is concrete:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit your current agent stack against the four-dimension rubric&lt;/strong&gt; (agent runtime, governance, connectors, AI primitives). Identify which properties are missing today and document the regulatory exposure each gap creates.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick one regulated workflow for a Phase 1 pilot.&lt;/strong&gt; KYC review, claims triage, or DSAR handling are good candidates: narrow enough to ship, regulated enough to validate the audit chain.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stand up the Agent Decision Record schema first.&lt;/strong&gt; Even when the agent runs as decision-support only, the tamper-evident event log is the artifact auditors will examine. Get the schema, signing, and lineage right before adding autonomy.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a reconstruction drill before Phase 2.&lt;/strong&gt; Reconstruct a past decision from event history and versioned snapshots. If you can't, the architecture isn't ready for autonomous actuation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Confluent provides the streaming-native runtime to make these systems verifiably defensible, scalable, and secure. Explore the &lt;a href="https://www.confluent.io/confluent-cloud/kora/" rel="noopener noreferrer"&gt;Kora engine&lt;/a&gt;, &lt;a href="https://www.confluent.io/product/flink/" rel="noopener noreferrer"&gt;Confluent Cloud for Apache Flink&lt;/a&gt;, and &lt;a href="https://www.confluent.io/product/confluent-intelligence/" rel="noopener noreferrer"&gt;Confluent Intelligence&lt;/a&gt; when you're ready to design Phase 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What makes an AI agent "compliant" in regulated environments like the EU AI Act?
&lt;/h3&gt;

&lt;p&gt;A compliant agent produces a complete, tamper-evident audit trail of inputs, context, model configuration, decisions, and actions, plus the ability to reconstruct decisions later using the same evidence and versions. It must also enforce access controls, data minimization, and continuous risk monitoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why are stateless, chat-based agent frameworks hard to audit?
&lt;/h3&gt;

&lt;p&gt;They don't persist a deterministic decision history, so outputs can't be reconstructed exactly months later. They also rely on fragmented application logs and can trigger duplicate real-world side effects during retries.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is an Agent Decision Record?
&lt;/h3&gt;

&lt;p&gt;It's the structured, immutable event stream defined earlier in this guide. Every input, retrieval, prompt, tool call, policy check, human override, and final action is captured with reason codes and evidence references attached.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "stateful stream processing" mean for AI agents?
&lt;/h3&gt;

&lt;p&gt;The agent's workflow context (case status, obligations, evidence snapshots, consent, risk signals, and audit history) is stored durably and updated continuously as events arrive. Decisions are made against the accumulated state, not just the current prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you prevent an AI agent from executing an unsafe or non-compliant action?
&lt;/h3&gt;

&lt;p&gt;Put a deterministic stateful policy gate in front of side effects. The LLM can propose an action, but the gate approves or blocks it based on current case, consent, obligation, and risk state. The system-wide kill switch can disable autonomous actuation entirely while keeping intake and audit flowing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is "exactly-once" execution, and why does it matter for agents?
&lt;/h3&gt;

&lt;p&gt;Exactly-once guarantees that a side effect (e.g., payment, email, account change) happens one time, even if the system retries or crashes. This prevents duplicate transactions, which is an audit and financial risk common in stateless agent designs. Note that this guarantee applies to the stream-processing layer. Any external side effect, such as LLM API calls, requires separate idempotency handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can an organization replay an agent decision for an auditor?
&lt;/h3&gt;

&lt;p&gt;Store the full event history plus versioned snapshots of evidence and model configuration (including retrieval snapshot identifiers). Reloading the same input events and state snapshots reconstructs what the agent knew and what decision was logged, giving auditors a complete, verifiable record without running the LLM.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you handle PII and PHI safely when using LLMs in agent workflows?
&lt;/h3&gt;

&lt;p&gt;Encrypt sensitive fields before they leave source systems with CSFLE, enforce schema-based contracts, and restrict what context can be sent to the model. Maintain lineage so you can prove where sensitive data flowed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between the "system of control" and the "system of reasoning"?
&lt;/h3&gt;

&lt;p&gt;The system of control is a deterministic infrastructure (stream processing, policy, and state) that governs what can happen. The system of reasoning is the LLM, which generates probabilistic suggestions that must be validated and logged.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Apache Kafka and Apache Flink to build compliant AI agents?
&lt;/h3&gt;

&lt;p&gt;You need an immutable event log, durable state, deterministic policy enforcement, and verifiable reconstruction at scale. Kafka and Flink commonly implement those requirements, but the key is meeting the compliance properties, not using specific products.  &lt;/p&gt;

</description>
      <category>kafka</category>
      <category>ai</category>
      <category>agents</category>
      <category>security</category>
    </item>
    <item>
      <title>What is the best real-time analytics database in 2026? An engineering buyer's guide</title>
      <dc:creator>Manveer Chawla</dc:creator>
      <pubDate>Sun, 14 Jun 2026 03:26:07 +0000</pubDate>
      <link>https://dev.to/dataengineering/best-real-time-analytics-database-2026-guide-4l0j</link>
      <guid>https://dev.to/dataengineering/best-real-time-analytics-database-2026-guide-4l0j</guid>
      <description>&lt;p&gt;Traditional databases just can't keep up with high concurrency and low latency at the same time.&lt;/p&gt;

&lt;p&gt;The term "real-time" has become kind of meaningless. Everyone claims it, from batch-oriented cloud data warehouses to transactional database extensions. This makes picking the right architecture really hard without expensive trial and error.&lt;/p&gt;

&lt;p&gt;The best real-time analytics database in 2026 depends entirely on your workload shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Key takeaways&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-time analytics (in this guide)&lt;/strong&gt; = sub-second p95/p99 analytical queries on billions of rows, &lt;strong&gt;high concurrency&lt;/strong&gt;, and &lt;strong&gt;milliseconds-to-seconds freshness&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best overall in 2026 for most workloads:&lt;/strong&gt; &lt;strong&gt;ClickHouse&lt;/strong&gt; (ingest throughput, query speed at scale, compression/TCO).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for strictly predefined query paths via star-tree indexes:&lt;/strong&gt; &lt;strong&gt;Apache Pinot&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for time-series operational dashboards and observability:&lt;/strong&gt; &lt;strong&gt;ClickHouse&lt;/strong&gt;. &lt;strong&gt;ClickStack&lt;/strong&gt; is its full observability offering for logs, metrics, and traces.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for rigid ingestion-time roll-up aggregations:&lt;/strong&gt; &lt;strong&gt;Apache Druid&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for unified OLTP + real-time analytics:&lt;/strong&gt; &lt;strong&gt;ClickHouse&lt;/strong&gt; paired with its &lt;strong&gt;managed Postgres offering and native sync to ClickHouse&lt;/strong&gt;, giving you a purpose-built OLTP engine and a purpose-built OLAP engine without rolling your own CDC pipeline. &lt;strong&gt;SingleStore&lt;/strong&gt; is an alternative if you prefer a single HTAP engine for both.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traditional Data Warehouses:&lt;/strong&gt; Snowflake and BigQuery are fine for batch BI if you already have one, but face latency, concurrency, and cost challenges under sub-second, high-concurrency workloads.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluate using 4 axes:&lt;/strong&gt; ingest/freshness, latency under concurrency, TCO, operational complexity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What 'real-time analytics' means (and why warehouses and OLTP databases fail)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Strict engineering thresholds define &lt;a href="https://clickhouse.com/resources/engineering/what-is-olap" rel="noopener noreferrer"&gt;true real-time OLAP&lt;/a&gt;: sub-second query latency on complex aggregations, the ability to serve tens to thousands of concurrent queries per second (QPS), and data freshness measured in milliseconds to seconds.&lt;/p&gt;

&lt;p&gt;Traditional cloud data warehouses like Snowflake and BigQuery are fine for batch BI if you already have one, where minute-to-second latency is acceptable. They were not architected for sub-second, high-concurrency workloads, which is why many teams add a purpose-built real-time OLAP engine as a speed layer alongside their existing warehouse, or use it as a complete replacement and consolidation option.&lt;/p&gt;

&lt;p&gt;Snowflake's virtual warehouse model can introduce compute startup overhead and queueing that adds latency variability, which can challenge sub-second SLAs for always-on interactive workloads.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.cloud.google.com/bigquery/docs/query-queues" rel="noopener noreferrer"&gt;BigQuery's shared slot model&lt;/a&gt; can introduce slot queueing under high concurrency, adding latency variability that conflicts with sub-second SLA requirements.&lt;/p&gt;

&lt;p&gt;Exposing these warehouses to public-facing applications or frequent polling dashboards can drive costs up significantly due to &lt;a href="https://clickhouse.com/blog/how-cloud-data-warehouses-bill-you" rel="noopener noreferrer"&gt;compute-uptime pricing models that charge for always-on resources&lt;/a&gt;. At petabyte scale, purpose-built real-time engines can deliver &lt;a href="https://clickhouse.com/blog/cloud-data-warehouses-cost-performance-comparison" rel="noopener noreferrer"&gt;significantly better cost-performance than cloud warehouses&lt;/a&gt; due to superior compression, vectorized execution, and compute-storage separation.&lt;/p&gt;

&lt;p&gt;On the other end, PostgreSQL is an excellent OLTP database that works well for analytics at small scale. But extensions can't rewrite its core tuple-at-a-time execution engine, so scanning billions of rows with sub-second latency is beyond its architectural reach.&lt;/p&gt;

&lt;p&gt;Columnar storage and CPU vectorization, foundational to purpose-built OLAP engines, are not present in PostgreSQL's core. At scale, row-oriented storage and B-tree indexes create increasing overhead under analytical ingestion workloads. For teams outgrowing PostgreSQL's analytical capabilities, ClickHouse's PostgreSQL integrations provide an upgrade path.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Real-time OLAP evaluation criteria: the four axes that matter&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Ingest throughput and data freshness (Kafka/CDC)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A real-time database must ingest high-volume event streams from Kafka, Redpanda, or change data capture (CDC) pipelines without degrading read performance.&lt;/p&gt;

&lt;p&gt;Focus your evaluation on exactly-once semantics, non-blocking inserts, and whether the system makes data queryable within milliseconds or seconds of arrival. Engines using Log-Structured Merge (LSM) style architectures allow heavy ingestion to proceed without blocking read operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Query latency under concurrency (p95/p99, QPS)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Horizontal scaling alone can't maintain sub-second p95 and p99 latency when over a thousand external users simultaneously query a dashboard.&lt;/p&gt;

&lt;p&gt;The system needs architectural advantages like SIMD vectorized execution, pre-aggregation mechanisms, and intelligent data pruning to minimize query fanout and CPU cycles per row. Vectorized execution using SIMD instructions maximizes CPU throughput per query by processing data in batches of column values.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Total cost of ownership at scale (compression, compute-storage separation)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;As data volume grows from terabytes to petabytes, infrastructure costs scale dynamically based on storage layout.&lt;/p&gt;

&lt;p&gt;True &lt;a href="https://clickhouse.com/resources/engineering/database-compression" rel="noopener noreferrer"&gt;columnar compression&lt;/a&gt; deeply impacts TCO. Systems offering configurable compression codecs and compute-storage separation let teams scale compute independently of storage. Storing a petabyte of raw data in a highly compressed columnar format often reduces the footprint to a fraction of its original size, but the primary cost saving comes from improved performance. Scanning significantly less data translates faster I/O directly into cheaper compute, dramatically lowering overall costs for high-cardinality data compared to uncompressed row stores.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Operational complexity and reference architecture&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The modern real-time reference architecture has shifted away from batch loading. The standard pipeline now flows from a streaming source into a real-time OLAP engine, through materialized views, and out to a serving API or dashboard.&lt;/p&gt;

&lt;p&gt;You'll need to evaluate the burden of cluster management, metadata handling, node types, and schema evolution. Systems requiring external coordination services, independent metadata databases, and multiple dedicated node types carry operational overhead. ClickHouse Keeper replaces ZooKeeper for self-managed ClickHouse deployments, while ClickHouse Cloud and other managed serverless runtimes abstract away cluster coordination and infrastructure maintenance entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Top real-time analytics databases in 2026 (ClickHouse, Pinot, Druid, SingleStore)&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;ClickHouse for real-time analytics&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;ClickHouse strengths for real-time OLAP&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;ClickHouse provides the broadest workload coverage, highest raw ingest throughput, and the strongest price-performance because of unmatched columnar compression.&lt;/p&gt;

&lt;p&gt;Recent engine advancements have addressed historical criticisms. ClickHouse now provides robust JOIN support for standard analytical patterns and star schemas. Recent investments in the query planner, including automatic global join reordering and memory-optimized execution strategies, drastically reduce memory usage and execution time without requiring explicit algorithm tuning.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://clickhouse.com/blog/json-data-type-gets-even-better" rel="noopener noreferrer"&gt;native JSON type&lt;/a&gt; enables &lt;a href="https://clickhouse.com/blog/json-bench-clickhouse-vs-mongodb-elasticsearch-duckdb-postgresql" rel="noopener noreferrer"&gt;sub-100ms queries&lt;/a&gt; on semi-structured data by splitting JSON objects into independently compressed sub-columns. Going beyond the fundamentals, it features automatic type inference and seamlessly handles arbitrarily deep, unlimited dynamic fields without schema changes. Query performance on the native JSON type is comparable to explicitly typed columns and significantly faster than string-based JSON parsing approaches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://clickhouse.com/blog/clickhouse-cloud-boosts-performance-with-sharedmergetree-and-lightweight-updates" rel="noopener noreferrer"&gt;Lightweight updates and deletes&lt;/a&gt; use a patch-parts mechanism: changes are applied immediately at query time via small delta parts and materialized asynchronously during the standard background merge process, establishing them as the primary, standard method for typical use cases that &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-3-benchmarks" rel="noopener noreferrer"&gt;outperform standard ALTER TABLE mutations&lt;/a&gt;. Standard mutations are reserved for specific, large-scale, partition-aligned operations. Separately, ReplacingMergeTree provides current-state deduplication by key, well suited for CDC and upsert workloads.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;ClickHouse trade-offs and limitations&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Maximizing performance requires a solid understanding of specific table engines, materialized view mechanics, and sorting keys. ClickHouse favors explicit architectural control over magic black-box optimizations.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;ClickHouse architecture and deployment options&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;ClickHouse runs as an efficient single binary, which means it is easier for Ops to run in production and easier for Devs to spin up for local development and testing. It also provides unmatched deployment versatility, running seamlessly in-memory, via CLI, on a single-server, or fully distributed. Self-managed deployments use MergeTree with workload scheduling and resource management for workload isolation. ClickHouse Cloud uses SharedMergeTree with separated storage and compute, plus dedicated read-write and read-only compute services for auto-scaling without replicated write overhead. For observability use cases, &lt;a href="https://clickhouse.com/use-cases/observability" rel="noopener noreferrer"&gt;ClickStack&lt;/a&gt; is ClickHouse's full observability stack covering logs, metrics, and traces.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Apache Pinot for ultra-low-latency user-facing analytics&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Pinot strengths (Kafka ingestion, star-tree indexes)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Apache Pinot delivers elite optimization for ultra-low-latency query performance and heavy Kafka-first event ingestion. Its native pull-based Kafka consumer reads micro-batches to make events queryable within milliseconds, offering exactly-once semantics.&lt;/p&gt;

&lt;p&gt;Pinot's defining feature is the &lt;a href="https://startree.ai/resources/a-tale-of-three-real-time-olap-databases" rel="noopener noreferrer"&gt;star-tree index&lt;/a&gt;. It's an intelligent, tunable materialized view that pre-aggregates user-defined dimensions while leaving raw data queryable, driving query times down by orders of magnitude.&lt;/p&gt;

&lt;p&gt;The multi-stage V2 query engine supports robust distributed joins, including broadcast, lookup, and shuffle distributed hash joins, scaling complex join throughput to hundreds of queries per second.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Pinot trade-offs (operational complexity, upserts)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Pinot introduces significant operational complexity. You're managing controllers, brokers, servers, minions, ZooKeeper, and a deep store.&lt;/p&gt;

&lt;p&gt;Full-row upserts require a heavy in-memory primary key map, adding substantial memory overhead in self-managed open-source deployments.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Pinot architecture (brokers, servers, controllers, deep store)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;A complex, heavily distributed architecture optimized for multi-tenancy and predictable low latency. StarTree provides the primary managed cloud offering and notably offloads the in-memory upsert requirement to disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Apache Druid for time-series dashboards and rollups&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Druid strengths (streaming ingestion, rollups)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Apache Druid is heavily optimized for time-series aggregation, high-ingest log data, and operational dashboards.&lt;/p&gt;

&lt;p&gt;Native Kafka and Kinesis streaming ingestion is a core strength. Data processes through supervisor specifications and becomes visible within seconds. Druid achieves guaranteed sub-second query latency by relying heavily on ingestion-time rollups, drastically reducing the data volume scanned during routine, predictable dashboard queries.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Druid trade-offs (ad-hoc queries, high-cardinality data)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Druid struggles with ad-hoc queries over raw, non-aggregated, high-cardinality data because its engine heavily depends on its pre-aggregated segment format.&lt;/p&gt;

&lt;p&gt;Druid also demands a large operational footprint. You're looking at overlord, coordinator, broker, historical, and MiddleManager nodes, alongside a separate relational metadata database and ZooKeeper.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Druid architecture (segments, coordinators, historical nodes)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;A segment-based distributed architecture requiring strict data roll-up modeling. Imply Polaris serves as the primary managed cloud option.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;SingleStore for HTAP (OLTP + real-time analytics)&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;SingleStore strengths for HTAP workloads&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;SingleStore excels at hybrid HTAP (Hybrid Transactional/Analytical Processing) capabilities. It allows simultaneous transactional writes and analytical reads within a single unified engine.&lt;/p&gt;

&lt;p&gt;The architecture uses a memory-optimized rowstore for active operational data and a disk-based columnstore for historical analytical data, managed by a powerful query optimizer with mature automatic join reordering capabilities.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;SingleStore trade-offs (memory footprint, cost for pure OLAP)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Supporting true OLTP-grade latency requires maintaining active data in memory. This significantly increases the infrastructure footprint and compute cost for pure analytical workloads compared to purpose-built, disk-optimized OLAP engines.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;SingleStore architecture (rowstore vs columnstore)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;A distributed SQL database blending row and columnar storage formats. &lt;a href="https://www.singlestore.com/product-overview" rel="noopener noreferrer"&gt;SingleStore Helios&lt;/a&gt; provides the managed cloud database-as-a-service option.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Real-time OLAP comparison: database features, performance, and cost&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;ClickHouse&lt;/th&gt;
&lt;th&gt;Apache Pinot&lt;/th&gt;
&lt;th&gt;Apache Druid&lt;/th&gt;
&lt;th&gt;SingleStore&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Core architecture&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Columnar (MergeTree family)&lt;/td&gt;
&lt;td&gt;Columnar (Segment-based)&lt;/td&gt;
&lt;td&gt;Columnar (Immutable segments)&lt;/td&gt;
&lt;td&gt;Hybrid HTAP (Row + Columnar)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ingest freshness SLA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Seconds to near-real-time&lt;/td&gt;
&lt;td&gt;Milliseconds (Kafka-native pull)&lt;/td&gt;
&lt;td&gt;Seconds (Streaming supervisor)&lt;/td&gt;
&lt;td&gt;Real-time (Transactional inserts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Concurrency limit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hundreds to 1,000s+ QPS&lt;/td&gt;
&lt;td&gt;1,000s+ QPS (via Star-Tree)&lt;/td&gt;
&lt;td&gt;Hundreds of QPS (via Rollups)&lt;/td&gt;
&lt;td&gt;Hundreds to 1,000s QPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Join performance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Grace Hash, Parallel Hash, Auto-reorder&lt;/td&gt;
&lt;td&gt;Broadcast, Lookup, Shuffle Dist. Hash&lt;/td&gt;
&lt;td&gt;Limited; pre-joined models preferred&lt;/td&gt;
&lt;td&gt;Full SQL joins, mature auto-reordering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mutable data handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lightweight Updates, ReplacingMergeTree&lt;/td&gt;
&lt;td&gt;Full/partial upserts; Primary Key map&lt;/td&gt;
&lt;td&gt;Append-mostly; no native upserts&lt;/td&gt;
&lt;td&gt;Full ACID transactions (UPDATE/DELETE)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Managed cloud options&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ClickHouse Cloud&lt;/td&gt;
&lt;td&gt;StarTree Cloud&lt;/td&gt;
&lt;td&gt;Imply Polaris&lt;/td&gt;
&lt;td&gt;SingleStore Helios&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four engines support analytical workloads, but compression ratios and execution speed heavily influence total cost of ownership.&lt;/p&gt;

&lt;p&gt;ClickHouse consistently achieves &lt;a href="https://clickhouse.com/resources/engineering/database-compression" rel="noopener noreferrer"&gt;10-20x compression&lt;/a&gt; over row stores because its fundamental columnar architecture groups similar data together. This layout makes configurable compression codecs, like LZ4 for hot query paths and ZSTD for cold storage, highly effective. This extreme compression, paired with hardware-optimized SIMD vectorized execution, allows ClickHouse to scan billions of rows with minimal compute resources.&lt;/p&gt;

&lt;p&gt;Pinot and Druid achieve low latency primarily through aggressive data pruning, segment indexing, and ingestion-time pre-aggregation rather than raw vectorized scan speed.&lt;/p&gt;

&lt;p&gt;SingleStore requires splitting memory between its rowstore and columnstore, meaning its pure analytical compression ratios can't match dedicated OLAP engines.&lt;/p&gt;

&lt;p&gt;When evaluating these engines, it is a mistake to be overly reliant on vendor benchmarks, which are often closed and lack methodology. Instead, prioritize benchmarks that are open-source, reproducible, and industry-recognized. Open suites like &lt;a href="https://benchmark.clickhouse.com/" rel="noopener noreferrer"&gt;ClickBench&lt;/a&gt; (maintained by ClickHouse and independently reproducible) and TPC-H provide verifiable data points for comparing sub-second latency and hardware efficiency across engines.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Which real-time analytics database should you choose? A workload-based decision tree&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; you're processing massive log, event, or telemetry ingestion at petabyte scale and need versatile, general-purpose ad-hoc analytics with the lowest infrastructure cost, &lt;strong&gt;choose ClickHouse&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; you're building public-facing, ultra-low-latency applications with high concurrency, &lt;strong&gt;choose ClickHouse&lt;/strong&gt;, which handles both ad-hoc queries and predefined paths efficiently. Apache Pinot is a specialized alternative if you strictly need to serve pre-defined query paths via star-tree indexes.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; your primary focus is operational time-series monitoring, network telemetry, or dashboards, &lt;strong&gt;choose ClickHouse&lt;/strong&gt;, which handles massive telemetry ingestion while supporting both raw ad-hoc queries and aggregations. Apache Druid is an alternative if your workload perfectly aligns with rigid ingestion-time roll-up aggregations.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; you must unify high-throughput operational transactions (writes) and real-time analytics (reads) without building a custom CDC pipeline, &lt;strong&gt;choose ClickHouse with its managed Postgres offering and native sync to ClickHouse&lt;/strong&gt;, which pairs a purpose-built OLTP engine (Postgres) with a purpose-built OLAP engine (ClickHouse). &lt;strong&gt;SingleStore&lt;/strong&gt; is an alternative if you prefer a single HTAP engine for both.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If&lt;/strong&gt; you want to expose fast data APIs directly to frontend developers without managing database infrastructure, query optimization, or cluster scaling, &lt;strong&gt;choose a managed runtime like ClickHouse Cloud or StarTree (on Pinot)&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Conclusion: choosing the best real-time analytics database for your workload&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Pinpoint your absolute primary constraint, whether that's ingest throughput, concurrency limits, or total cost of ownership, before committing to an architecture.&lt;/p&gt;

&lt;p&gt;For the vast majority of real-time analytical workloads, ClickHouse offers the most versatile, high-performance foundation. Widely evaluated as the fastest analytics database for raw throughput and query execution at scale, it delivers unmatched query speed and storage compression.&lt;/p&gt;

&lt;p&gt;If you're evaluating real-time OLAP and want to eliminate the operational overhead of cluster management, spin up a &lt;a href="https://clickhouse.cloud/signup" rel="noopener noreferrer"&gt;ClickHouse Cloud free trial&lt;/a&gt;, load as much of your own data as possible, run an evaluation at a realistic scale, and compare against your existing system. StarTree (on Pinot) is another managed runtime option for teams that do not want to operate clusters.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Real-time analytics database FAQs&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What does "real-time analytics" mean in this guide?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Sub-second p95/p99 query latency under high concurrency, with data freshness measured in milliseconds to seconds. Not minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Which real-time analytics database should I choose in 2026?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Choose based on workload: ClickHouse for general-purpose real-time OLAP, best price-performance, user-facing apps, and time-series operational dashboards/observability. For unified OLTP + real-time analytics, pair ClickHouse with its managed Postgres offering and native sync to ClickHouse, which gives you both engines without a custom CDC pipeline. Apache Pinot is a specialized alternative if you strictly need predefined query paths via star-tree indexes. Apache Druid suits workloads aligned to rigid ingestion-time roll-up aggregations. SingleStore is an alternative for HTAP teams preferring a single engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can Snowflake or BigQuery support real-time dashboards?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;They can support &lt;em&gt;near-real-time BI&lt;/em&gt;, but they're typically a poor fit for &lt;strong&gt;sub-second, high-concurrency&lt;/strong&gt; user-facing analytics because of latency variability and cost under frequent polling.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Do I need a streaming system (Kafka/Redpanda) to do real-time analytics?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Often yes for event ingestion and freshness. But the database still needs to serve fast ad-hoc queries. Streaming systems and real-time OLAP engines are complementary.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How should I benchmark real-time analytics databases?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Use reproducible, open benchmarks (e.g., ClickBench and TPC-H where applicable) and measure p95/p99 latency under concurrency, ingest freshness, and cost at your target data volume. Ensure you test beyond your own expected volume to account for bursts and future growth.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What's the biggest operational difference between ClickHouse, Pinot, and Druid?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;ClickHouse can run as a simpler single-binary cluster (or managed cloud), while Pinot and Druid typically require more moving parts (multiple node roles plus ZooKeeper and external metadata/deep storage), increasing operational overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do these databases handle updates, deletes, and CDC?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Support for mutable data varies widely. ClickHouse natively supports standard SQL UPDATE and DELETE operations via lightweight patch parts and background deduplication for high-volume CDC, whereas systems like Druid remain primarily append-only. HTAP systems like SingleStore support full transactional UPDATE/DELETE semantics.&lt;/p&gt;

</description>
      <category>database</category>
      <category>analytics</category>
      <category>dataengineering</category>
      <category>data</category>
    </item>
    <item>
      <title>Can ClickHouse DELETE Data? A 2026 PR-by-PR Analysis</title>
      <dc:creator>Manveer Chawla</dc:creator>
      <pubDate>Tue, 19 May 2026 21:33:41 +0000</pubDate>
      <link>https://dev.to/dataengineering/can-clickhouse-delete-data-a-2026-pr-by-pr-analysis-58in</link>
      <guid>https://dev.to/dataengineering/can-clickhouse-delete-data-a-2026-pr-by-pr-analysis-58in</guid>
      <description>&lt;h2&gt;
  
  
  &lt;strong&gt;TL;DR&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ClickHouse has supported DELETE operations since 2018. As of 2026, it ships four production-grade deletion paths: heavyweight &lt;code&gt;ALTER TABLE DELETE&lt;/code&gt;, lightweight &lt;code&gt;DELETE FROM&lt;/code&gt; (default since v23.3), patch-part DELETEs (v25.7), and &lt;code&gt;ALTER TABLE DROP PARTITION&lt;/code&gt; for bulk operations. The "ClickHouse is immutable / append-only" narrative is outdated by eight years and 80+ merged PRs spanning five architectural eras, and the evidence is in the commit history.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We analyzed 80+ GitHub pull requests, official ClickHouse changelogs, and release blogs to trace the full evolution of DELETE support from 2018 through early 2026.
&lt;/li&gt;
&lt;li&gt;In 2018, ClickHouse shipped &lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt; as a heavyweight asynchronous mutation that rewrote affected data parts. The criticism that "deletes require heavy mutations" was fair — for that era. It was also the &lt;em&gt;only&lt;/em&gt; delete path for four years.
&lt;/li&gt;
&lt;li&gt;By early 2026, ClickHouse ships standard SQL &lt;code&gt;DELETE FROM&lt;/code&gt; (lightweight by default since v23.3), &lt;code&gt;ALTER TABLE DELETE&lt;/code&gt; for guaranteed physical removal, &lt;code&gt;ALTER TABLE DROP PARTITION&lt;/code&gt; for bulk deletion, patch-part-based lightweight updates and deletes, on-the-fly mutation visibility at SELECT time, and engine-level deletion patterns through &lt;code&gt;ReplacingMergeTree(version, is_deleted)&lt;/code&gt; with optimized &lt;code&gt;FINAL&lt;/code&gt;. None of these require experimental flags.
&lt;/li&gt;
&lt;li&gt;The single highest-impact change is the lightweight DELETE introduction (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt;), which redefined DELETE on MergeTree from "rewrite all affected parts" to "rewrite only &lt;code&gt;_row_exists&lt;/code&gt;, hardlink the rest, filter on read." Benchmarks in the PR show 15 single-row deletes on a 100M-row, 12-column table dropping from ~8 seconds to ~200 ms — roughly 40× faster on the initial mask write.
&lt;/li&gt;
&lt;li&gt;Patch parts (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;), shipped in v25.7, eliminated the part rewrite entirely. A DELETE becomes a tiny insert that sets &lt;code&gt;_row_exists = 0&lt;/code&gt;, applied on read until a background merge consolidates it. ClickHouse's own benchmark blog series claims up to ~1,000× speedup for small/selective changes versus classic mutations (vendor benchmark — treat as an upper bound, not a guarantee).
&lt;/li&gt;
&lt;li&gt;On-the-fly mutations (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;) and on-the-fly LWD (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79281" rel="noopener noreferrer"&gt;PR #79281&lt;/a&gt;) eliminated the "DELETE was issued but rows still appear" surprise. Queued deletes that haven't materialized are now applied at SELECT time.
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; setting hasn't been needed since 23.3. It was aliased to &lt;code&gt;enable_lightweight_delete&lt;/code&gt; in &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/50044" rel="noopener noreferrer"&gt;PR #50044&lt;/a&gt; (commit &lt;code&gt;7189481&lt;/code&gt;, June 2023) for backward compatibility, then promoted to default-enabled.
&lt;/li&gt;
&lt;li&gt;Verdict: the "ClickHouse is immutable" advice made sense in 2017. Repeating it in 2026 is misinformation. ClickHouse offers four production-grade deletion paths covering compliance, bulk, selective, and high-frequency operational workloads, each with explicit trade-offs and observability.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Why People Still Say "ClickHouse Can't Delete"&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you've evaluated ClickHouse in the last few years, you've heard the warnings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"ClickHouse can't delete data"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"ClickHouse is immutable / append-only"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Deletes require heavy mutations"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"ReplacingMergeTree can't handle deletes"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"You need &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt;"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"&lt;code&gt;FINAL&lt;/code&gt; is too slow for production queries"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of these started as legitimate ClickHouse guidance in the 2018–2020 era. The original &lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt; was deliberately syntactically heavyweight: ClickHouse was founded on the principle that "logs are immutable," and the ALTER syntax (rather than &lt;code&gt;DELETE FROM&lt;/code&gt;) was an explicit signal that this was an administrative operation, not OLTP-style row modification. The cost model was honest: for a 100-column table, deleting a single row required reading and rewriting all 100 column files for the affected part.&lt;/p&gt;

&lt;p&gt;In 2018, the criticism was largely fair. There was one delete path (&lt;code&gt;ALTER TABLE DELETE&lt;/code&gt;), it was a full part rewrite, it was asynchronous, and you tracked it through &lt;code&gt;system.mutations&lt;/code&gt;. If you needed selective row-level deletion at scale, you were going to feel it.&lt;/p&gt;

&lt;p&gt;Then ClickHouse's engineering team spent eight years dismantling every one of those limitations. Over 80 significant pull requests merged. They added &lt;code&gt;DELETE FROM&lt;/code&gt; syntax with a hidden-mask implementation (PR #37893), promoted it to GA (v23.3), made it synchronous by default (PR #44718), added &lt;code&gt;IN PARTITION&lt;/code&gt; scoping (PR #67805), made it observable (&lt;code&gt;apply_deleted_mask&lt;/code&gt;, &lt;code&gt;has_lightweight_delete&lt;/code&gt;, &lt;code&gt;parts_postpone_reasons&lt;/code&gt;), made it correct in the presence of projections and skip indexes (PRs #52517, #52530, #62364, #65594), and finally re-implemented it as a tiny patch-part write (PR #82004) so the part rewrite is gone entirely.&lt;/p&gt;

&lt;p&gt;This article traces that evolution with PR-level evidence. No marketing claims. No benchmarks on toy datasets. Just the commit history.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Methodology: How We Analyzed ClickHouse's DELETE Commit History&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;We went through ClickHouse's GitHub commit history, pull requests, changelogs, and release blogs from 2018 through early 2026. The scope covered every PR that touched the DELETE subsystem: mutation engine changes, the lightweight-delete read path, patch parts, projection and skip-index correctness, replication and &lt;code&gt;ON CLUSTER&lt;/code&gt; propagation, observability, and default configuration changes.&lt;/p&gt;

&lt;p&gt;Each PR was classified by category (mutation engine, read-path filtering, storage interaction, correctness, settings, observability), impact severity, and whether it changed default behavior. We cross-referenced PR descriptions against changelog entries and release blog benchmarks to verify the claimed improvements. Where multiple PRs addressed the same subsystem (for example, the long tail of LWD-vs-projection bugs), we traced the dependency chain to understand how the incremental fixes compounded.&lt;/p&gt;

&lt;p&gt;The result is a ranked set of PRs by impact, organized into five chronological eras, with full provenance. Every claim in this article maps to a specific merged PR or commit SHA that you can verify yourself on GitHub. Where two reputable sources cite different SHAs for the same PR (a known issue with squash-merges in older PR threads), we surface the conflict rather than picking one.&lt;/p&gt;

&lt;p&gt;This isn't a benchmarking exercise. Benchmarks measure peak performance on controlled workloads. This analysis measures the engineering trajectory: what was built, why, and what it means for teams deciding whether ClickHouse can handle their delete patterns today.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse DELETE Features in 2026: What Ships by Default&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The current state, as of early 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard SQL &lt;code&gt;DELETE FROM&lt;/code&gt;:&lt;/strong&gt; Lightweight by default since v23.3. Hidden &lt;code&gt;_row_exists&lt;/code&gt; mask column, PREWHERE-injected at read time, hardlinks unaffected column files in wide parts. Synchronous by default with explicit &lt;code&gt;lightweight_deletes_sync&lt;/code&gt; control.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt; (heavyweight mutation):&lt;/strong&gt; Retained for use cases that require guaranteed physical removal at completion (compliance, GDPR right-to-erasure, audit-bound workloads). Tracked in &lt;code&gt;system.mutations&lt;/code&gt;, cancellable via &lt;code&gt;KILL MUTATION&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ALTER TABLE DELETE … IN PARTITION&lt;/code&gt;:&lt;/strong&gt; Both for heavyweight mutations (PR #13403, 2020) and lightweight DELETE (PR #67805, 2024). Prunes partitions before the delete plan even starts.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ALTER TABLE DROP PARTITION&lt;/code&gt;:&lt;/strong&gt; Bulk deletion via durable empty parts (PR #41145), atomic and non-blocking for concurrent reads. The most efficient path for time-bounded data lifecycle operations.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch-part lightweight updates and DELETEs:&lt;/strong&gt; Available since v25.7 via &lt;code&gt;lightweight_delete_mode = 'lightweight_update'&lt;/code&gt;. A DELETE becomes a tiny insert of a patch part instead of a part rewrite.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-the-fly mutation visibility:&lt;/strong&gt; &lt;code&gt;apply_mutations_on_fly = 1&lt;/code&gt; makes queued deletes visible at SELECT time before background materialization completes.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReplacingMergeTree(version, is_deleted)&lt;/code&gt;:&lt;/strong&gt; Engine-native upsert and tombstone semantics with &lt;code&gt;OPTIMIZE TABLE … FINAL CLEANUP&lt;/code&gt; for forced physical removal. Combined with the optimized &lt;code&gt;FINAL&lt;/code&gt; keyword for immediate consistency at query time.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit physical-removal control:&lt;/strong&gt; &lt;code&gt;ALTER TABLE … APPLY DELETED MASK&lt;/code&gt; (PR #57433) forces materialization without waiting for background merges. &lt;code&gt;min_age_to_force_merge_seconds&lt;/code&gt; and &lt;code&gt;exclude_deleted_rows_for_part_size_in_merge&lt;/code&gt; give the merge selector the right inputs.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability:&lt;/strong&gt; &lt;code&gt;system.parts.has_lightweight_delete&lt;/code&gt;, &lt;code&gt;removal_state&lt;/code&gt;, &lt;code&gt;last_removal_attempt_time&lt;/code&gt;, and &lt;code&gt;system.mutations.parts_postpone_reasons&lt;/code&gt; give operators machine-readable state for every delete in flight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These aren't experimental features hidden behind flags. They're defaults that ship with every ClickHouse installation.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse DELETE Myths vs. Reality: A 2026 Checklist&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;The FUD&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Evidence Volume&lt;/th&gt;
&lt;th&gt;Reality (2026)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;"ClickHouse can't delete data"&lt;/td&gt;
&lt;td&gt;🟢 False since 2018&lt;/td&gt;
&lt;td&gt;80+ PRs across 5 eras&lt;/td&gt;
&lt;td&gt;Four production-grade delete paths: heavyweight mutation, lightweight DELETE, patch-part DELETE, partition-scoped DELETE.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;"ClickHouse is immutable / append-only"&lt;/td&gt;
&lt;td&gt;🟢 Outdated&lt;/td&gt;
&lt;td&gt;Mutations since 2018 (release &lt;code&gt;1.1.54388&lt;/code&gt;); LWD since 22.8&lt;/td&gt;
&lt;td&gt;Standard SQL DELETE FROM has been default-enabled since v23.3 (April 2023).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;"Deletes require heavy mutations"&lt;/td&gt;
&lt;td&gt;🟢 False since 22.8&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;#82004&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;LWD is ~40× faster than heavy mutations on the initial mask write. Patch-part DELETEs target up to ~1,000× speedup for small/selective changes per ClickHouse benchmarks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;"ReplacingMergeTree can't handle deletes"&lt;/td&gt;
&lt;td&gt;🟢 False&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;is_deleted&lt;/code&gt; column parameter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ReplacingMergeTree(version, is_deleted)&lt;/code&gt; natively supports tombstones. &lt;code&gt;OPTIMIZE TABLE … FINAL CLEANUP&lt;/code&gt; forces physical removal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;"You need &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt;"&lt;/td&gt;
&lt;td&gt;🟢 Obsolete&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/50044" rel="noopener noreferrer"&gt;PR #50044&lt;/a&gt; (commit &lt;code&gt;7189481&lt;/code&gt;, June 2023)&lt;/td&gt;
&lt;td&gt;The setting was renamed to &lt;code&gt;enable_lightweight_delete&lt;/code&gt; and default-enabled in v23.3. The old name remains as a backward-compatibility alias.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;"&lt;code&gt;FINAL&lt;/code&gt; is too slow for production queries"&lt;/td&gt;
&lt;td&gt;🟡 Outdated&lt;/td&gt;
&lt;td&gt;Multiple optimization PRs through v25.x&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FINAL&lt;/code&gt; was significantly optimized for production. It's the recommended path for immediate consistency on &lt;code&gt;ReplacingMergeTree&lt;/code&gt; regardless of background merge state.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;"DELETE crashes the mutation queue"&lt;/td&gt;
&lt;td&gt;🟢 False since 2023&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/48522" rel="noopener noreferrer"&gt;PR #48522&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/44718" rel="noopener noreferrer"&gt;#44718&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Memory usage reduced for large mutation queues. LWD synchronous by default to bound queue growth.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;"Deleted rows linger forever in storage"&lt;/td&gt;
&lt;td&gt;🟢 False&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58223" rel="noopener noreferrer"&gt;PR #58223&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57433" rel="noopener noreferrer"&gt;#57433&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Merge selector counts existing rows, not physical rows. &lt;code&gt;APPLY DELETED MASK&lt;/code&gt; forces immediate physical cleanup on demand.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;"Can't delete in a specific partition without scanning everything"&lt;/td&gt;
&lt;td&gt;🟢 False since 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/67805" rel="noopener noreferrer"&gt;PR #67805&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/13403" rel="noopener noreferrer"&gt;#13403&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DELETE FROM … IN PARTITION&lt;/code&gt; and &lt;code&gt;ALTER … DELETE … IN PARTITION&lt;/code&gt; prune partitions at plan time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;"DELETEs are invisible until merges finish"&lt;/td&gt;
&lt;td&gt;🟢 False since 2025&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79281" rel="noopener noreferrer"&gt;#79281&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apply_mutations_on_fly&lt;/code&gt; makes queued deletes visible at SELECT time. LWDs apply on the fly via the same mechanism.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;"DELETE breaks projections and skip indexes"&lt;/td&gt;
&lt;td&gt;🟢 False since 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/52517" rel="noopener noreferrer"&gt;PR #52517&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/52530" rel="noopener noreferrer"&gt;#52530&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62364" rel="noopener noreferrer"&gt;#62364&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/65594" rel="noopener noreferrer"&gt;#65594&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Skip indexes and projections recalculate correctly during delete-driven merges. &lt;code&gt;lightweight_mutation_projection_mode&lt;/code&gt; gives explicit policy options.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;"No way to observe in-flight deletes"&lt;/td&gt;
&lt;td&gt;🟢 False&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;system.mutations&lt;/code&gt;, &lt;code&gt;system.parts.has_lightweight_delete&lt;/code&gt;, &lt;code&gt;parts_postpone_reasons&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Full lifecycle observability: queue state, postpone reasons, masked-part flagging, removal attempt timing.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 1 (2018): The Original Mutation-Based DELETE&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"ClickHouse can't delete data"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This one was true for the first few years of ClickHouse's life. ClickHouse was designed around the principle that analytical data, once written, should not be modified. Compression and read throughput took priority over update flexibility. The first delete capability landed in mid-2018 as a deliberate compromise: support deletion, but signal architecturally that this was a heavyweight administrative operation.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt; Lands as a Mutation (June–July 2018)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;ClickHouse release &lt;code&gt;1.1.54388&lt;/code&gt; (2018-06-28) added replicated &lt;code&gt;ALTER TABLE t DELETE WHERE&lt;/code&gt; support together with the &lt;code&gt;system.mutations&lt;/code&gt; table. Release 18.1.0 (2018-07-23) extended this to non-replicated MergeTree via &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/2634" rel="noopener noreferrer"&gt;PR #2634&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The mechanism was straightforward and expensive. When &lt;code&gt;ALTER TABLE DELETE&lt;/code&gt; was issued, the server recorded the mutation with a unique ID (&lt;code&gt;mutation_1.txt&lt;/code&gt;), returned immediately, and a background process scanned for parts containing rows matching the filter. For each affected part, a new version was created by reading the original data, applying the filter in memory, and writing only the surviving rows into a new part directory. The old part remained active until the new part was fully written and verified.&lt;/p&gt;

&lt;p&gt;The write amplification was severe. For a 100-column table, deleting a single row required reading and rewriting all 100 column files for the affected part. The cost scaled with column count, not with the number of deleted rows. This is the math behind the "deletes are expensive" guidance from this era — and it was true.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Skip Unaffected Parts in DELETE Mutations (PR #2694, Late 2018)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/2694" rel="noopener noreferrer"&gt;PR #2694&lt;/a&gt; added the first explicit rewrite-amplification optimization for DELETE: &lt;code&gt;ALTER TABLE t DELETE WHERE&lt;/code&gt; no longer rewrote data parts that the predicate didn't touch. Before this PR, the mutation engine was conservative; after it, parts with no matching rows were skipped entirely. This established the pattern that would define the next eight years of DELETE evolution: do less work, lazily, and only on parts that actually need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;DELETE Correctness Fixes (2020–2021)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The mutation-based DELETE generated a steady stream of correctness fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/9048" rel="noopener noreferrer"&gt;PR #9048&lt;/a&gt; (alesapin, 2020) — fixed &lt;code&gt;primary.idx&lt;/code&gt; corruption after a delete mutation, the most severe class of DELETE bug.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/12153" rel="noopener noreferrer"&gt;PR #12153&lt;/a&gt; (alexey-milovidov, 2020) — fixed over-deletion when the predicate evaluated to NULL on a row.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/21477" rel="noopener noreferrer"&gt;PR #21477&lt;/a&gt; (alesapin, 2021) — fixed a deadlock for non-replicated MergeTree when &lt;code&gt;ALTER DELETE WHERE&lt;/code&gt; referenced the same table.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/13403" rel="noopener noreferrer"&gt;PR #13403&lt;/a&gt; (Vladimir Chebotarev, 2020) — added &lt;code&gt;ALTER TABLE … DELETE … IN PARTITION&lt;/code&gt; for partition pruning, addressing metadata and ZooKeeper bloat in tables with thousands of partitions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end of this era, ClickHouse had a working DELETE. It was honest about the cost. The "ClickHouse is immutable" critique was already inaccurate by 2018, but it was understandable.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 2 (2022): How Lightweight DELETE Reframed Everything&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"Deletes require heavy mutations"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt;, authored by Jianmei Zhang (&lt;code&gt;zhangjmruc&lt;/code&gt;) and reviewed by &lt;code&gt;davenger&lt;/code&gt; and &lt;code&gt;alesapin&lt;/code&gt;, is the single highest-impact change in the entire DELETE history. Merged into v22.8 in mid-2022, it introduced standard SQL &lt;code&gt;DELETE FROM &amp;lt;table&amp;gt; WHERE …&lt;/code&gt; and re-implemented it as a special mutation: &lt;code&gt;ALTER TABLE &amp;lt;table&amp;gt; UPDATE _row_exists = 0 WHERE …&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The architectural shift was complete. DELETE went from "rewrite all affected parts" to "rewrite only the &lt;code&gt;_row_exists&lt;/code&gt; mask, hardlink the rest, filter on read."&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The &lt;code&gt;_row_exists&lt;/code&gt; Mask Column&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Each MergeTree part gained a virtual system column called &lt;code&gt;_row_exists&lt;/code&gt;. When a row was deleted, its bit in this column was flipped from 1 to 0. The data itself remained on disk — only the mask was updated.&lt;/p&gt;

&lt;p&gt;For wide-format MergeTree parts (the default for parts above a threshold size), where each column is stored in its own &lt;code&gt;.bin&lt;/code&gt; and &lt;code&gt;.mrk&lt;/code&gt; files, the optimization is dramatic. ClickHouse only writes a new &lt;code&gt;_row_exists.bin&lt;/code&gt;; all other column files are hardlinked from the old part to the new one. For compact-format parts, where all columns are interleaved in one file, the gain is smaller because the single file still has to be rewritten.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;PREWHERE Injection on Read&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Reading from a table with deleted rows is where the design pays off. A query like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is internally transformed into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;PREWHERE&lt;/span&gt; &lt;span class="n"&gt;_row_exists&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PREWHERE runs before the main column reads. If &lt;code&gt;_row_exists&lt;/code&gt; indicates an entire granule is deleted, ClickHouse skips reading any other column data for that granule. The mask is tiny (one bit per row, highly compressible), so the read overhead is negligible compared to the savings on filtered-out granules.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Hardlink Optimization for Wide Parts&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The PR description includes a benchmark on a 100-million-row, 12-column table. Fifteen single-row lightweight DELETEs took roughly 200 ms. The same operation as a heavyweight mutation took roughly 8 seconds. That's a ~40× speedup on the initial mask write — and the gap widens as column count increases.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;IStorage::supportsDelete()&lt;/code&gt;: Architectural Formalization (December 2022)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Commit &lt;code&gt;938aac9&lt;/code&gt; (2022-12-29, davenger) added &lt;code&gt;IStorage::supportsDelete()&lt;/code&gt;, formalizing the architectural separation between engines that support &lt;code&gt;DELETE FROM&lt;/code&gt; and those that don't. This wasn't a feature in itself, but it was the contract that the rest of the system would build on.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Lightweight DELETE Correctness Fixes (Late 2022)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Lightweight DELETE introduced a new class of correctness bugs that had to be hunted down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/40559" rel="noopener noreferrer"&gt;PR #40559&lt;/a&gt; (davenger, August 2022) — fixed vertical merge of parts with lightweight-deleted rows. First post-introduction LWD bugfix; backported to 22.8.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/42126" rel="noopener noreferrer"&gt;PR #42126&lt;/a&gt; (davenger, October 2022) — fixed "Invalid number of rows in Chunk" errors. Required a substantive PREWHERE refactor to support multiple PREWHERE steps so the &lt;code&gt;_row_exists&lt;/code&gt; filter could be the first step in the chain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end of 2022, the criticism had shifted. "Deletes require heavy mutations" had a documented expiration date in the changelog.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 3 (2023): Lightweight DELETE Goes GA&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"You need &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt;"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Lightweight DELETE was promoted to GA / on-by-default in v23.3, announced in the &lt;a href="https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse" rel="noopener noreferrer"&gt;v23.3 release blog&lt;/a&gt; and the &lt;a href="https://clickhouse.com/blog/newsletter_2023_april" rel="noopener noreferrer"&gt;April 2023 newsletter&lt;/a&gt;. The GA work is credited to Jianmei Zhang and Alexander Gololobov.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Synchronous by Default (PR #44718, December 2022 / January 2023)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/44718" rel="noopener noreferrer"&gt;PR #44718&lt;/a&gt; made &lt;code&gt;DELETE FROM&lt;/code&gt; synchronous by default so the command would not return until the rows were masked and invisible to subsequent queries. This bounded the mutation queue and prevented accumulating piles of pending LWD mutations. The original async behavior was later partially restored as a setting (&lt;code&gt;lightweight_deletes_sync&lt;/code&gt;) for users on remote storage where the per-LWD coordination cost is high.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Memory and Concurrency Hardening (PR #48522, April 2023)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/48522" rel="noopener noreferrer"&gt;PR #48522&lt;/a&gt; (KochetovNicolai) directly targeted "Reduce memory usage for multiple ALTER DELETE mutations" — the canonical reference for fixing OOM scenarios on large mutation queues. Related issue #57411 documented servers being killed by OOM while loading large numbers of &lt;code&gt;mutation_*.txt&lt;/code&gt; files on startup. This PR shrunk the per-mutation memory footprint enough that the queue stopped being an operational hazard.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Lightweight DELETE on JSON and Object Columns (PR #49737, May 2023)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/49737" rel="noopener noreferrer"&gt;PR #49737&lt;/a&gt; (davenger) stopped the &lt;code&gt;MutatePlainMergeTreeTask&lt;/code&gt; log loop "There is no physical column &lt;code&gt;_row_exists&lt;/code&gt; in table" when the table had an &lt;code&gt;Object&lt;/code&gt; or JSON column. Fixed issues #49509 and #55076.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;From &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; to &lt;code&gt;enable_lightweight_delete&lt;/code&gt; (PR #50044, June 2023)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/50044" rel="noopener noreferrer"&gt;PR #50044&lt;/a&gt; (Azat Khuzhin, commit &lt;code&gt;7189481&lt;/code&gt;, 2023-06-04) aliased &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; to &lt;code&gt;enable_lightweight_delete&lt;/code&gt;. This was the bridge for users coming from older releases: their existing settings continued to work, but the canonical name reflected the feature's promotion out of experimental status.&lt;/p&gt;

&lt;p&gt;This is the precise moment where "you need &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt;" became misinformation. The setting wasn't removed (backward compatibility matters), but it stopped being required, and the canonical documentation moved to the new name.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Projection Compatibility (PRs #52517 and #52530, July–August 2023)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/52517" rel="noopener noreferrer"&gt;PR #52517&lt;/a&gt; (Anton Popov / CurtizJ, July 2023) — fixed lightweight DELETE failing after a projection was dropped. Even after the projection was gone, stale metadata could poison later LWD execution. Backported to 22.8 and 23.3.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/52530" rel="noopener noreferrer"&gt;PR #52530&lt;/a&gt; (CurtizJ, August 2023, commit &lt;code&gt;b6ce725&lt;/code&gt;) — fixed recalculation of skip indexes (bloom_filter, minmax, ngrambf, etc.) and projections in &lt;code&gt;ALTER DELETE&lt;/code&gt; queries. Both needed to be recalculated, not stale-copied. Backported to 22.8, 23.3, 23.5, 23.7.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;apply_deleted_mask&lt;/code&gt; and &lt;code&gt;APPLY DELETED MASK&lt;/code&gt;: Operator Levers (PRs #55952 and #57433, late 2023)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/55952" rel="noopener noreferrer"&gt;PR #55952&lt;/a&gt; (davenger, October 2023, commit &lt;code&gt;40062ca&lt;/code&gt;) added the &lt;code&gt;apply_deleted_mask&lt;/code&gt; setting. With &lt;code&gt;apply_deleted_mask = 0&lt;/code&gt;, SELECTs return rows that LWD has masked, which is essential for forensics, audits, and compliance verification — confirming that a delete actually happened, that the right rows were marked, and that the data is still recoverable until physical cleanup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57433" rel="noopener noreferrer"&gt;PR #57433&lt;/a&gt; (CurtizJ, December 2023, commit &lt;code&gt;87d0cec&lt;/code&gt;) added &lt;code&gt;ALTER TABLE … APPLY DELETED MASK [IN PARTITION …]&lt;/code&gt;. This is the explicit "stop waiting for merges, physically remove these rows now" lever. Implemented as an ordinary mutation command, it's the clean answer to compliance workloads that need guaranteed cleanup on demand.&lt;/p&gt;

&lt;p&gt;By the end of 2023, lightweight DELETE was production-ready. The umbrella tracking issue (#56728) for production-readiness work was being closed. Operators had observability, force-cleanup levers, and synchronous semantics by default.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 4 (2023–2024): Storage-Aware DELETE Optimizations&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"Deleted rows linger forever in storage"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The early lightweight DELETE design had a known operational gap. Because LWD only writes the mask, the underlying part still contains the deleted rows. They're filtered out at read time, but they consume disk space until the next merge. And the merge selector — which decides which parts to merge next — was counting &lt;em&gt;physical&lt;/em&gt; rows, not &lt;em&gt;existing&lt;/em&gt; rows. That meant a large part dominated by lightweight-deleted rows could sit at near &lt;code&gt;max_bytes_to_merge_at_max_space_in_pool&lt;/code&gt; indefinitely, never picked up for merging, never cleaned.&lt;/p&gt;

&lt;p&gt;Phase 4 fixed this and added the merge-selector and physical-cleanup machinery that makes LWD usable at scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;exclude_deleted_rows_for_part_size_in_merge&lt;/code&gt;: Merge Selection That Counts Existing Rows (PR #58223, early 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58223" rel="noopener noreferrer"&gt;PR #58223&lt;/a&gt; (jewelzqiu) added &lt;code&gt;existing_rows_count&lt;/code&gt; to data parts and taught the merge selector to use it. The MergeTree settings &lt;code&gt;exclude_deleted_rows_for_part_size_in_merge&lt;/code&gt; and &lt;code&gt;load_existing_rows_count_for_old_parts&lt;/code&gt; give operators control over the trade-off.&lt;/p&gt;

&lt;p&gt;The operational result: a 50 GB part where 90% of the rows are lightweight-deleted is now treated as a 5 GB part for merge selection. It gets picked up, merged, and the deleted rows physically disappear.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;lightweight_deletes_sync&lt;/code&gt; (PR #62195, April 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62195" rel="noopener noreferrer"&gt;PR #62195&lt;/a&gt; (CurtizJ, 2024-04-03) introduced the dedicated &lt;code&gt;lightweight_deletes_sync&lt;/code&gt; setting (default value 2: "wait all replicas synchronously"), separating LWD synchronicity from the generic &lt;code&gt;mutations_sync&lt;/code&gt;. This gave users on S3-backed deployments — where the per-LWD coordination cost is high — a way to lower the wait without weakening async semantics for heavy mutations. References to commit SHAs vary across sources (&lt;code&gt;534905f&lt;/code&gt; and &lt;code&gt;ed448ea&lt;/code&gt; both appear in PR #62195's commit set).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Projection Rebuild on Row-Reducing Merges (PR #62364, Q2 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62364" rel="noopener noreferrer"&gt;PR #62364&lt;/a&gt; (cangyin / ardenwick) added projection rebuild for merges that reduce row count. Some merging modes (Replacing, Collapsing, deletes) genuinely reduce rows, and projections need to be rebuilt to avoid silently retaining "deleted" rows in projection data — a subtle correctness bug that could cause queries hitting the projection to return different results than queries hitting the base table.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;lightweight_mutation_projection_mode&lt;/code&gt;: Lightweight DELETE on Tables with Projections (PR #65594, July 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/65594" rel="noopener noreferrer"&gt;PR #65594&lt;/a&gt; (jsc0218 / ShiChao Jin, 2024-07-04, commit &lt;code&gt;556c7de&lt;/code&gt;) added the &lt;code&gt;lightweight_mutation_projection_mode&lt;/code&gt; table-level setting with three values: &lt;code&gt;throw&lt;/code&gt; (default), &lt;code&gt;drop&lt;/code&gt;, and &lt;code&gt;rebuild&lt;/code&gt;. Without this setting, lightweight DELETE on a table with a projection unconditionally errored. Now you have an explicit policy: throw safely, drop the projection, or rebuild it as part of the merge.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;DELETE FROM … IN PARTITION&lt;/code&gt; for Lightweight DELETE (PR #67805, August 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/67805" rel="noopener noreferrer"&gt;PR #67805&lt;/a&gt; (sunny19930321, 2024-08-30, commit &lt;code&gt;950ca28&lt;/code&gt;) added &lt;code&gt;DELETE FROM … IN PARTITION 'xy' WHERE …&lt;/code&gt;. This means the planner doesn't have to scan all partitions when you know the delete is partition-bounded. Resolves issues #59409 and #60218. The &lt;code&gt;ON CLUSTER&lt;/code&gt; form is also supported: &lt;code&gt;DELETE FROM [db.]table [ON CLUSTER cluster] [IN PARTITION partition_expr] WHERE expr;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;system.parts.has_lightweight_delete&lt;/code&gt; and &lt;code&gt;system.mutations&lt;/code&gt; Schema Additions (2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;system.parts&lt;/code&gt; gained columns to flag and track lightweight-deleted parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;has_lightweight_delete&lt;/code&gt; — true if the part has any rows masked by LWD
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;removal_state&lt;/code&gt; — current state of the part's removal lifecycle
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;last_removal_attempt_time&lt;/code&gt; — timestamp of the most recent attempt to remove the part&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combined with &lt;code&gt;system.mutations.parts_postpone_reasons&lt;/code&gt; (commit &lt;code&gt;8903fd1&lt;/code&gt;) and &lt;code&gt;parts_in_progress_names&lt;/code&gt;, operators have machine-readable answers for "why is this delete stuck" without grepping logs.&lt;/p&gt;

&lt;p&gt;By the end of 2024, the storage layer understood lightweight DELETE end-to-end. Deleted rows didn't linger; the merge selector picked the right parts; projections and skip indexes were consistent; and operators had the levers and observability to manage it all.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 5 (2025–2026): Patch Parts and On-the-Fly Mutations&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"DELETEs are invisible until merges finish"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The lightweight DELETE design from PR #37893 still required &lt;em&gt;something&lt;/em&gt; to be written to disk for each delete — at minimum, a new version of &lt;code&gt;_row_exists.bin&lt;/code&gt; for the affected part. For workloads with many small, frequent deletes, that per-DELETE write was the dominant cost. Phase 5 attacked it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;apply_mutations_on_fly&lt;/code&gt;: On-the-Fly Mutations (PR #74877, Q1 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt; (CurtizJ) introduced &lt;code&gt;apply_mutations_on_fly&lt;/code&gt;. Queued &lt;code&gt;ALTER UPDATE&lt;/code&gt; and &lt;code&gt;ALTER DELETE&lt;/code&gt; mutations that have not yet materialized are now applied at SELECT time, so users immediately see updated and deleted state. This closed the long-standing "DELETE was issued but rows still appear when &lt;code&gt;mutations_sync = 0&lt;/code&gt;" surprise. Heavy mutations still materialize asynchronously in the background, but they're no longer invisible to queries in the meantime.&lt;/p&gt;

&lt;p&gt;The mechanism has limits: only scalar subqueries up to &lt;code&gt;mutations_max_literal_size_to_replace&lt;/code&gt;, only constant non-deterministic functions (controlled by &lt;code&gt;mutations_execute_nondeterministic_on_initiator&lt;/code&gt; and &lt;code&gt;mutations_execute_subqueries_on_initiator&lt;/code&gt;). Within those limits, the read path applies the mutation transform on the fly.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;On-the-Fly Lightweight DELETE (PR #79281, April 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79281" rel="noopener noreferrer"&gt;PR #79281&lt;/a&gt; (CurtizJ, commit &lt;code&gt;dc9f636&lt;/code&gt;) extended on-the-fly mutations to lightweight DELETE specifically. Now &lt;code&gt;DELETE FROM … SETTINGS lightweight_deletes_sync = 0&lt;/code&gt; becomes visible immediately when &lt;code&gt;apply_mutations_on_fly = 1&lt;/code&gt;. Resolves issue #75180.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Patch Parts: DELETEs Without Part Rewrites (PR #82004, July 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt; (CurtizJ / Anton Popov), merged into v25.7, is the second-most-important PR in this entire history. It added standard SQL &lt;code&gt;UPDATE&lt;/code&gt; syntax via &lt;em&gt;patch parts&lt;/em&gt; and re-implemented lightweight DELETE on top of the same mechanism when &lt;code&gt;lightweight_delete_mode = 'lightweight_update'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The new shape: a DELETE creates a tiny patch part that sets &lt;code&gt;_row_exists = 0&lt;/code&gt; for affected rows. The patch is applied on read and physically merged in the next background merge. There's no rewrite of the source part. There's no hardlinking ceremony. The DELETE is, essentially, an insert.&lt;/p&gt;

&lt;p&gt;Mechanically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Patch parts are sorted by &lt;code&gt;_part&lt;/code&gt;, &lt;code&gt;_part_offset&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;Partition ID is &lt;code&gt;patch-&amp;lt;hash of column names&amp;gt;-&amp;lt;original_partition_id&amp;gt;&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;Merging of patch parts uses a ReplacingMergeTree-style algorithm with &lt;code&gt;_data_version&lt;/code&gt; as version.
&lt;/li&gt;
&lt;li&gt;Two read-time application modes: merge by sorted system columns when the source part is unchanged, join when the source part has been re-merged.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;update_sequential_consistency&lt;/code&gt; and &lt;code&gt;update_parallel_mode&lt;/code&gt; control behavior under concurrent updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The benchmarks in &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-2-sql-style-updates" rel="noopener noreferrer"&gt;ClickHouse's own three-part series&lt;/a&gt; claim up to ~1,000× faster for small/selective changes versus classic mutations. Vendor benchmarks; treat as upper bounds, not guarantees, but the mechanism explains the size of the gap. For larger deletes (&amp;gt;~10% of a table), classic mutation is still preferred — the patch-on-read overhead grows with patch size.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;2026 Correctness and Operability Hardening&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The first half of 2026 has been a wave of LWD-related correctness and operability fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/101212" rel="noopener noreferrer"&gt;PR #101212&lt;/a&gt; (Anton Popov / CurtizJ, 2026-04-21, commit &lt;code&gt;509d35a&lt;/code&gt;) — "Fix several optimizations after lightweight deletes [2]." Critical fix: query optimizations like trivial &lt;code&gt;COUNT(*)&lt;/code&gt; and &lt;code&gt;minmax_count_projection&lt;/code&gt; were permanently disabled after a lightweight DELETE, even after all masked parts had been merged away. Replaced a sticky global flag with a per-snapshot computation: &lt;code&gt;mutations_snapshot-&amp;gt;hasLightweightDeletedMask()&lt;/code&gt;. This prevents permanent performance degradation on tables that ever ran an LWD.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/97589" rel="noopener noreferrer"&gt;PR #97589&lt;/a&gt; (Alexey Milovidov, 2026-02-28, commit &lt;code&gt;53a75e8&lt;/code&gt;) — Fix &lt;code&gt;KILL QUERY&lt;/code&gt; for &lt;code&gt;ALTER DELETE&lt;/code&gt; with &lt;code&gt;mutations_sync=1&lt;/code&gt; on &lt;code&gt;ReplicatedMergeTree&lt;/code&gt;. Synchronous replicated &lt;code&gt;ALTER DELETE&lt;/code&gt; could become effectively unkillable. The fix is in mutation execution and control flow rather than DELETE semantics, but it materially improves operability under stalls.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/99281" rel="noopener noreferrer"&gt;PR #99281&lt;/a&gt; (Yash, 2026) — Fix &lt;code&gt;ALTER TABLE UPDATE/DELETE&lt;/code&gt; failing with "Missing columns" when a MATERIALIZED column depends on an EPHEMERAL column. Computed-column dependency analysis was wrong; the statement could fail before mutation execution.
&lt;/li&gt;
&lt;li&gt;Commit &lt;code&gt;9c4dda6&lt;/code&gt; (2026-04-06) — Fix usage of text index with lightweight deletes. High-severity correctness fix for incorrect query results when both features were used together.
&lt;/li&gt;
&lt;li&gt;Commit &lt;code&gt;1acc6f3&lt;/code&gt; — Fix for stuck mutations caused by phantom entries (race condition causing DELETE mutations to become stuck indefinitely).
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/101792" rel="noopener noreferrer"&gt;PR #101792&lt;/a&gt; — Broad LWD stateless test coverage. Not a feature landing, but a signal: LWD's hidden-row lifecycle and read-path semantics are now important enough to encode in dedicated stateless test suites.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This long correctness tail isn't a bad sign — it's how all mature deletion paths look once they're in production at scale. Compare to PostgreSQL's history of vacuum/freeze edge cases, or InnoDB's purge interactions. The volume of LWD fixes in 2026 reflects the volume of LWD usage.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Bulk Deletion: &lt;code&gt;DROP PARTITION&lt;/code&gt; and Empty-Part Tombstones&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"Bulk deletion in ClickHouse is unsafe / non-atomic"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For bulk data lifecycle operations — removing a day's worth of data, dropping all rows for a deleted customer, reloading after a bad ETL run — the right answer is rarely &lt;code&gt;DELETE FROM&lt;/code&gt;. It's &lt;code&gt;ALTER TABLE … DROP PARTITION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/41145" rel="noopener noreferrer"&gt;PR #41145&lt;/a&gt; (Sema Checherinda, 2022) made these destructive partition operations &lt;em&gt;durable&lt;/em&gt;. Before this PR, &lt;code&gt;TRUNCATE TABLE&lt;/code&gt;, &lt;code&gt;ALTER TABLE DROP PART&lt;/code&gt;, and &lt;code&gt;ALTER TABLE DROP PARTITION&lt;/code&gt; worked by removing the part metadata and unlinking the files on disk. In a distributed system coordinated by ZooKeeper, a replica that was offline during the deletion or a crash between unlink and ZooKeeper update could leave the system in a state where the replica later attempted to "recover" the deleted part from another node. The result: "resurrected parts" — data that was supposedly deleted reappearing after a server restart or replica re-initialization.&lt;/p&gt;

&lt;p&gt;The fix is elegant. Instead of immediate removal, these queries now create &lt;em&gt;empty parts&lt;/em&gt; that explicitly cover the range of the old parts. Empty parts act as tombstones within the part set, so even if an old part is found on disk or on another replica, the system knows it has been superseded.&lt;/p&gt;

&lt;p&gt;The achievements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Durability:&lt;/strong&gt; if the request succeeds, the empty part is committed to disk and ZooKeeper, preventing resurrection.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomicity:&lt;/strong&gt; the substitution of old parts with empty ones is a single atomic operation within MergeTree's transaction scope.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-blocking reads:&lt;/strong&gt; the operation no longer requires a follow-up exclusive lock to clean up filesystem entries, so concurrent reads aren't blocked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For workloads where data has a clean partition boundary (date, customer, region), &lt;code&gt;DROP PARTITION&lt;/code&gt; is the most efficient deletion path in ClickHouse, full stop. It's effectively constant-time in the data volume.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;&lt;code&gt;ReplacingMergeTree&lt;/code&gt;, &lt;code&gt;is_deleted&lt;/code&gt;, and the Optimized &lt;code&gt;FINAL&lt;/code&gt;&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"&lt;code&gt;ReplacingMergeTree&lt;/code&gt; can't handle deletes" / "&lt;code&gt;FINAL&lt;/code&gt; is too slow"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For workloads that look more like upserts — frequent updates to the same primary key, with occasional deletions — engine-level deletion through &lt;code&gt;ReplacingMergeTree&lt;/code&gt; is often the right architecture. ClickHouse supports an &lt;code&gt;is_deleted&lt;/code&gt; column parameter on &lt;code&gt;ReplacingMergeTree&lt;/code&gt;, which is the canonical "tombstone" pattern.&lt;/p&gt;

&lt;p&gt;The mechanics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ReplacingMergeTree&lt;/code&gt; keeps only the most recent version of a row with a given primary key (using a version column).
&lt;/li&gt;
&lt;li&gt;Adding &lt;code&gt;is_deleted&lt;/code&gt; as a parameter tells the engine to treat rows where &lt;code&gt;is_deleted = 1&lt;/code&gt; as tombstones during merges.
&lt;/li&gt;
&lt;li&gt;To delete a row, insert a new record with the same primary key, the latest version, and &lt;code&gt;is_deleted = 1&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;During a merge, ClickHouse keeps the record with the highest version; if that record has &lt;code&gt;is_deleted = 1&lt;/code&gt;, the row is dropped entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;allow_experimental_cleanup_merges&lt;/code&gt; setting allows &lt;code&gt;OPTIMIZE TABLE … FINAL CLEANUP&lt;/code&gt;, which forces the engine to physically remove rows where the latest version is marked as deleted. This is a declarative, high-throughput way to manage deletions without going through the mutation engine at all.&lt;/p&gt;

&lt;p&gt;For query-time consistency, &lt;code&gt;SELECT … FINAL&lt;/code&gt; ensures deleted rows are excluded regardless of background merge state. The historical critique of &lt;code&gt;FINAL&lt;/code&gt; — that it was prohibitively slow for production queries — has been largely addressed through extensive optimization work. &lt;code&gt;FINAL&lt;/code&gt; now runs efficiently enough to be the recommended path for immediate consistency on &lt;code&gt;ReplacingMergeTree&lt;/code&gt; tables, especially when paired with appropriate primary key design.&lt;/p&gt;

&lt;p&gt;The pattern in production: write inserts and tombstones at full ingest speed, run analytical queries with &lt;code&gt;FINAL&lt;/code&gt; for consistency, and let merges (or &lt;code&gt;OPTIMIZE … FINAL CLEANUP&lt;/code&gt; on demand) handle physical cleanup in the background.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse DELETE Internals: Low-Level Optimizations and Correctness Hardening&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Beyond the headline features, the DELETE subsystem received systematic low-level optimization that compounds across every delete operation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PREWHERE multi-step refactor&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/42126" rel="noopener noreferrer"&gt;PR #42126&lt;/a&gt;): MergeTree reader now supports multiple PREWHERE steps so the &lt;code&gt;_row_exists&lt;/code&gt; filter can be the first step in the chain, both for correctness and for performance — read the tiny mask first, then large columns only for surviving rows.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I/O pool and asynchronous reads&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/43260" rel="noopener noreferrer"&gt;PR #43260&lt;/a&gt;): &lt;code&gt;max_streams_for_merge_tree_reading&lt;/code&gt; and &lt;code&gt;allow_asynchronous_read_from_io_pool_for_merge_tree&lt;/code&gt; allow a dedicated I/O pool for reading MergeTree parts during queries and mutations. Up to 100× speedup for mutation reads on high-latency storage like Amazon S3, per the 2022 changelog.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MemoryTracker for background tasks&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/48787" rel="noopener noreferrer"&gt;PR #48787&lt;/a&gt;, novikd): Memory tracking and soft limits for background tasks, including DELETE mutations.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mutations_execute_subqueries_on_initiator&lt;/code&gt; / &lt;code&gt;mutations_execute_nondeterministic_on_initiator&lt;/code&gt;&lt;/strong&gt; (settings, 23.x): Address one of the historically thorniest classes of replicated-DELETE bugs — divergent results across replicas when the predicate contains &lt;code&gt;now()&lt;/code&gt;, scalar subqueries, or &lt;code&gt;IN (subquery)&lt;/code&gt; (issues #18118, #19315, #23734, #16532).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_row_exists&lt;/code&gt; user-collision fix&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/41763" rel="noopener noreferrer"&gt;PR #41763&lt;/a&gt;): Early correctness proof point — handles the case where a user defines a column named &lt;code&gt;_row_exists&lt;/code&gt; themselves, preventing segfaults and wrong results.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;min_age_to_force_merge_seconds&lt;/code&gt;&lt;/strong&gt;: MergeTree setting that lets operators force older parts to merge regardless of size, reclaiming space from lightweight-deleted rows on a predictable schedule.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock contention reduction in &lt;code&gt;BackgroundSchedulePool&lt;/code&gt;&lt;/strong&gt;: On high-core-count CPUs (240+ threads), internal mutex contention in the background schedule pool was a latency source. CPU cycles spent in &lt;code&gt;native_queued_spin_lock_slowpath&lt;/code&gt; were reduced significantly through critical-section shrinking and thread-local &lt;code&gt;timer_id&lt;/code&gt; storage. This improves the scalability of the mutation engine on massive servers, ensuring background deletions don't interfere with interactive query performance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these individually make a press release. Together, they compound into a materially faster and more reliable DELETE engine at every level of the stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse DELETE Limitations and Trade-offs in 2026&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Fairness matters. A few things still require awareness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MutationsInterpreter&lt;/code&gt; still routes through the old analyzer.&lt;/strong&gt; Issue #61563 tracks the migration of &lt;code&gt;MutationsInterpreter&lt;/code&gt; to the new query analyzer; PR #61528 is the partial work. Most user-facing DELETE behavior is unaffected, but some advanced predicate forms behave differently than equivalent SELECTs. This is the only feature area where the answer is "partial / not done."
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch-on-read overhead grows with patch size.&lt;/strong&gt; Patch-part DELETEs (PR #82004) are dramatically faster than classic mutations for small, selective changes. For deletes affecting more than ~10% of a table, classic mutation is still preferred. The optimizer doesn't auto-select between them; the user picks via &lt;code&gt;lightweight_delete_mode&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compact parts gain less from LWD's hardlink optimization.&lt;/strong&gt; In compact parts (the format used for small parts), all columns are interleaved in a single file. The "rewrite only &lt;code&gt;_row_exists&lt;/code&gt;, hardlink the rest" optimization can't apply, so LWD on compact parts still rewrites the file. The wider your parts and the more columns, the bigger the LWD win.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lightweight DELETE on remote storage has surprising latency.&lt;/strong&gt; Real-world reports (issues #58281, #59225, #67048) document that LWD on S3-backed deployments can be slow even when no rows match the predicate, due to the synchronous coordination cost. &lt;code&gt;lightweight_deletes_sync&lt;/code&gt; and patch parts (#82004) are the architectural answers; users on older versions should expect older behavior.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DROP PARTITION&lt;/code&gt; is constant-time only if your partitioning key is right.&lt;/strong&gt; If your data isn't partitioned along the dimension you want to delete by, &lt;code&gt;DROP PARTITION&lt;/code&gt; doesn't help. Partition design is a one-shot decision that defines what bulk deletion looks like.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correctness fixes are ongoing.&lt;/strong&gt; The 2026 wave (PR #101212, #97589, #99281, &lt;code&gt;9c4dda6&lt;/code&gt;, &lt;code&gt;1acc6f3&lt;/code&gt;) shows that LWD's interactions with read-path optimizations, replication, and other indexes still produce edge cases. ClickHouse's engineering team has been rigorous about correctness, but running the latest stable release matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are real engineering trade-offs, and understanding them is part of making an informed decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse DELETE Improvements Timeline (2018–2026)&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;What Changed&lt;/th&gt;
&lt;th&gt;Key PRs&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2018&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt; lands as mutation. Replicated and non-replicated MergeTree. Skip-unaffected-parts optimization.&lt;/td&gt;
&lt;td&gt;Release &lt;code&gt;1.1.54388&lt;/code&gt;; &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/2634" rel="noopener noreferrer"&gt;#2634&lt;/a&gt;; &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/2694" rel="noopener noreferrer"&gt;#2694&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;DELETE is supported. Heavyweight by design. &lt;code&gt;system.mutations&lt;/code&gt;, &lt;code&gt;KILL MUTATION&lt;/code&gt;, &lt;code&gt;mutations_sync&lt;/code&gt; established.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2020–2021&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Correctness hardening on the mutation path. &lt;code&gt;IN PARTITION&lt;/code&gt; for mutations.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/9048" rel="noopener noreferrer"&gt;#9048&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/12153" rel="noopener noreferrer"&gt;#12153&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/21477" rel="noopener noreferrer"&gt;#21477&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/13403" rel="noopener noreferrer"&gt;#13403&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Primary index corruption fixed, NULL-predicate over-deletion fixed, deadlocks fixed. Partition pruning for heavy mutations.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2022&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lightweight DELETE introduction. &lt;code&gt;_row_exists&lt;/code&gt; mask, PREWHERE injection, hardlink unaffected columns. Empty-part tombstones for &lt;code&gt;DROP PARTITION&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;#37893&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/41145" rel="noopener noreferrer"&gt;#41145&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/40559" rel="noopener noreferrer"&gt;#40559&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/42126" rel="noopener noreferrer"&gt;#42126&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Standard SQL &lt;code&gt;DELETE FROM&lt;/code&gt;. ~40× faster than heavy mutation on initial mask write. Durable bulk-deletion semantics.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2023&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;LWD goes GA in v23.3. Synchronous by default. Memory hardening. Projection compatibility. &lt;code&gt;apply_deleted_mask&lt;/code&gt;. &lt;code&gt;APPLY DELETED MASK&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/44718" rel="noopener noreferrer"&gt;#44718&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/48522" rel="noopener noreferrer"&gt;#48522&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/52517" rel="noopener noreferrer"&gt;#52517&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/52530" rel="noopener noreferrer"&gt;#52530&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/55952" rel="noopener noreferrer"&gt;#55952&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57433" rel="noopener noreferrer"&gt;#57433&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/50044" rel="noopener noreferrer"&gt;#50044&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;LWD production-ready. &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; deprecated. Operators get visibility and force-cleanup levers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2024&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Storage-aware merge selection. &lt;code&gt;lightweight_deletes_sync&lt;/code&gt;. Projection policy. &lt;code&gt;DELETE FROM … IN PARTITION&lt;/code&gt;. Row-reducing-merge projection rebuild.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58223" rel="noopener noreferrer"&gt;#58223&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62195" rel="noopener noreferrer"&gt;#62195&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/65594" rel="noopener noreferrer"&gt;#65594&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/67805" rel="noopener noreferrer"&gt;#67805&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62364" rel="noopener noreferrer"&gt;#62364&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Deleted rows physically reclaimed by merges. Replicated LWD has independent sync control. Partition-scoped LWD.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On-the-fly mutations and on-the-fly LWD. Patch parts: DELETE as a tiny insert.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;#74877&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79281" rel="noopener noreferrer"&gt;#79281&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;#82004&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Queued deletes visible at SELECT time. Patch-part path targets up to ~1,000× faster than classic mutations on small/selective changes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read-path optimization fixes after LWD. Killable replicated synchronous DELETEs. Text-index correctness. Stuck-mutation race fixes.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/101212" rel="noopener noreferrer"&gt;#101212&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/97589" rel="noopener noreferrer"&gt;#97589&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/99281" rel="noopener noreferrer"&gt;#99281&lt;/a&gt;, &lt;code&gt;9c4dda6&lt;/code&gt;, &lt;code&gt;1acc6f3&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;COUNT(*)&lt;/code&gt; and projection optimizations no longer permanently disabled after LWD. &lt;code&gt;KILL QUERY&lt;/code&gt; works for synchronous replicated &lt;code&gt;ALTER DELETE&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;When Should You Use Each DELETE Method in ClickHouse?&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Reasoning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bulk historical cleanup along a partition boundary&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;ALTER TABLE … DROP PARTITION&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Constant-time, atomic, durable via empty-part tombstones (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/41145" rel="noopener noreferrer"&gt;PR #41145&lt;/a&gt;). The most efficient bulk path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reloading data after a bad ETL run&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;DROP PARTITION&lt;/code&gt; then re-insert&lt;/td&gt;
&lt;td&gt;Same logic — partition-bounded operations are essentially free.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Selective row-level deletion (small set of rows)&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;DELETE FROM … WHERE …&lt;/code&gt; (lightweight)&lt;/td&gt;
&lt;td&gt;Default since v23.3. ~40× faster than heavy mutation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-frequency operational deletes (many small)&lt;/td&gt;
&lt;td&gt;✅ Patch-part DELETE (&lt;code&gt;lightweight_delete_mode = 'lightweight_update'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Each DELETE is a tiny insert, no part rewrite. Up to ~1,000× faster on small/selective changes per ClickHouse benchmarks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance-grade deletion (GDPR right-to-erasure)&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt; (heavyweight) or &lt;code&gt;APPLY DELETED MASK&lt;/code&gt; after LWD&lt;/td&gt;
&lt;td&gt;When you need "the bytes are physically gone" on completion, mutation is the path. &lt;code&gt;APPLY DELETED MASK&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57433" rel="noopener noreferrer"&gt;PR #57433&lt;/a&gt;) forces materialization of LWD-marked rows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frequent updates with occasional deletions (upserts)&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;ReplacingMergeTree(version, is_deleted)&lt;/code&gt; + &lt;code&gt;FINAL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Engine-native pattern. Tombstones at ingest speed, query-time consistency via optimized &lt;code&gt;FINAL&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming data with explicit cancellation pairs&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;CollapsingMergeTree&lt;/code&gt; with &lt;code&gt;Sign&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;+1&lt;/code&gt; / &lt;code&gt;-1&lt;/code&gt; pair "collapses" on merge. Highly efficient for streams that can produce cancellation events.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deletes scoped to a known partition&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;DELETE FROM … IN PARTITION&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Planner skips unaffected partitions (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/67805" rel="noopener noreferrer"&gt;PR #67805&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need to verify a delete worked / forensic audit&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;apply_deleted_mask = 0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Returns rows that LWD has masked but not physically removed (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/55952" rel="noopener noreferrer"&gt;PR #55952&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need queued deletes visible immediately to queries&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;apply_mutations_on_fly = 1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;On-the-fly mutation visibility (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79281" rel="noopener noreferrer"&gt;#79281&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deleting more than ~10% of a table&lt;/td&gt;
&lt;td&gt;🟡 &lt;code&gt;ALTER TABLE DELETE&lt;/code&gt; (heavyweight) or &lt;code&gt;DROP PARTITION&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Patch-part overhead grows with patch size; classic mutation is more efficient at this scale.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-10ms p99 latency on read-heavy workload with frequent deletes&lt;/td&gt;
&lt;td&gt;🟡 Conditional&lt;/td&gt;
&lt;td&gt;LWD is fast but adds a PREWHERE step. &lt;code&gt;ReplacingMergeTree&lt;/code&gt; + &lt;code&gt;FINAL&lt;/code&gt; may be faster depending on read-pattern. Benchmark on your workload.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;How to Respond to "ClickHouse Can't Delete"&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Run the PR numbers.&lt;/p&gt;

&lt;p&gt;When someone tells you ClickHouse can't delete data in 2026, ask them what release they're benchmarking against. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If they're citing "experimental lightweight delete," they're on something pre-v23.3. The setting was renamed and default-enabled in April 2023 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/50044" rel="noopener noreferrer"&gt;PR #50044&lt;/a&gt;).
&lt;/li&gt;
&lt;li&gt;If they're citing "heavyweight mutations only," they're on something pre-v22.8. Standard SQL &lt;code&gt;DELETE FROM&lt;/code&gt; has been available for nearly four years.
&lt;/li&gt;
&lt;li&gt;If they're citing "deleted rows linger forever," they're on something pre-v24.x. The merge selector has counted &lt;em&gt;existing&lt;/em&gt; rows since &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58223" rel="noopener noreferrer"&gt;PR #58223&lt;/a&gt;.
&lt;/li&gt;
&lt;li&gt;If they're citing "DELETEs are invisible until merges finish," they're on something pre-v25.x. On-the-fly mutations (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;) and on-the-fly LWD (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79281" rel="noopener noreferrer"&gt;PR #79281&lt;/a&gt;) closed that gap in early 2025.
&lt;/li&gt;
&lt;li&gt;If they're citing "DELETE breaks projections," they're on something pre-v24.7. &lt;code&gt;lightweight_mutation_projection_mode&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/65594" rel="noopener noreferrer"&gt;PR #65594&lt;/a&gt;) gives explicit policy options.
&lt;/li&gt;
&lt;li&gt;If they're citing "patch parts don't exist," they're on something pre-v25.7. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt; shipped them in July 2025.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If they're benchmarking against ClickHouse 21.x or earlier, or repeating 2018-era documentation, they aren't evaluating ClickHouse. They're evaluating a system that no longer exists.&lt;/p&gt;

&lt;p&gt;The commit history doesn't lie. 80+ pull requests. Five architectural eras. Four production-grade delete paths. Engine-level deletion patterns through &lt;code&gt;ReplacingMergeTree&lt;/code&gt;. Storage-aware merge selection. Patch parts. On-the-fly visibility. Full observability through &lt;code&gt;system.mutations&lt;/code&gt; and &lt;code&gt;system.parts.has_lightweight_delete&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;ClickHouse's DELETE subsystem in 2026 bears no resemblance to the one that earned the early "immutable" warnings. The engineers built a modern deletion engine, and the evidence is in the PRs.&lt;/p&gt;

&lt;p&gt;Test it on your workload. That's the only benchmark that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse DELETE FAQ&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can ClickHouse delete data?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. ClickHouse has supported &lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt; since 2018 (release &lt;code&gt;1.1.54388&lt;/code&gt;) and standard SQL &lt;code&gt;DELETE FROM&lt;/code&gt; since v22.8 (lightweight, default-enabled since v23.3). It also supports bulk deletion via &lt;code&gt;ALTER TABLE … DROP PARTITION&lt;/code&gt;, engine-level deletion patterns via &lt;code&gt;ReplacingMergeTree(version, is_deleted)&lt;/code&gt;, and patch-part-based DELETEs since v25.7. The "ClickHouse is append-only" claim is outdated by eight years.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What's the fastest way to delete in ClickHouse?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The fastest delete in ClickHouse is &lt;code&gt;ALTER TABLE … DROP PARTITION&lt;/code&gt; for bulk deletion along a partition boundary — it's essentially constant-time in data volume. For selective row-level deletion, lightweight &lt;code&gt;DELETE FROM&lt;/code&gt; (default since v23.3) is roughly 40× faster than heavyweight mutations on the initial mask write (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt; benchmark). For high-frequency small deletes, patch-part DELETEs (v25.7, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;) eliminate the part rewrite entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Do I still need &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt;?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. The &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; setting was renamed to &lt;code&gt;enable_lightweight_delete&lt;/code&gt; and default-enabled in ClickHouse v23.3 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/50044" rel="noopener noreferrer"&gt;PR #50044&lt;/a&gt;, commit &lt;code&gt;7189481&lt;/code&gt;, June 2023). The old name is preserved as a backward-compatibility alias but is no longer required. Anyone telling you to set this flag is benchmarking a release older than three years.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How does lightweight DELETE work?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Lightweight DELETE in ClickHouse implements standard SQL &lt;code&gt;DELETE FROM &amp;lt;table&amp;gt; WHERE …&lt;/code&gt; as a hidden &lt;code&gt;ALTER TABLE &amp;lt;table&amp;gt; UPDATE _row_exists = 0 WHERE …&lt;/code&gt; mutation. Each MergeTree part has a virtual column &lt;code&gt;_row_exists&lt;/code&gt;; setting bits to 0 marks rows as deleted. Reads automatically inject &lt;code&gt;PREWHERE _row_exists&lt;/code&gt; so deleted rows are filtered out before the main column scan. For wide-format parts, only the mask file is rewritten — all other column files are hardlinked from the old part. Physical removal happens during the next background merge, or on demand via &lt;code&gt;ALTER TABLE … APPLY DELETED MASK&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What's the difference between lightweight DELETE and heavyweight ALTER DELETE?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;In ClickHouse, lightweight DELETE writes a hidden mask and filters deleted rows out at read time; physical rows survive until the next background merge. It returns fast but defers physical cleanup. Heavyweight &lt;code&gt;ALTER TABLE DELETE&lt;/code&gt; rewrites all affected parts to physically remove rows; it's slower but guarantees the bytes are gone when the mutation completes. For compliance-grade workloads (GDPR right-to-erasure), heavyweight &lt;code&gt;ALTER DELETE&lt;/code&gt; or &lt;code&gt;APPLY DELETED MASK&lt;/code&gt; after a lightweight DELETE is the right path.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Is &lt;code&gt;FINAL&lt;/code&gt; slow in ClickHouse?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Not anymore. &lt;code&gt;FINAL&lt;/code&gt; in ClickHouse was historically slow on &lt;code&gt;ReplacingMergeTree&lt;/code&gt; and similar engines, which fueled the critique. It has been significantly optimized in recent versions and is now the recommended path for immediate consistency at query time, regardless of background merge state. For workloads using &lt;code&gt;ReplacingMergeTree(version, is_deleted)&lt;/code&gt;, &lt;code&gt;SELECT … FINAL&lt;/code&gt; is the canonical pattern for ensuring deleted rows are excluded.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can &lt;code&gt;ReplacingMergeTree&lt;/code&gt; handle deletes?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. ClickHouse's &lt;code&gt;ReplacingMergeTree&lt;/code&gt; engine supports an &lt;code&gt;is_deleted&lt;/code&gt; column parameter for tombstone-style deletion. To delete a row, insert a new record with the same primary key, the latest version, and &lt;code&gt;is_deleted = 1&lt;/code&gt;. During a merge, rows where the latest version has &lt;code&gt;is_deleted = 1&lt;/code&gt; are dropped entirely. &lt;code&gt;OPTIMIZE TABLE … FINAL CLEANUP&lt;/code&gt; (gated by &lt;code&gt;allow_experimental_cleanup_merges&lt;/code&gt;) forces physical removal on demand.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I bulk-delete a lot of data efficiently?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;In ClickHouse, the most efficient bulk-delete is &lt;code&gt;ALTER TABLE … DROP PARTITION&lt;/code&gt;. Since &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/41145" rel="noopener noreferrer"&gt;PR #41145&lt;/a&gt;, partition drops use durable empty-part tombstones, making them atomic, non-blocking for concurrent reads, and resilient to replica crashes. The operation is essentially constant-time in the data volume, far cheaper than any row-level DELETE for the same scope. The trade-off: you have to design your partitioning key around the dimensions you'll bulk-delete by.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Are queued deletes visible to queries before they finish?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes, in ClickHouse v25.x and later. Set &lt;code&gt;apply_mutations_on_fly = 1&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;, Q1 2025) and queued &lt;code&gt;ALTER UPDATE&lt;/code&gt; / &lt;code&gt;ALTER DELETE&lt;/code&gt; mutations are applied at SELECT time before background materialization. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79281" rel="noopener noreferrer"&gt;PR #79281&lt;/a&gt; (April 2025) extended this to lightweight DELETE specifically. The "I deleted a row but a SELECT still returns it" surprise is solved.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What if my table has projections or skip indexes?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;If a ClickHouse table with projections or skip indexes needs lightweight DELETE, use the &lt;code&gt;lightweight_mutation_projection_mode&lt;/code&gt; table-level setting (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/65594" rel="noopener noreferrer"&gt;PR #65594&lt;/a&gt;, v24.7): &lt;code&gt;throw&lt;/code&gt; (default), &lt;code&gt;drop&lt;/code&gt;, or &lt;code&gt;rebuild&lt;/code&gt;. Projections and skip indexes are recalculated correctly during delete-driven merges as of &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/52530" rel="noopener noreferrer"&gt;PR #52530&lt;/a&gt; (backported to 22.8 and later). Row-reducing merges trigger projection rebuild (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62364" rel="noopener noreferrer"&gt;PR #62364&lt;/a&gt;) so projections stay consistent with the base table.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I monitor in-flight deletes?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;In ClickHouse, monitor in-flight deletes via the &lt;code&gt;system.mutations&lt;/code&gt; table, which shows queued and running mutations with &lt;code&gt;parts_to_do&lt;/code&gt;, &lt;code&gt;is_done&lt;/code&gt;, &lt;code&gt;latest_fail_reason&lt;/code&gt;, &lt;code&gt;parts_postpone_reasons&lt;/code&gt;, and &lt;code&gt;parts_in_progress_names&lt;/code&gt;. &lt;code&gt;system.parts&lt;/code&gt; includes &lt;code&gt;has_lightweight_delete&lt;/code&gt;, &lt;code&gt;removal_state&lt;/code&gt;, and &lt;code&gt;last_removal_attempt_time&lt;/code&gt; for masked parts. &lt;code&gt;KILL MUTATION&lt;/code&gt; cancels a stuck or unwanted mutation. &lt;code&gt;apply_deleted_mask = 0&lt;/code&gt; lets you query rows that LWD has masked but not physically removed, useful for forensics and audits.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What are patch parts?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Patch parts in ClickHouse (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;, v25.7) are a new on-disk architecture for lightweight UPDATEs and DELETEs. Instead of rewriting &lt;code&gt;_row_exists&lt;/code&gt; in the source part, a DELETE creates a tiny patch part that records the rows to mark deleted. The patch is applied on read (via merge by sorted system columns or a join, depending on whether the source has been re-merged) and physically merged into the source during the next background merge. For small/selective changes, this eliminates the per-DELETE write cost almost entirely. ClickHouse's benchmarks claim up to ~1,000× speedup on small changes, though that's a vendor benchmark on a favorable workload — treat it as an upper bound. Set &lt;code&gt;lightweight_delete_mode = 'lightweight_update'&lt;/code&gt; to enable.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Analysis based on 80+ GitHub pull requests, official ClickHouse changelogs, and release blogs covering the period 2018–2026. Every claim maps to a specific merged PR or commit SHA. Verify the evidence yourself — the commit history is public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>dataengineering</category>
      <category>data</category>
    </item>
    <item>
      <title>Does ClickHouse Support UPDATEs? A 2026 Data Analysis</title>
      <dc:creator>Manveer Chawla</dc:creator>
      <pubDate>Thu, 30 Apr 2026 20:30:00 +0000</pubDate>
      <link>https://dev.to/dataengineering/does-clickhouse-support-updates-a-2026-data-analysis-4m75</link>
      <guid>https://dev.to/dataengineering/does-clickhouse-support-updates-a-2026-data-analysis-4m75</guid>
      <description>&lt;h2&gt;
  
  
  &lt;strong&gt;TL;DR&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Yes, ClickHouse fully supports UPDATEs.&lt;/strong&gt; As of April 2026, ClickHouse ships standard SQL &lt;code&gt;UPDATE ... SET ... WHERE&lt;/code&gt; syntax that runs in milliseconds, alongside four other update mechanisms: &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; mutations for bulk operations, lightweight DELETE, on-the-fly mutation visibility, and &lt;code&gt;ReplacingMergeTree&lt;/code&gt; for high-volume upserts and CDC. The "ClickHouse is append-only" claim is outdated by eight years and 100+ merged pull requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key facts:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard SQL UPDATE shipped in ClickHouse 25.7 (July 2025)&lt;/strong&gt; via &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;, backed by a new "patch part" architecture. It was promoted to Beta with default enablement in version 25.8 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85952" rel="noopener noreferrer"&gt;PR #85952&lt;/a&gt;).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lightweight UPDATE delivers up to 1,000× to 2,400× speedup&lt;/strong&gt; for single-row updates compared to classical mutations, per ClickHouse's own benchmarks. Patch parts store only the changed columns plus five system columns, with no part rewrite.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; has shipped since August 2018&lt;/strong&gt; (ClickHouse v18.12), authored by Alex Zatelepin (&lt;code&gt;ztlpn&lt;/code&gt;). Updates have never been "unsupported" in any release from the last eight years.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lightweight DELETE has been GA since 2022&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt;). The &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; flag is no longer required.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-the-fly mutations&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;) make queued UPDATEs immediately visible to SELECTs, eliminating the eventual-consistency gap when needed.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational controls are production-grade&lt;/strong&gt;: &lt;code&gt;max_uncompressed_bytes_in_patches&lt;/code&gt; (default 30 GiB, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85641" rel="noopener noreferrer"&gt;PR #85641&lt;/a&gt;), exponential backoff for failed mutations (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58036" rel="noopener noreferrer"&gt;PR #58036&lt;/a&gt;), workload classification (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/64061" rel="noopener noreferrer"&gt;PR #64061&lt;/a&gt;), and bandwidth throttling (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57877" rel="noopener noreferrer"&gt;PR #57877&lt;/a&gt;).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability is first-class&lt;/strong&gt;: &lt;code&gt;parts_postpone_reasons&lt;/code&gt;, &lt;code&gt;latest_fail_error_code_name&lt;/code&gt;, &lt;code&gt;mutation_ids&lt;/code&gt; in &lt;code&gt;system.part_log&lt;/code&gt;, and dynamic &lt;code&gt;system.warnings&lt;/code&gt; for stalled mutations all ship by default.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verdict&lt;/strong&gt;: the "ClickHouse is append-only" claim made sense in 2017. Repeating it in 2026 is misinformation. ClickHouse's UPDATE subsystem now uses standard SQL, runs in milliseconds, and replicates correctly across distributed clusters.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Why People Still Say "ClickHouse Doesn't Support Updates"&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you have evaluated ClickHouse in the last few years, you have probably heard one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"ClickHouse is append-only."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"ClickHouse doesn't support UPDATEs."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Updates require ALTER TABLE mutations that rewrite entire parts."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Mutations are the only way to update data."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Updates are eventually consistent."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"You have to use &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt;."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"There is no standard SQL &lt;code&gt;UPDATE&lt;/code&gt; syntax."&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of this was accurate documentation circa 2018 to 2022. Some is now folklore that competitors keep repeating because it is a convenient story: ClickHouse is fast for scans, but you can't update.&lt;/p&gt;

&lt;p&gt;In 2017, before mutations even existed, the criticism was structurally correct. ClickHouse's MergeTree engine was designed around immutability, and "updates" had to be modeled by inserting new rows into specialized engines like &lt;code&gt;ReplacingMergeTree&lt;/code&gt; and resolving the conflict at merge time or via &lt;code&gt;FINAL&lt;/code&gt;. There was no &lt;code&gt;UPDATE&lt;/code&gt; statement.&lt;/p&gt;

&lt;p&gt;Then, over eight years, ClickHouse's engineering team systematically dismantled that limitation. They added &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; (2018), &lt;code&gt;KILL MUTATION&lt;/code&gt; and &lt;code&gt;system.mutations&lt;/code&gt; for diagnosability (2019), &lt;code&gt;mutations_sync&lt;/code&gt; for synchronous waits (2019), &lt;code&gt;IN PARTITION&lt;/code&gt; scoping (2020), the &lt;code&gt;MutateTask&lt;/code&gt; refactor (2021), a long correctness wave for replicated mutations (2020 to 2022), lightweight DELETE (2022), on-the-fly mutations (2025), and finally lightweight UPDATE backed by patch parts (2025), accompanied by 50+ stabilization PRs that made it production-safe by 2026.&lt;/p&gt;

&lt;p&gt;This article traces that evolution with PR-level evidence. No marketing claims, no benchmarks on toy datasets. Just the commit history.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Methodology: How This ClickHouse UPDATE Analysis Was Built&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;We went through ClickHouse's GitHub commit history, pull requests, changelogs, and release blogs from 2018 through April 2026. The scope covered every PR that touched the UPDATE subsystem: the original mutation engine, the replicated-coordination correctness wave, lightweight DELETE, on-the-fly mutations, the patch-part architecture (PR #82004 plus 35 follow-up commits inside the same PR), and the post-landing stabilization work.&lt;/p&gt;

&lt;p&gt;Each PR was classified by category (engine, planner, replication, observability, performance, correctness), impact severity, and whether it changed default behavior or required an opt-in flag. We cross-referenced PR descriptions against changelog entries and the &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-1-purpose-built-engines" rel="noopener noreferrer"&gt;ClickHouse Updates blog series&lt;/a&gt; to verify the claimed improvements. Where multiple PRs addressed the same subsystem, we traced the dependency chain to understand how the incremental changes compounded.&lt;/p&gt;

&lt;p&gt;The result is a chronological narrative across seven distinct eras, with full provenance. Every claim in this article maps to a specific merged PR or issue that you can verify yourself on GitHub.&lt;/p&gt;

&lt;p&gt;This is not a benchmarking exercise. Benchmarks measure peak performance on controlled workloads. This analysis measures the engineering trajectory: what was built, why, and what it means for teams deciding whether ClickHouse can support their update workloads today.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What Update Features Does ClickHouse Ship by Default in 2026?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The current state, as of April 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard SQL &lt;code&gt;UPDATE&lt;/code&gt; statement.&lt;/strong&gt; &lt;code&gt;UPDATE table SET col = expr WHERE …&lt;/code&gt; works for MergeTree-family tables, backed by patch parts. No special syntax, no experimental flags by default in stable production paths.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classical &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; mutations.&lt;/strong&gt; The original 2018 mechanism is still available and is the right tool for bulk backfills, schema-level corrections, and operations where rewriting affected parts is acceptable.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lightweight DELETE.&lt;/strong&gt; &lt;code&gt;DELETE FROM … WHERE&lt;/code&gt; is implemented as a single-column rewrite of a &lt;code&gt;_row_exists&lt;/code&gt; virtual mask. Deletes that used to take 8 seconds finish in 200 ms.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-the-fly mutation visibility.&lt;/strong&gt; SELECTs see queued UPDATEs and DELETEs immediately, before background materialization completes. The latency between issuing an UPDATE and seeing its effect goes from "depends on the merge schedule" to "insert-like."
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReplacingMergeTree&lt;/code&gt; for CDC and upsert workflows.&lt;/strong&gt; Updates are ingested as new rows; deduplication happens asynchronously during background merges. The &lt;code&gt;FINAL&lt;/code&gt; keyword guarantees deduplicated reads at query time, and &lt;code&gt;FINAL&lt;/code&gt; has been heavily optimized for production use.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational safety nets.&lt;/strong&gt; Exponential backoff for failed mutations, workload classification for resource isolation, server-level bandwidth throttling, per-replica concurrency caps, and &lt;code&gt;max_uncompressed_bytes_in_patches&lt;/code&gt; reject runaway UPDATE storms before they can hurt the cluster.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-class observability.&lt;/strong&gt; &lt;code&gt;system.mutations&lt;/code&gt;, &lt;code&gt;system.part_log&lt;/code&gt;, &lt;code&gt;system.warnings&lt;/code&gt;, and &lt;code&gt;system.parts.is_patch&lt;/code&gt; give operators the data they need to diagnose stalled or failed mutations without grepping logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not experimental features hidden behind flags. They are defaults that ship with every modern ClickHouse installation.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse UPDATE Myths vs. Reality: A 2026 Checklist&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;The FUD&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;th&gt;Reality (2026)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;"ClickHouse is append-only"&lt;/td&gt;
&lt;td&gt;🟢 False since 2018&lt;/td&gt;
&lt;td&gt;&lt;a href="https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse" rel="noopener noreferrer"&gt;v18.12 release&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; shipped in 2018. Standard SQL &lt;code&gt;UPDATE&lt;/code&gt; shipped in 2025 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;"ClickHouse doesn't support updates"&lt;/td&gt;
&lt;td&gt;🟢 False&lt;/td&gt;
&lt;td&gt;100+ PRs across 8 years&lt;/td&gt;
&lt;td&gt;Multiple update mechanisms ship by default: standard &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt;, lightweight DELETE, on-the-fly mutations, ReplacingMergeTree.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;"Updates require ALTER TABLE mutations that rewrite entire parts"&lt;/td&gt;
&lt;td&gt;🟢 False since 2025&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Lightweight UPDATE writes only changed columns into patch parts. No part rewrite. Insert-like latency.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;"Mutations are the only way to update"&lt;/td&gt;
&lt;td&gt;🟢 False since 2022&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;#74877&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;#82004&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Lightweight DELETE, on-the-fly mutations, and lightweight UPDATE all bypass the classical part-rewrite path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;"Updates are eventually consistent"&lt;/td&gt;
&lt;td&gt;🟡 Nuanced&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;#82004&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Classical &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; is async by default. On-the-fly mutations and lightweight UPDATE provide immediate read-after-write visibility. &lt;code&gt;mutations_sync&lt;/code&gt; provides synchronous semantics on demand.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;"&lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; is required"&lt;/td&gt;
&lt;td&gt;🟢 False since v22.8&lt;/td&gt;
&lt;td&gt;Lightweight DELETE is GA&lt;/td&gt;
&lt;td&gt;The flag is no longer needed. Lightweight DELETE is the default &lt;code&gt;DELETE&lt;/code&gt; implementation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;"No standard SQL UPDATE syntax"&lt;/td&gt;
&lt;td&gt;🟢 False since 2025&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UPDATE table SET col = expr WHERE …&lt;/code&gt; works as standard SQL on MergeTree-family tables.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;"Updates cause unbounded part rewriting"&lt;/td&gt;
&lt;td&gt;🟢 Solved since 2025&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85641" rel="noopener noreferrer"&gt;PR #85641&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58036" rel="noopener noreferrer"&gt;#58036&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;max_uncompressed_bytes_in_patches&lt;/code&gt; (default 30 GiB) caps patch accumulation. Exponential backoff prevents failure-loop CPU burn.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;"You can't kill a stuck UPDATE"&lt;/td&gt;
&lt;td&gt;🟢 False since 2019&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/4287" rel="noopener noreferrer"&gt;PR #4287&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KILL MUTATION&lt;/code&gt; works on both MergeTree and ReplicatedMergeTree. &lt;code&gt;system.mutations&lt;/code&gt; exposes failure reasons.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;"ClickHouse can't isolate update workload from queries"&lt;/td&gt;
&lt;td&gt;🟢 False since 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/64061" rel="noopener noreferrer"&gt;PR #64061&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57877" rel="noopener noreferrer"&gt;#57877&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mutation_workload&lt;/code&gt;, &lt;code&gt;merge_workload&lt;/code&gt;, and &lt;code&gt;max_mutations_bandwidth_for_server&lt;/code&gt; provide first-class resource isolation.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 0 (2016 to 2017): How Did You Update Data in ClickHouse Before Mutations?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"ClickHouse is append-only and was never designed for updates."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This part of the criticism is half-right historically. ClickHouse was designed as a columnar OLAP store optimized for ingest throughput and scan performance. Row-level mutability was deliberately out of scope. There was no &lt;code&gt;UPDATE&lt;/code&gt; statement.&lt;/p&gt;

&lt;p&gt;But "no UPDATE statement" never meant "no way to update data." From the earliest releases, ClickHouse shipped specialized MergeTree engines that modeled mutations as insertions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReplacingMergeTree&lt;/code&gt;&lt;/strong&gt;: last-write-wins on the sorting key. Updates are ingested as new rows with the same primary key, and the most recent version wins after the next background merge. The &lt;code&gt;FINAL&lt;/code&gt; keyword forces deduplication at query time for cases where you can't wait for the merge.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CollapsingMergeTree&lt;/code&gt;&lt;/strong&gt;: uses a &lt;code&gt;Sign&lt;/code&gt; column (+1 for the new row, −1 for the old one). Pairs of rows with the same key cancel each other out during merges.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VersionedCollapsingMergeTree&lt;/code&gt;&lt;/strong&gt;: adds a version column for ingestion of updates that arrive out of order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These were not workarounds. They were the design. For change-data-capture (CDC) workloads, high-volume upserts, and event-sourcing patterns, they remain the most efficient option in ClickHouse to this day. The trade-off is moving the cost from write time to read time (or to merge time).&lt;/p&gt;

&lt;p&gt;The competitor FUD that frames ClickHouse as "append-only" is technically describing this era. What it leaves out is everything that happened after.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 1 (2018): How Does ClickHouse's &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; Work?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"ClickHouse has no UPDATE statement."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In v18.12, Alex Zatelepin (&lt;code&gt;ztlpn&lt;/code&gt;) shipped &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; and &lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt;. The model was deliberately heavyweight: every UPDATE is a logged &lt;em&gt;mutation&lt;/em&gt; that runs asynchronously in the background.&lt;/p&gt;

&lt;p&gt;Mechanically, a mutation does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The command is persisted (to ZooKeeper for &lt;code&gt;ReplicatedMergeTree&lt;/code&gt;, or to a local &lt;code&gt;mutation_*.txt&lt;/code&gt; file for non-replicated tables).
&lt;/li&gt;
&lt;li&gt;Each affected part is rewritten to a temporary part by &lt;code&gt;MergeTreeDataMergerMutator::mutatePartToTemporaryPart&lt;/code&gt;. Files for unaffected columns are &lt;em&gt;hardlinked&lt;/em&gt; in Wide parts; only the changed columns get rewritten.
&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;max_block_number&lt;/code&gt; invariant ensures mutations only process parts that existed when the mutation was issued. Data inserted &lt;em&gt;after&lt;/em&gt; the UPDATE is not retroactively touched.
&lt;/li&gt;
&lt;li&gt;Replicas pull the mutation entry from the ZooKeeper log and execute it locally on their copies of the affected parts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This design enforces several semantic restrictions that persist today. They are not bugs; they are the contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You cannot UPDATE primary-key or partition-key columns.&lt;/strong&gt; Enforced in &lt;code&gt;MutationsInterpreter::validateUpdateColumns&lt;/code&gt;. Changing the sort order would require rebuilding the entire part's index, which defeats the point of MergeTree.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No transactional atomicity.&lt;/strong&gt; Mutations are not bundled into transactions by default. If the server restarts mid-mutation, the operation resumes from where it left off, but you do not get cross-mutation atomicity.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No immediate read-after-write.&lt;/strong&gt; A SELECT issued right after an &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; may return pre-update values until the background materialization completes.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No non-deterministic functions in replicated mutations.&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/7247" rel="noopener noreferrer"&gt;PR #7247&lt;/a&gt;.) &lt;code&gt;rand()&lt;/code&gt; and &lt;code&gt;now()&lt;/code&gt; are forbidden because each replica would compute different values, causing divergence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 2018 criticism was: ClickHouse just got UPDATE support, but it is slow and async. That was fair. What followed was eight years of work to make it fast, predictable, and immediately visible, without giving up the bulk-update use case the original design was good at.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 2 (2019 to 2021): Can You Diagnose, Cancel, and Wait for ClickHouse Mutations?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"You can't kill a stuck mutation. There's no way to know if your UPDATE landed."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once mutations existed, the next two years were dominated by operational maturity. Six PRs in particular turned mutations from "fire and pray" into something you could reason about in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;KILL MUTATION&lt;/code&gt; and Failure Diagnostics (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/4287" rel="noopener noreferrer"&gt;PR #4287&lt;/a&gt;, 2019)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ztlpn&lt;/code&gt;'s February 2019 PR added &lt;code&gt;KILL MUTATION&lt;/code&gt; for both MergeTree and ReplicatedMergeTree. It also extended &lt;code&gt;system.mutations&lt;/code&gt; with &lt;code&gt;latest_failed_part&lt;/code&gt;, &lt;code&gt;latest_fail_time&lt;/code&gt;, and &lt;code&gt;latest_fail_reason&lt;/code&gt;, and added an &lt;code&gt;is_mutation&lt;/code&gt; flag in &lt;code&gt;system.merges&lt;/code&gt;. From this point on, "my UPDATE is stuck" became a diagnosable problem rather than an opaque one.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;mutations_sync&lt;/code&gt;: Defining "Did My UPDATE Land?" (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/8237" rel="noopener noreferrer"&gt;PR #8237&lt;/a&gt;, 2019)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;alesapin&lt;/code&gt;'s December 2019 PR introduced the &lt;code&gt;mutations_sync&lt;/code&gt; setting. Set to 0, the default, mutations are fully async. Set to 1, the client waits until the mutation completes on the local replica. Set to 2, it waits until all replicas have completed. Every later wait-correctness fix in the replication wave (#22669, #28889, #24809, #10588) is a repair of this contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;&lt;code&gt;IN PARTITION&lt;/code&gt; Scoping (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/13403" rel="noopener noreferrer"&gt;PR #13403&lt;/a&gt;, 2020)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Vladimir Chebotarev's PR added &lt;code&gt;ALTER UPDATE/DELETE … IN PARTITION&lt;/code&gt;. This was the first SQL semantics extension since the original landing, enabling partition pruning. If you only need to update last week's data, you say so explicitly and the mutation skips every other partition.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The &lt;code&gt;MergeTask&lt;/code&gt; / &lt;code&gt;MutateTask&lt;/code&gt; Refactor (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/25165" rel="noopener noreferrer"&gt;PR #25165&lt;/a&gt;, 2021)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;nikitamikhaylov&lt;/code&gt;'s September 2021 PR is the quietly load-bearing change of the entire UPDATE history. It split the monolithic merge/mutate logic into stage-based, suspendable &lt;code&gt;MergeTask&lt;/code&gt; and &lt;code&gt;MutateTask&lt;/code&gt; objects. The PR description: &lt;em&gt;"Added an ability to suspend and resume a process of a merge."&lt;/em&gt; Every later mutation improvement, from compact-part stage collapse to patch parts to vertical-merge correctness, builds on this refactor.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The Replication Correctness Wave (2020 to 2022)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;In parallel, a long series of PRs from &lt;code&gt;alesapin&lt;/code&gt;, &lt;code&gt;azat&lt;/code&gt;, and &lt;code&gt;tavplubix&lt;/code&gt; turned replicated UPDATE from "best-effort" into "predictable but slow." Notable fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/9022" rel="noopener noreferrer"&gt;#9022&lt;/a&gt; fixes the &lt;code&gt;parts_to_do=0 ∧ is_done=0&lt;/code&gt; hang where a mutation appeared "almost done" forever.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/11681" rel="noopener noreferrer"&gt;#11681&lt;/a&gt; fixes the inconsistency between &lt;code&gt;system.mutations.is_done=1&lt;/code&gt; and a &lt;code&gt;MUTATE_PART&lt;/code&gt; entry still sitting in the replication queue.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/17499" rel="noopener noreferrer"&gt;#17499&lt;/a&gt; fixes ALTER hang when the corresponding mutation is killed on a different replica.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/19702" rel="noopener noreferrer"&gt;#19702&lt;/a&gt; fixes &lt;code&gt;virtual_parts&lt;/code&gt; after part corruption so replicated mutations can recover.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/22669" rel="noopener noreferrer"&gt;#22669&lt;/a&gt; fixes wait-on-multiple-replicas semantics for &lt;code&gt;mutations_sync=2&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/28889" rel="noopener noreferrer"&gt;#28889&lt;/a&gt; fixes a &lt;code&gt;rbegin&lt;/code&gt; vs &lt;code&gt;begin&lt;/code&gt; typo in the cross-replica wait logic. Tiny diff, large blast radius.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/34096" rel="noopener noreferrer"&gt;#34096&lt;/a&gt; fixes the race between &lt;code&gt;mergeSelectingTask&lt;/code&gt; and queue reinit after ZooKeeper reconnect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Distributed UPDATE is uniquely hard. ZooKeeper coordination, virtual_parts after part corruption, queue reinit races, finalization ambiguity: every distributed system that supports UPDATE eventually relives this set of problems. ClickHouse's 2020 to 2022 commit history is what working through them looks like.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 3 (2022): Why Is ClickHouse's Lightweight DELETE So Much Faster?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"Every delete in ClickHouse rewrites entire parts."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt; by &lt;code&gt;zhangjmruc&lt;/code&gt; in 2022 was the architectural wedge that made everything later possible. It implemented &lt;code&gt;DELETE FROM … WHERE&lt;/code&gt; as &lt;code&gt;ALTER UPDATE _row_exists = 0 WHERE …&lt;/code&gt; against a new virtual mask column.&lt;/p&gt;

&lt;p&gt;Before this PR, deleting matching rows meant rewriting every part that contained any of them. After it, deletion is a single-column UPDATE of the virtual &lt;code&gt;_row_exists&lt;/code&gt; mask, with the actual row filtering happening at SELECT time.&lt;/p&gt;

&lt;p&gt;The PR body cites a benchmark: &lt;strong&gt;200 ms vs 8 seconds&lt;/strong&gt; on the same workload. Forty-fold improvement, with no part rewrite required.&lt;/p&gt;

&lt;p&gt;This was not just a performance win. It was a proof of concept for a new pattern: instead of physically modifying data, write a small "diff" alongside it and reconcile at read time. That pattern would later become the foundation of patch parts.&lt;/p&gt;

&lt;p&gt;The competitor FUD point about &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; refers to the early enablement flag for this feature. The flag is no longer needed; lightweight DELETE has been the default &lt;code&gt;DELETE&lt;/code&gt; implementation for years.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 4 (Early 2025): How Do On-the-Fly Mutations in ClickHouse Work?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"You always have to wait for the next merge to see your update."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The latency problem with classical mutations is structural: the UPDATE is logged immediately, but the data is not physically modified until a background merge gets around to it. In a busy cluster, that can mean seconds or minutes between issuing an UPDATE and being able to read its effect.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt; by &lt;code&gt;CurtizJ&lt;/code&gt; (early 2025) introduced &lt;strong&gt;on-the-fly mutations&lt;/strong&gt; via the &lt;code&gt;apply_mutations_on_fly&lt;/code&gt; setting. With this enabled, SELECTs apply non-finished UPDATE/DELETE mutations immediately, before background materialization. The latency between "I issued an UPDATE" and "I can read the new value" goes from "depends on the merge schedule" to "insert-like."&lt;/p&gt;

&lt;p&gt;Three companion settings landed alongside it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mutations_max_literal_size_to_replace&lt;/code&gt;: caps how large a literal can be while still being inlined into the on-the-fly application path.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mutations_execute_nondeterministic_on_initiator&lt;/code&gt;: controls where non-deterministic mutation expressions execute, to keep results consistent across replicas.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mutations_execute_subqueries_on_initiator&lt;/code&gt;: same idea for subqueries inside mutation predicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On-the-fly mutations made it explicit: read-after-write consistency is something users can opt into when they need it, without giving up the asynchronous bulk-rewrite model when they do not.&lt;/p&gt;

&lt;p&gt;This was the latency wedge. The next PR was the syntax wedge.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 5 (Mid-2025): How Does ClickHouse's Lightweight UPDATE and Patch-Part Architecture Work?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"There is no standard SQL &lt;code&gt;UPDATE&lt;/code&gt; syntax in ClickHouse. Every UPDATE rewrites parts."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt; is the landmark commit of this entire eight-year history. Authored by Anton Popov (&lt;code&gt;CurtizJ&lt;/code&gt;), the initial commit (&lt;code&gt;a5327c6&lt;/code&gt;) landed June 16, 2025, and the PR merged to master around July 6, 2025. It shipped in ClickHouse 25.7.&lt;/p&gt;

&lt;p&gt;What it does: introduces standard SQL &lt;code&gt;UPDATE table SET col = expr WHERE …&lt;/code&gt; for MergeTree-family tables, backed by a new artifact called a &lt;strong&gt;patch part&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What Does a ClickHouse Patch Part Contain?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A patch part is a small, separate part on disk that stores only what changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The columns that were updated (with their new values).
&lt;/li&gt;
&lt;li&gt;Five system columns: &lt;code&gt;_part&lt;/code&gt;, &lt;code&gt;_part_offset&lt;/code&gt;, &lt;code&gt;_block_number&lt;/code&gt;, &lt;code&gt;_block_offset&lt;/code&gt;, &lt;code&gt;_data_version&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is it. No copy of unchanged columns. No index rebuild. No part rewrite. The size overhead is approximately &lt;strong&gt;40 bytes per row&lt;/strong&gt; plus the actual changed cell values.&lt;/p&gt;

&lt;p&gt;The implementation lives in a new directory, &lt;code&gt;src/Storages/MergeTree/PatchParts/&lt;/code&gt;, with new types like &lt;code&gt;PatchPartInfo&lt;/code&gt;, &lt;code&gt;PatchMode&lt;/code&gt;, &lt;code&gt;MergeTreeSinkPatch&lt;/code&gt;, &lt;code&gt;MergeTreePatchReader&lt;/code&gt;, and a brand-new &lt;code&gt;InterpreterUpdateQuery&lt;/code&gt;. The landing also touched &lt;code&gt;MutationCommands&lt;/code&gt;, &lt;code&gt;MutateTask&lt;/code&gt;, &lt;code&gt;MergeTreeData&lt;/code&gt;, &lt;code&gt;MergeTreeDataMergerMutator&lt;/code&gt;, &lt;code&gt;MergeTreeSink&lt;/code&gt;, &lt;code&gt;ReplicatedMergeTreeQueue&lt;/code&gt;, &lt;code&gt;ReplicatedMergeTreeLogEntry&lt;/code&gt;, and the reader-chain files. By any reasonable measure, this was a multi-subsystem rewrite, not a feature add.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How Does ClickHouse Apply Patch Parts at Query Time?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;ClickHouse reconciles a patch part with a base part at SELECT time. There are two strategies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PatchMode::Merge&lt;/code&gt;&lt;/strong&gt;: sorted on &lt;code&gt;(_part, _part_offset)&lt;/code&gt;. Used when patches and base parts share row offsets directly.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PatchMode::Join&lt;/code&gt;&lt;/strong&gt;: joined on &lt;code&gt;(_block_number, _block_offset)&lt;/code&gt;. Used when offsets do not line up directly and a logical join is needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The choice is automatic. Implicit minmax indexes on &lt;code&gt;_block_number&lt;/code&gt; and &lt;code&gt;_block_offset&lt;/code&gt; inside patch parts (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85040" rel="noopener noreferrer"&gt;PR #85040&lt;/a&gt;) make the join-mode path much faster by pruning patches that do not touch the rows being read.&lt;/p&gt;

&lt;p&gt;Patches themselves get merged together in the background (a "replacing-merge by &lt;code&gt;_data_version&lt;/code&gt;"), so the read-time overhead does not accumulate forever. Eventually, patches fold into base parts during normal merges, and the system returns to baseline read performance.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How Fast Is ClickHouse's Lightweight UPDATE?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;ClickHouse's own benchmarks, published in the &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-3-benchmarks" rel="noopener noreferrer"&gt;Updates in ClickHouse, Part 3&lt;/a&gt; blog post, report &lt;strong&gt;up to 1,000× to 2,400× faster&lt;/strong&gt; for single-row updates compared to classical &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; mutations. The exact multiplier depends on the workload shape; the headline is that what used to be a heavyweight asynchronous operation now has insert-like latency.&lt;/p&gt;

&lt;p&gt;The cost is read-time overhead. The umbrella issue &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/82033" rel="noopener noreferrer"&gt;#82033&lt;/a&gt; cites approximately &lt;strong&gt;7% to 18% on average&lt;/strong&gt; for SELECTs that have to apply patches. That is the trade-off: patches are cheap to write and bounded in size, but they do add a small reconciliation cost at read time. When patches fold into base parts during background merges, the overhead disappears.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What Settings Control Lightweight UPDATE in ClickHouse?&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;allow_experimental_lightweight_update&lt;/code&gt;: gate during the experimental period.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apply_patches_to_read&lt;/code&gt;: read-side toggle.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;update_parallel_mode&lt;/code&gt;: controls write-side parallelism for patch creation.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;update_sequential_consistency&lt;/code&gt;: visibility model.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enable_block_number_column = 1&lt;/code&gt; and &lt;code&gt;enable_block_offset_column = 1&lt;/code&gt;: prerequisites; patch parts depend on the per-row block-number/offset columns introduced for this purpose.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lightweight_delete_mode = 'lightweight_update'&lt;/code&gt;: opt-in path for routing DELETEs through patch parts as well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85952" rel="noopener noreferrer"&gt;PR #85952&lt;/a&gt; (August 24, 2025) promoted lightweight UPDATE to &lt;strong&gt;Beta&lt;/strong&gt; with default enablement, shipping in ClickHouse 25.8.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 6 (Mid-2025 to 2026): How Was ClickHouse's Patch-Part Architecture Stabilized?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"The new UPDATE features are experimental and unsafe in production."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A feature this cross-cutting needed weeks of immediate stabilization. Inside PR #82004 itself, 35 commits landed between the initial June 16, 2025 commit and the final merge. Ten of those follow-up commits are worth naming:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;SHA&lt;/th&gt;
&lt;th&gt;What it fixed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-17&lt;/td&gt;
&lt;td&gt;&lt;code&gt;7f5a42a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lightweight updates on &lt;code&gt;ReplicatedMergeTree&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-19&lt;/td&gt;
&lt;td&gt;&lt;code&gt;284c239&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Better consistency for lightweight updates in RMT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-19&lt;/td&gt;
&lt;td&gt;&lt;code&gt;c7ec4db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Merges of patch parts in RMT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-20&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f18385c&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disable partition detach in RMT with patch parts (operational safety)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-20&lt;/td&gt;
&lt;td&gt;&lt;code&gt;9902d37&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Crash in prefetch of patch parts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-23&lt;/td&gt;
&lt;td&gt;&lt;code&gt;e7d8624&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filtering of &lt;code&gt;versions_block&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-25&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cc28005&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Better waiting for LWU before running classic mutation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-26&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5af26c2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Better applying patches with PREWHERE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-07-02&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;6409858&lt;/code&gt;/&lt;code&gt;b23a074&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Disable lazy columns with lightweight updates (correctness over a read-path optimization)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is just inside the original PR. Outside it, the post-landing stabilization involved another wave of fixes:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Read-Path and Query-Plan Correctness&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85040" rel="noopener noreferrer"&gt;PR #85040&lt;/a&gt;: implicit minmax indexes on &lt;code&gt;_block_number&lt;/code&gt;/&lt;code&gt;_block_offset&lt;/code&gt; inside patch parts; reworked &lt;code&gt;PatchJoinCache&lt;/code&gt;. Big SELECT-side win.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92838" rel="noopener noreferrer"&gt;PR #92838&lt;/a&gt;: primary-index use for lightweight updates with &lt;code&gt;IN&lt;/code&gt;-subquery predicates.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/99023" rel="noopener noreferrer"&gt;PR #99023&lt;/a&gt;: patch parts without &lt;code&gt;_part_offset&lt;/code&gt; query-plan fix.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/99164" rel="noopener noreferrer"&gt;PR #99164&lt;/a&gt;: patch-parts column-order mismatch causing &lt;code&gt;LOGICAL_ERROR&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Memory and Resource Guardrails&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85641" rel="noopener noreferrer"&gt;PR #85641&lt;/a&gt;: &lt;code&gt;max_uncompressed_bytes_in_patches&lt;/code&gt; (default &lt;strong&gt;30 GiB&lt;/strong&gt;). New lightweight updates are rejected with &lt;code&gt;TOO_LARGE_LIGHTWEIGHT_UPDATES&lt;/code&gt; if patches accumulate beyond the threshold. This is the operational governor that prevents runaway patch growth from degrading reads forever.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/95231" rel="noopener noreferrer"&gt;PR #95231&lt;/a&gt;: fixes inaccurate memory accounting for large patch-part application that could trigger OOM-killer events.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77922" rel="noopener noreferrer"&gt;PR #77922&lt;/a&gt;: parallel column flushes during vertical merges.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Correctness and Crash Fixes (Late 2025 to April 2026)&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82945" rel="noopener noreferrer"&gt;PR #82945&lt;/a&gt;: mutations snapshot built from parts visible in the query; consistency for on-fly + patch parts vs running mutations.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/97162" rel="noopener noreferrer"&gt;PR #97162&lt;/a&gt; (alexey-milovidov, 2026-02-17): fixes phantom entries in mutations' &lt;code&gt;parts_to_do&lt;/code&gt; that caused stuck mutations. Race condition where &lt;code&gt;PartCheckThread&lt;/code&gt; re-enqueued already-mutated parts; the fix adjusts &lt;code&gt;ReplicatedMergeTreeQueue&lt;/code&gt; to immediately remove obsolete parts.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/97347" rel="noopener noreferrer"&gt;PR #97347&lt;/a&gt; (Kirill Kopnev, 2026-02-20): scalar subquery in &lt;code&gt;ALTER UPDATE/DELETE&lt;/code&gt; could corrupt the mutation command and even make the table unloadable on restart. &lt;strong&gt;High-severity.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98044" rel="noopener noreferrer"&gt;PR #98044&lt;/a&gt; (Raul Marin / &lt;code&gt;Algunenano&lt;/code&gt;, 2026-02-26): fixes mutation after lightweight update on tables with secondary indices. The cleanest example of how the legacy mutation framework and the new lightweight-update system needed to learn to coexist.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/101403" rel="noopener noreferrer"&gt;PR #101403&lt;/a&gt; (2026-04-22): fixes &lt;code&gt;UPDATE SET DateTime&lt;/code&gt; literal not being rewritten with session timezone, which was a silent data-corruption hazard.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Replicated-Side Concurrency&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/95771" rel="noopener noreferrer"&gt;PR #95771&lt;/a&gt; (2026-04-09): optimizes &lt;code&gt;ReplicatedMergeTree&lt;/code&gt; queue locks; reduces lock contention for SELECTs on replicated tables with mutations.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/87265" rel="noopener noreferrer"&gt;PR #87265&lt;/a&gt;: fixes lightweight UPDATE with &lt;code&gt;WHERE col IN (SELECT …)&lt;/code&gt; in replicated tables with partitions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The volume of stabilization work tells you something honest: a feature that lets you write &lt;code&gt;UPDATE&lt;/code&gt; against a columnar OLAP store &lt;em&gt;and&lt;/em&gt; finishes in milliseconds &lt;em&gt;and&lt;/em&gt; replicates correctly &lt;em&gt;and&lt;/em&gt; coexists with the legacy mutation framework is genuinely hard. ClickHouse's engineering team did the work. Running ClickHouse 25.8 or later gets you a feature that has been hardened in the open, with every fix traceable to a public PR.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What Operational Controls Does ClickHouse Provide for UPDATE Workloads?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Beyond the headline features, the eight-year history added a set of operational levers that make UPDATE workloads predictable in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff for failed mutations&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58036" rel="noopener noreferrer"&gt;PR #58036&lt;/a&gt;, 2024). Default retry interval of 5 minutes for mutations that keep failing (e.g., a bad CAST). Prevents CPU and log-file blowup from hot-looping on a permanent error.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workload classification&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/64061" rel="noopener noreferrer"&gt;PR #64061&lt;/a&gt;, 2024). The &lt;code&gt;mutation_workload&lt;/code&gt; and &lt;code&gt;merge_workload&lt;/code&gt; settings integrate with the workload scheduler so UPDATE mutations can be classed and throttled separately from merges and queries.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-level bandwidth throttling&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57877" rel="noopener noreferrer"&gt;PR #57877&lt;/a&gt;, 2024). The &lt;code&gt;max_mutations_bandwidth_for_server&lt;/code&gt; setting caps the I/O bandwidth mutations can consume cluster-wide.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-submit query validation&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71300" rel="noopener noreferrer"&gt;PR #71300&lt;/a&gt;, 2024). The full mutation query, including subqueries, is validated before being queued. Prevents queue-blocking dead mutations.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttling caps&lt;/strong&gt;. &lt;code&gt;number_of_mutations_to_delay&lt;/code&gt;, &lt;code&gt;number_of_mutations_to_throw&lt;/code&gt;, and &lt;code&gt;max_number_of_mutations_for_replica&lt;/code&gt; cap queued and concurrent mutation counts.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication coalescing limit&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/48731" rel="noopener noreferrer"&gt;PR #48731&lt;/a&gt;, 2023). &lt;code&gt;replicated_max_mutations_in_one_entry&lt;/code&gt; (default 10000) bounds how many mutation commands are coalesced into one ZooKeeper entry, preventing OOM on startup.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lightweight-DELETE-with-projections control&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66169" rel="noopener noreferrer"&gt;PR #66169&lt;/a&gt;). &lt;code&gt;lightweight_mutation_projection_mode&lt;/code&gt; (&lt;code&gt;throw&lt;/code&gt; / &lt;code&gt;drop&lt;/code&gt; / &lt;code&gt;rebuild&lt;/code&gt;) gives operators explicit control over how lightweight DELETE interacts with materialized projections.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these individually make a press release. Together, they are what "production-grade UPDATE support in a columnar database" actually requires.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;How Do You Monitor ClickHouse UPDATE Performance and Health?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you cannot see what your UPDATEs are doing, you cannot run them in production. The 2024 to 2026 observability additions are substantial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;latest_fail_error_code_name&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;system.mutations&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72398" rel="noopener noreferrer"&gt;PR #72398&lt;/a&gt;). Enables automated alerting on specific failure classes.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;parts_postpone_reasons&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;system.mutations&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92206" rel="noopener noreferrer"&gt;PR #92206&lt;/a&gt;, 2025-12-16). Lets operators diagnose stalled mutations instantly. "Why is this mutation not progressing?" used to require log-grepping. Now it is a column.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mutation_ids&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;system.part_log&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/93811" rel="noopener noreferrer"&gt;PR #93811&lt;/a&gt;). For &lt;code&gt;MUTATE_PART&lt;/code&gt; and &lt;code&gt;MUTATE_PART_START&lt;/code&gt; events. Materially improves traceability during incident investigations.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;is_patch&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;system.parts&lt;/code&gt;. Distinguishes patch overlays from base parts, so operators can see directly how much patch material has accumulated.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-running mutation warnings&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/78658" rel="noopener noreferrer"&gt;PR #78658&lt;/a&gt;). Adds a dynamic &lt;code&gt;system.warnings&lt;/code&gt; entry when mutations exceed &lt;code&gt;max_pending_mutations_execution_time_to_warn&lt;/code&gt;. Surfaces silently-stuck mutations without external monitoring.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-fly mutation metrics in &lt;code&gt;system.tables&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/75738" rel="noopener noreferrer"&gt;PR #75738&lt;/a&gt;). Per-table visibility into the on-the-fly mutation backlog.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent background settings for mutate vs. merge&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/93905" rel="noopener noreferrer"&gt;PR #93905&lt;/a&gt;). Previously the two shared the default profile, which made it impossible to isolate update resource usage from merges.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;What Are the Limitations of ClickHouse UPDATEs in 2026?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Fairness matters. A few things still require awareness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Primary-key and partition-key columns still cannot be updated.&lt;/strong&gt; This is a structural property of MergeTree, not a missing feature. Changing the sort order would require rebuilding the part's primary index; if you genuinely need to change a key column, the right pattern is to insert into a new table with the desired key and swap.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classical &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; mutations are still asynchronous by default.&lt;/strong&gt; They are the right tool for bulk backfills and schema-level corrections, but if you need read-after-write consistency, you need on-the-fly mutations, lightweight UPDATE, or &lt;code&gt;mutations_sync&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch parts have a read-time cost.&lt;/strong&gt; The umbrella issue cites approximately 7% to 18% read overhead while patches are unmerged. Background merges fold patches into base parts and the overhead disappears, but a workload that issues massive patch volume faster than merges can absorb will see sustained read regression.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_uncompressed_bytes_in_patches&lt;/code&gt; is a hard ceiling.&lt;/strong&gt; The 30 GiB default is a sensible starting point, but a workload generating patches faster than merges can consume them will eventually hit the cap and have new updates rejected with &lt;code&gt;TOO_LARGE_LIGHTWEIGHT_UPDATES&lt;/code&gt;. Tune it, monitor it.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The new analyzer is intentionally not used by &lt;code&gt;MutationsInterpreter&lt;/code&gt;.&lt;/strong&gt; &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/61528" rel="noopener noreferrer"&gt;PR #61528&lt;/a&gt; (2024) explicitly forces mutations to use the legacy analyzer, and &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/61563" rel="noopener noreferrer"&gt;issue #61563&lt;/a&gt; tracking the migration remains open in early 2026. This is the largest outstanding planner gap on the UPDATE side.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lightweight UPDATE and classical mutations can interact.&lt;/strong&gt; Issues like &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/98898" rel="noopener noreferrer"&gt;#98898&lt;/a&gt; (&lt;code&gt;LOGICAL_ERROR: Found patch part intersects mutation&lt;/code&gt;) and &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98044" rel="noopener noreferrer"&gt;PR #98044&lt;/a&gt; show that the two systems are still being taught to coexist cleanly. Run a recent stable release.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ReplacingMergeTree&lt;/code&gt; with &lt;code&gt;FINAL&lt;/code&gt; is still the right tool for very high-volume CDC and upsert workloads.&lt;/strong&gt; Lightweight UPDATE is fast for low-to-medium volume row-level changes; for streams of millions of upserts per second, the engine-level deduplication model continues to win.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are real engineering trade-offs. Understanding them is part of making an informed decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse UPDATE Improvements Timeline (2018 to 2026)&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;What Changed&lt;/th&gt;
&lt;th&gt;Key PRs&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2018&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Original &lt;code&gt;ALTER TABLE … UPDATE/DELETE&lt;/code&gt; lands&lt;/td&gt;
&lt;td&gt;ztlpn 2018 series&lt;/td&gt;
&lt;td&gt;First UPDATE statement. Heavyweight, async, replicated via ZooKeeper.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2019&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KILL MUTATION&lt;/code&gt;, &lt;code&gt;system.mutations&lt;/code&gt; failure columns, &lt;code&gt;mutations_sync&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/4287" rel="noopener noreferrer"&gt;#4287&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/8237" rel="noopener noreferrer"&gt;#8237&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;UPDATE becomes diagnosable, cancellable, and waitable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2020&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;IN PARTITION&lt;/code&gt; scoping, NULL semantics fix, &lt;code&gt;isAffectingAllColumns&lt;/code&gt; gate&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/13403" rel="noopener noreferrer"&gt;#13403&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/12153" rel="noopener noreferrer"&gt;#12153&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/12760" rel="noopener noreferrer"&gt;#12760&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;First SQL semantics extension. Partition pruning. Correct WHERE handling.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2021&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MergeTask&lt;/code&gt;/&lt;code&gt;MutateTask&lt;/code&gt; refactor; replicated correctness wave&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/25165" rel="noopener noreferrer"&gt;#25165&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/22669" rel="noopener noreferrer"&gt;#22669&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/28889" rel="noopener noreferrer"&gt;#28889&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Architectural foundation for everything later. Replicated UPDATE becomes predictable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2022&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lightweight DELETE via &lt;code&gt;_row_exists&lt;/code&gt; mask&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;#37893&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;200 ms vs 8 s. Wedge for the patch-part architecture.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2023&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Skip-index recalc, vertical Compact-to-Wide merges, &lt;code&gt;replicated_max_mutations_in_one_entry&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/55202" rel="noopener noreferrer"&gt;#55202&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/45681" rel="noopener noreferrer"&gt;#45681&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/48731" rel="noopener noreferrer"&gt;#48731&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Storage-feature integration. Mutation-storm safety.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2024&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Backoff, workload classification, bandwidth throttling, &lt;code&gt;latest_fail_error_code_name&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58036" rel="noopener noreferrer"&gt;#58036&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/64061" rel="noopener noreferrer"&gt;#64061&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57877" rel="noopener noreferrer"&gt;#57877&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72398" rel="noopener noreferrer"&gt;#72398&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Production-grade operational controls. UPDATE becomes a first-class workload class.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On-the-fly mutations; lightweight UPDATE / patch parts&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;#74877&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;#82004&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85641" rel="noopener noreferrer"&gt;#85641&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85952" rel="noopener noreferrer"&gt;#85952&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Standard SQL &lt;code&gt;UPDATE&lt;/code&gt;. Insert-like latency. 1,000× to 2,400× faster for single-row updates. Promoted to Beta.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stabilization: phantom-queue fix, secondary-index reconciliation, queue-lock optimization, timezone correctness&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/97162" rel="noopener noreferrer"&gt;#97162&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98044" rel="noopener noreferrer"&gt;#98044&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/95771" rel="noopener noreferrer"&gt;#95771&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/101403" rel="noopener noreferrer"&gt;#101403&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Production hardening. New observability columns. Replicated concurrency improvements.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;When Should You Use Each ClickHouse Update Mechanism?&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;th&gt;Reasoning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single-row UPDATEs from an application&lt;/td&gt;
&lt;td&gt;✅ Lightweight UPDATE (&lt;code&gt;UPDATE ... SET ... WHERE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Insert-like latency, standard SQL syntax, immediate read-after-write visibility (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scattered row-level updates from a service&lt;/td&gt;
&lt;td&gt;✅ Lightweight UPDATE&lt;/td&gt;
&lt;td&gt;Patch parts handle scattered writes far better than classical mutations.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk backfill of a column across millions of rows&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Classical mutation rewrites parts efficiently when the volume justifies the rewrite.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema-level correction (one-off fix for bad data)&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Async, runs in the background, no read-time overhead afterwards.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continuous high-volume CDC / upsert stream&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;ReplacingMergeTree&lt;/code&gt; + &lt;code&gt;FINAL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Engine-level deduplication remains the most efficient path for millions of upserts per second.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Soft delete / mark-as-deleted&lt;/td&gt;
&lt;td&gt;✅ Lightweight DELETE&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_row_exists&lt;/code&gt; mask is a single-column rewrite (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard delete with disk reclamation&lt;/td&gt;
&lt;td&gt;🟡 &lt;code&gt;ALTER TABLE … DELETE&lt;/code&gt; or &lt;code&gt;APPLY DELETED MASK&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Lightweight DELETE leaves data on disk until merge; force physical removal when compliance or reclamation requires it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read-after-write consistency on a queued mutation&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;apply_mutations_on_fly&lt;/code&gt; or &lt;code&gt;mutations_sync&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;On-the-fly application makes pending mutations visible to SELECTs immediately (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update to a primary-key or partition-key column&lt;/td&gt;
&lt;td&gt;❌ Not supported&lt;/td&gt;
&lt;td&gt;Insert into a new table with the desired key and swap. This is structural, not a missing feature.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Updates with non-deterministic functions in replicated tables&lt;/td&gt;
&lt;td&gt;❌ Not supported&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rand()&lt;/code&gt; and &lt;code&gt;now()&lt;/code&gt; would diverge across replicas (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/7247" rel="noopener noreferrer"&gt;PR #7247&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;How to Respond to "ClickHouse Doesn't Support Updates"&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Run the numbers on your data.&lt;/p&gt;

&lt;p&gt;When someone tells you ClickHouse cannot handle updates in 2026, ask them which version they tested against. If they are benchmarking ClickHouse 22.x or earlier, they are testing a system that does not include lightweight DELETE (2022), on-the-fly mutations (early 2025), lightweight UPDATE (mid-2025), patch parts (mid-2025), or the entire 2025 to 2026 stabilization wave.&lt;/p&gt;

&lt;p&gt;If they cite "ClickHouse is append-only" without acknowledging that &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; shipped in v18.12, they are working from 2017 documentation.&lt;/p&gt;

&lt;p&gt;If they cite "no standard SQL UPDATE syntax," they have not read &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt; or the &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-2-sql-style-updates" rel="noopener noreferrer"&gt;Updates in ClickHouse blog series&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If they cite "all updates rewrite entire parts," they are describing one of three update mechanisms (the classical heavyweight one) and ignoring the other two (lightweight DELETE and lightweight UPDATE) plus the engine-level upsert pattern (&lt;code&gt;ReplacingMergeTree&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;If they cite "you need &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt;," they have not run a stable ClickHouse release in years.&lt;/p&gt;

&lt;p&gt;The commit history does not lie. 100+ pull requests across eight years. Standard SQL &lt;code&gt;UPDATE&lt;/code&gt; syntax. Insert-like latency for single-row updates. Production-grade observability. Workload isolation. Bandwidth throttling. Patch-part guardrails. Phantom-queue race conditions fixed in February 2026 by Alexey Milovidov himself.&lt;/p&gt;

&lt;p&gt;ClickHouse's UPDATE subsystem in 2026 bears no resemblance to the one that earned the "append-only" label. The engineers built a real update story, and the evidence is in the PRs.&lt;/p&gt;

&lt;p&gt;Test it on your workload. That is the only benchmark that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse UPDATE FAQ&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does ClickHouse support standard SQL &lt;code&gt;UPDATE&lt;/code&gt;?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. ClickHouse 25.7 (July 2025) added standard SQL &lt;code&gt;UPDATE table SET col = expr WHERE …&lt;/code&gt; for MergeTree-family tables via &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;. It uses a "patch part" architecture and was promoted to Beta with default enablement in version 25.8.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Is ClickHouse append-only?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. ClickHouse stopped being append-only in August 2018, when v18.12 added &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt;. Standard SQL &lt;code&gt;UPDATE&lt;/code&gt; arrived in v25.7 (July 2025). The "append-only" label is accurate only for the 2016 to 2017 era.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Do all ClickHouse UPDATEs rewrite entire parts?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. ClickHouse offers three update paths. Lightweight UPDATE (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82004" rel="noopener noreferrer"&gt;PR #82004&lt;/a&gt;) writes a small patch part containing only changed columns, with no part rewrite. Lightweight DELETE (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/37893" rel="noopener noreferrer"&gt;PR #37893&lt;/a&gt;) rewrites only the &lt;code&gt;_row_exists&lt;/code&gt; virtual column. Classical &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; rewrites affected parts and is the right mechanism for bulk backfills.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Are ClickHouse UPDATEs eventually consistent?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;It depends on the mechanism. Classical &lt;code&gt;ALTER TABLE … UPDATE&lt;/code&gt; is asynchronous by default. Lightweight UPDATE and on-the-fly mutations (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74877" rel="noopener noreferrer"&gt;PR #74877&lt;/a&gt;) provide immediate read-after-write visibility. The &lt;code&gt;mutations_sync&lt;/code&gt; setting forces synchronous semantics on demand. You choose the consistency model per workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is &lt;code&gt;ReplacingMergeTree&lt;/code&gt; and when should you use it?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ReplacingMergeTree&lt;/code&gt; is a ClickHouse engine that resolves duplicates on the sorting key during background merges. Use it for high-volume CDC and upsert workflows: updates are ingested as new rows, and deduplication runs asynchronously. Add the &lt;code&gt;FINAL&lt;/code&gt; keyword to SELECT queries for guaranteed deduplicated reads. &lt;code&gt;FINAL&lt;/code&gt; has been heavily optimized for production use.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is the read-time overhead of ClickHouse patch parts?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Approximately 7% to 18% on average while patches are unmerged, per umbrella issue &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/82033" rel="noopener noreferrer"&gt;#82033&lt;/a&gt;. Background merges fold patches into base parts, after which the overhead disappears. The &lt;code&gt;max_uncompressed_bytes_in_patches&lt;/code&gt; setting (default 30 GiB, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85641" rel="noopener noreferrer"&gt;PR #85641&lt;/a&gt;) caps total patch accumulation.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Do you still need &lt;code&gt;allow_experimental_lightweight_delete&lt;/code&gt; in ClickHouse?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. Lightweight DELETE has been GA for years and is the default &lt;code&gt;DELETE&lt;/code&gt; implementation in modern ClickHouse releases. The experimental flag is no longer required.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can you cancel a stuck UPDATE in ClickHouse?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;KILL MUTATION&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/4287" rel="noopener noreferrer"&gt;PR #4287&lt;/a&gt;, 2019) works on both MergeTree and ReplicatedMergeTree. The &lt;code&gt;system.mutations&lt;/code&gt; table exposes &lt;code&gt;latest_fail_reason&lt;/code&gt;, &lt;code&gt;latest_failed_part&lt;/code&gt;, and &lt;code&gt;latest_fail_time&lt;/code&gt;. Since late 2025, &lt;code&gt;parts_postpone_reasons&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92206" rel="noopener noreferrer"&gt;PR #92206&lt;/a&gt;) tells you exactly why a mutation is not progressing.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can you UPDATE primary-key or partition-key columns in ClickHouse?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. This is structural, not a missing feature. Changing a key column would require rebuilding the part's primary index. The recommended pattern is to insert into a new table with the desired key and swap.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How fast is ClickHouse lightweight UPDATE compared to classical mutations?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Up to 1,000× to 2,400× faster for single-row updates, per ClickHouse's &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-3-benchmarks" rel="noopener noreferrer"&gt;Updates in ClickHouse, Part 3&lt;/a&gt; benchmark blog post. Classical mutation latency is bounded by the merge schedule; lightweight UPDATE has insert-like latency because it writes a small patch part instead of rewriting affected parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does ClickHouse use the new query analyzer for mutations?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Not yet. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/61528" rel="noopener noreferrer"&gt;PR #61528&lt;/a&gt; (2024) explicitly forces &lt;code&gt;MutationsInterpreter&lt;/code&gt; to use the legacy analyzer. The migration is tracked in &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/61563" rel="noopener noreferrer"&gt;issue #61563&lt;/a&gt;, still open in early 2026. This is the largest outstanding planner gap on the UPDATE side.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Analysis based on 100+ GitHub pull requests, official ClickHouse changelogs, and release blog posts covering the period 2018 to April 2026. Every claim maps to a specific merged PR, issue, or blog post. Verify the evidence yourself; the commit history is public.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reference reading: &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-1-purpose-built-engines" rel="noopener noreferrer"&gt;Updates in ClickHouse, Part 1: Purpose-Built Engines&lt;/a&gt; · &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-2-sql-style-updates" rel="noopener noreferrer"&gt;Part 2: SQL-Style Updates&lt;/a&gt; · &lt;a href="https://clickhouse.com/blog/updates-in-clickhouse-3-benchmarks" rel="noopener noreferrer"&gt;Part 3: Benchmarks&lt;/a&gt; · &lt;a href="https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse" rel="noopener noreferrer"&gt;Handling Updates and Deletes in ClickHouse&lt;/a&gt; · &lt;a href="https://clickhouse.com/docs/sql-reference/statements/update" rel="noopener noreferrer"&gt;SQL Reference: UPDATE&lt;/a&gt; · &lt;a href="https://clickhouse.com/docs/updating-data/overview" rel="noopener noreferrer"&gt;Updating Data Overview&lt;/a&gt; · &lt;a href="https://clickhouse.com/docs/guides/replacing-merge-tree" rel="noopener noreferrer"&gt;ReplacingMergeTree Guide&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>analytics</category>
      <category>dataengineering</category>
      <category>sql</category>
    </item>
    <item>
      <title>ClickHouse Native JSON Support in 2026: A PR-by-PR Analysis</title>
      <dc:creator>Manveer Chawla</dc:creator>
      <pubDate>Mon, 20 Apr 2026 20:18:38 +0000</pubDate>
      <link>https://dev.to/dataengineering/clickhouse-native-json-support-in-2026-a-pr-by-pr-analysis-1hdp</link>
      <guid>https://dev.to/dataengineering/clickhouse-native-json-support-in-2026-a-pr-by-pr-analysis-1hdp</guid>
      <description>&lt;h2&gt;
  
  
  &lt;strong&gt;TL;DR&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ClickHouse has full native JSON support, and has since v25.3. The JSON type stores each path as a separate columnar subcolumn with native type preservation, primary key indexing, and selective path reads. It is 2,500x faster than MongoDB for aggregations on the JSONBench 1-billion-document benchmark. The narrative that "ClickHouse can't do JSON" is outdated by two years and 80+ merged PRs.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We analyzed 80+ GitHub pull requests, official ClickHouse changelogs, release blogs, and third-party benchmarks to trace the full evolution of JSON support from string-based functions through the modern native JSON type.
&lt;/li&gt;
&lt;li&gt;In 2021, the criticism had some basis. JSON was stored as opaque String blobs, queried via &lt;code&gt;JSONExtract*&lt;/code&gt; functions that required full column scans on every query. The experimental &lt;code&gt;Object('json')&lt;/code&gt; type shipped in 2022 but suffered from eager type unification, unbounded column explosion, and race conditions.
&lt;/li&gt;
&lt;li&gt;By early 2026, ClickHouse ships a production-ready native JSON type built on three foundational types (Variant, Dynamic, JSON), with configurable path limits, type hints, primary key support for JSON subcolumns, three generations of storage serialization, and a query planner that reads only the specific JSON paths your query needs. None of this requires manual schema management.
&lt;/li&gt;
&lt;li&gt;The single highest-impact storage change is advanced shared data serialization (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;PR #83777&lt;/a&gt;), which delivered &lt;strong&gt;58x faster reads and 3,300x less memory&lt;/strong&gt; for selective path access by introducing per-granule metadata with path indexes.
&lt;/li&gt;
&lt;li&gt;The native JSON type stores each path as a separate Dynamic-typed subcolumn in columnar format. The result: &lt;strong&gt;2,500x faster than MongoDB&lt;/strong&gt; for aggregations, &lt;strong&gt;10x faster than Elasticsearch&lt;/strong&gt;, and &lt;strong&gt;9,000x faster than DuckDB/PostgreSQL&lt;/strong&gt; for analytics on the same dataset, according to the JSONBench benchmark on 1 billion Bluesky documents.
&lt;/li&gt;
&lt;li&gt;The JSON type reached GA in ClickHouse 25.3 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77785" rel="noopener noreferrer"&gt;PR #77785&lt;/a&gt;), with experimental flags removed and the type backported to the LTS release. The legacy &lt;code&gt;Object('json')&lt;/code&gt; type was fully removed in v25.11 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85718" rel="noopener noreferrer"&gt;PR #85718&lt;/a&gt;).
&lt;/li&gt;
&lt;li&gt;Verdict: the "ClickHouse doesn't do JSON" advice referenced a system that no longer exists. The current JSON type is a ground-up columnar implementation that preserves native types, supports primary key indexing, and reads only the paths you query. Repeating the old criticism in 2026 is misinformation.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Why People Still Say "ClickHouse Has No Native JSON Support"&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you've evaluated ClickHouse for semi-structured data, you've heard the warnings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"ClickHouse doesn't support JSON natively"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Flatten JSON into columns manually"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Use JSONExtract functions on String columns"&lt;/em&gt; (as the primary approach)
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Use Object('JSON')"&lt;/em&gt; (deprecated type)
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"No native JSON support"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of these started as legitimate observations circa 2021-2022. ClickHouse did store JSON as Strings. The &lt;code&gt;JSONExtract*&lt;/code&gt; functions did scan the full column. The first attempt at a native type (&lt;code&gt;Object('json')&lt;/code&gt;) did have serious architectural flaws.&lt;/p&gt;

&lt;p&gt;Others were amplified by competitors who found a convenient story: ClickHouse is fast for scans, but it can't handle semi-structured data.&lt;/p&gt;

&lt;p&gt;Then ClickHouse's engineering team spent three years building one of the most sophisticated columnar JSON implementations in any database. Over 80 significant pull requests merged. They built three new foundational types (Variant, Dynamic, JSON), three generations of storage serialization, a query planner that reads only needed subcolumns, primary key and skip index support for JSON paths, and clear migration paths from every legacy representation.&lt;/p&gt;

&lt;p&gt;This article traces that evolution with PR-level evidence. No marketing claims. No benchmarks on toy datasets. Just the commit history.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Methodology: How We Analyzed ClickHouse's JSON Type Commit History&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;We went through ClickHouse's GitHub commit history, pull requests, changelogs, and release blogs from 2019 through early 2026. The scope covered every PR that touched JSON handling: type implementations, storage formats, function changes, planner optimizations, memory improvements, correctness fixes, and migration paths.&lt;/p&gt;

&lt;p&gt;Each PR was classified by category (type system, storage, functions, planner, correctness, migration), impact severity, and whether it changed default behavior. We cross-referenced PR descriptions against changelog entries and benchmark results to verify claimed improvements. Where multiple PRs addressed the same subsystem, we traced the dependency chain to understand how incremental changes compounded.&lt;/p&gt;

&lt;p&gt;The result is a ranked analysis of 80+ pull requests organized into six phases, with full provenance. Every claim in this article maps to a specific merged PR that you can verify yourself on GitHub.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JSON Capabilities in 2026: What Ships by Default&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The current state, as of early 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native JSON data type (GA since v25.3):&lt;/strong&gt; Each JSON path is stored as a separate Dynamic-typed subcolumn in columnar format. Full SQL query, filter, and aggregation support on JSON fields, including nested structures and arrays (&lt;code&gt;Array(JSON)&lt;/code&gt;). Configurable &lt;code&gt;max_dynamic_paths&lt;/code&gt; (default 1024) and &lt;code&gt;max_dynamic_types&lt;/code&gt; (default 32) control resource usage. Known paths can be materialized as physical columns with type hints (&lt;code&gt;JSON(key1 UInt32, key2 String)&lt;/code&gt;), while unknown paths are automatically discovered with type inference. Path filtering via &lt;code&gt;SKIP&lt;/code&gt; and &lt;code&gt;SKIP REGEXP&lt;/code&gt; provides fine-grained schema control.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three foundational types:&lt;/strong&gt; Variant (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58047" rel="noopener noreferrer"&gt;PR #58047&lt;/a&gt;) provides discriminated unions. Dynamic (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/63058" rel="noopener noreferrer"&gt;PR #63058&lt;/a&gt;) extends Variant with open-ended type storage. JSON (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66444" rel="noopener noreferrer"&gt;PR #66444&lt;/a&gt;) combines both to store semi-structured data with native type preservation.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Primary key and skip index support:&lt;/strong&gt; JSON subcolumns can appear in &lt;code&gt;ORDER BY&lt;/code&gt; and data-skipping index expressions (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72644" rel="noopener noreferrer"&gt;PR #72644&lt;/a&gt;), enabling the same data pruning that ClickHouse applies to regular typed columns.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced shared data serialization:&lt;/strong&gt; Per-granule path indexes for selective reads of specific paths without scanning the entire JSON column (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;PR #83777&lt;/a&gt;). Three serialization modes optimized for different access patterns.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planner-level subcolumn optimization:&lt;/strong&gt; The query planner reads only the JSON paths referenced in your query (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/68053" rel="noopener noreferrer"&gt;PR #68053&lt;/a&gt;), pushes subcolumn requirements through CTEs and views (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/94105" rel="noopener noreferrer"&gt;PR #94105&lt;/a&gt;), and rewrites JSONExtract calls into direct subcolumn reads (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96711" rel="noopener noreferrer"&gt;PR #96711&lt;/a&gt;).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full JSONExtract interop:&lt;/strong&gt; All JSONExtract* functions work with native JSON columns (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96711" rel="noopener noreferrer"&gt;PR #96711&lt;/a&gt;). Introspection functions (&lt;code&gt;distinctJSONPaths&lt;/code&gt;, &lt;code&gt;distinctJSONPathsAndTypes&lt;/code&gt;) provide schema discovery from metadata alone (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/68463" rel="noopener noreferrer"&gt;PR #68463&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92196" rel="noopener noreferrer"&gt;PR #92196&lt;/a&gt;).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration from every legacy format:&lt;/strong&gt; &lt;code&gt;ALTER TABLE ... MODIFY COLUMN&lt;/code&gt; converts String, Object('json'), Map, and Tuple columns to the native JSON type (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70442" rel="noopener noreferrer"&gt;PR #70442&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71784" rel="noopener noreferrer"&gt;PR #71784&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71320" rel="noopener noreferrer"&gt;PR #71320&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not experimental features behind flags. They are defaults that ship with every ClickHouse installation since v25.3.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JSON Myths vs. Reality: A 2026 Checklist&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;The FUD&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Evidence Volume&lt;/th&gt;
&lt;th&gt;Reality (2026)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;"No native JSON support"&lt;/td&gt;
&lt;td&gt;False since Aug 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66444" rel="noopener noreferrer"&gt;PR #66444&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77785" rel="noopener noreferrer"&gt;#77785&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Native JSON type stores each path as a separate columnar subcolumn. GA since v25.3.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;"Flatten JSON into columns manually"&lt;/td&gt;
&lt;td&gt;False since Aug 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66444" rel="noopener noreferrer"&gt;PR #66444&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72644" rel="noopener noreferrer"&gt;#72644&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Automatic path flattening into Dynamic-typed subcolumns. No manual schema management.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;"Use JSONExtract on String columns"&lt;/td&gt;
&lt;td&gt;Outdated&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96711" rel="noopener noreferrer"&gt;PR #96711&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66444" rel="noopener noreferrer"&gt;#66444&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;JSONExtract works on native JSON columns and gets rewritten to direct subcolumn reads. No full-column scan.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;"Use Object('JSON')"&lt;/td&gt;
&lt;td&gt;Removed in v25.11&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85718" rel="noopener noreferrer"&gt;PR #85718&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66444" rel="noopener noreferrer"&gt;#66444&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Object('json') was replaced by a ground-up redesign. The old type was fully removed in v25.11.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;"JSON queries require full column scans"&lt;/td&gt;
&lt;td&gt;False since 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/68053" rel="noopener noreferrer"&gt;PR #68053&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;#83777&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/94105" rel="noopener noreferrer"&gt;#94105&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Planner reads only referenced subcolumns. Advanced serialization provides per-granule path indexes. 58x faster, 3,300x less memory.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;"Can't index JSON fields"&lt;/td&gt;
&lt;td&gt;False since Dec 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72644" rel="noopener noreferrer"&gt;PR #72644&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98886" rel="noopener noreferrer"&gt;#98886&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;JSON subcolumns in ORDER BY, primary key, and skip indexes. Bloom/text indexes on JSONAllPaths.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;"JSON types lose type information"&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58047" rel="noopener noreferrer"&gt;PR #58047&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/63058" rel="noopener noreferrer"&gt;#63058&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Variant/Dynamic preserve native types (UInt32, Float64, DateTime, etc.). No String collapse.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;"ClickHouse JSON is slower than document DBs"&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;td&gt;JSONBench (1B docs)&lt;/td&gt;
&lt;td&gt;2,500x faster than MongoDB. 10x faster than Elasticsearch. 9,000x faster than DuckDB/PostgreSQL.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;"No schema discovery for JSON"&lt;/td&gt;
&lt;td&gt;False since Aug 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/68463" rel="noopener noreferrer"&gt;PR #68463&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92196" rel="noopener noreferrer"&gt;#92196&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;distinctJSONPaths()&lt;/code&gt; and &lt;code&gt;distinctJSONPathsAndTypes()&lt;/code&gt; read metadata only. Instant schema views.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;"Can't migrate existing JSON String columns"&lt;/td&gt;
&lt;td&gt;False since Oct 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70442" rel="noopener noreferrer"&gt;PR #70442&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71784" rel="noopener noreferrer"&gt;#71784&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71320" rel="noopener noreferrer"&gt;#71320&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;ALTER TABLE converts String, Object, Map, and Tuple to native JSON. Background merge conversion.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 1: ClickHouse JSON Functions and String Storage (2019-2021)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"Use JSONExtract functions on String columns"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In this era, the criticism was fair. ClickHouse stored JSON as opaque String blobs, and every JSON query required parsing the entire string value.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;ClickHouse JSONExtract Functions: simdjson-Powered but CPU-Heavy (May 2019)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/5235" rel="noopener noreferrer"&gt;PR #5235&lt;/a&gt; introduced the &lt;code&gt;JSONExtract*&lt;/code&gt; function family, powered by simdjson with a RapidJSON fallback. This was a meaningful step: SIMD instructions allowed structural element identification at near-memory-bandwidth speeds.&lt;/p&gt;

&lt;p&gt;But the fundamental limitation remained. Every query, no matter which field it accessed, required scanning and parsing the full JSON string column. There was no way to read just &lt;code&gt;event.user_id&lt;/code&gt; without also reading &lt;code&gt;event.metadata&lt;/code&gt;, &lt;code&gt;event.payload&lt;/code&gt;, and every other field.&lt;/p&gt;

&lt;p&gt;ClickHouse provided two function families with different trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;simpleJSON&lt;/code&gt; / &lt;code&gt;visitParam&lt;/code&gt;: Minimalist heuristic parsing with low CPU overhead, but strict assumptions about canonical encoding and no nested object support.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JSONExtract*&lt;/code&gt;: Full simdjson-powered parsing with standards-compliant extraction, but high per-row CPU cost from full document parsing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither approach could avoid the core problem: 100% column scan for every query.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;SQL/JSON Standard Functions (Mid-2021)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/24148" rel="noopener noreferrer"&gt;PR #24148&lt;/a&gt; added &lt;code&gt;JSON_VALUE&lt;/code&gt;, &lt;code&gt;JSON_QUERY&lt;/code&gt;, and &lt;code&gt;JSON_EXISTS&lt;/code&gt; with JSONPath expression support, bringing ClickHouse closer to SQL/JSON standard compliance. This improved SQL compatibility but did not change the underlying storage model. JSON was still strings.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Map(String, String): A Partial Improvement&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Map(String, String)&lt;/code&gt; type offered some improvement by storing JSON key-value pairs natively, eliminating the need for string parsing on every access. But it still required reading all keys to find one entry, and it lost all type information by collapsing everything to strings.&lt;/p&gt;

&lt;p&gt;By the end of 2021, ClickHouse had capable JSON parsing functions but no native JSON storage. The gap was real, and the engineering team knew it.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 2: ClickHouse Object('json') Type -- What Went Wrong (2022)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"Use Object('JSON')"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/23932" rel="noopener noreferrer"&gt;PR #23932&lt;/a&gt;, merged March 2022 by Anton Popov, was the first attempt at native columnar JSON storage. It shipped in ClickHouse 22.3 LTS under &lt;code&gt;allow_experimental_object_type&lt;/code&gt;. The implementation spanned 101 commits and proved a critical concept: JSON could be stored with each path as a separate subcolumn.&lt;/p&gt;

&lt;p&gt;But it had serious architectural flaws:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Challenge&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;th&gt;Consequence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Eager Type Unification&lt;/td&gt;
&lt;td&gt;Mixed types at a path collapsed to String&lt;/td&gt;
&lt;td&gt;Lost native type optimizations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata Explosion&lt;/td&gt;
&lt;td&gt;High memory for many unique keys&lt;/td&gt;
&lt;td&gt;System instability with high-cardinality JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Race Conditions&lt;/td&gt;
&lt;td&gt;Inconsistent results during merges&lt;/td&gt;
&lt;td&gt;Unreliable query analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema Rigidity&lt;/td&gt;
&lt;td&gt;Inability to handle type changes&lt;/td&gt;
&lt;td&gt;Required manual ALTER or table rewrites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No Primary Key Support&lt;/td&gt;
&lt;td&gt;JSON paths excluded from ORDER BY&lt;/td&gt;
&lt;td&gt;No data pruning on JSON fields&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Despite these flaws, &lt;code&gt;Object('json')&lt;/code&gt; validated the demand for native JSON storage and identified every architectural challenge the replacement would need to solve.&lt;/p&gt;

&lt;p&gt;Alongside the type work, ClickHouse continued improving JSON ecosystem support. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/40910" rel="noopener noreferrer"&gt;PR #40910&lt;/a&gt; introduced the &lt;code&gt;JSONObjectEachRow&lt;/code&gt; format for keyed JSON objects. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/39186" rel="noopener noreferrer"&gt;PR #39186&lt;/a&gt; added automatic type inference from JSON strings, detecting dates, datetimes, and integers by default. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/54427" rel="noopener noreferrer"&gt;PR #54427&lt;/a&gt; enabled schema inference of JSON objects as named Tuples.&lt;/p&gt;

&lt;p&gt;These format and inference improvements meant ClickHouse was getting better at ingesting JSON data. What it still lacked was a sound way to store it.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 3: ClickHouse Variant and Dynamic Types -- The JSON Foundation (2024)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"JSON types lose type information"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Rather than patching Object('json'), ClickHouse built from first principles. The redesign started with two new foundational types that solved the type-preservation problem that had plagued the original implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Variant: Discriminated Union Type (January 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58047" rel="noopener noreferrer"&gt;PR #58047&lt;/a&gt;, by Pavel Kruglov, introduced &lt;code&gt;Variant(T1, T2, ..., TN)&lt;/code&gt;, a discriminated union storing values of different types in a single column. It uses a UInt8 discriminator column plus dense subcolumns per type variant, supporting up to 255 variants. The PR included 47 commits and roughly 5,000 lines of tests.&lt;/p&gt;

&lt;p&gt;This solved the type-unification problem that killed Object('json'). Instead of collapsing &lt;code&gt;42&lt;/code&gt; and &lt;code&gt;"hello"&lt;/code&gt; at the same path into String, Variant stores them in their native types with a discriminator indicating which type each row contains.&lt;/p&gt;

&lt;p&gt;A follow-up optimization (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62774" rel="noopener noreferrer"&gt;PR #62774&lt;/a&gt;) introduced compact discriminator serialization: when all discriminators in a granule are the same type (the common case for JSON paths), it stores 3 values instead of 8,192. This is highly effective in practice since most JSON paths have homogeneous types within a granule.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Dynamic: Open-Ended Type Storage (May 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/63058" rel="noopener noreferrer"&gt;PR #63058&lt;/a&gt;, also by Pavel Kruglov, extended Variant with an open, self-describing type set. Dynamic has a &lt;code&gt;max_types&lt;/code&gt; parameter (default 32); the most frequent types get their own Variant slots, and overflow types are stored in a SharedVariant as binary-encoded strings. This provided the flexibility that JSON demands without the unbounded explosion that doomed Object('json').&lt;/p&gt;

&lt;p&gt;The PR included 39 commits and introduced the &lt;code&gt;dynamicType()&lt;/code&gt; introspection function. A &lt;code&gt;dynamic_structure.bin&lt;/code&gt; metadata file per data part tracks the type composition.&lt;/p&gt;

&lt;p&gt;These two types, Variant and Dynamic, were the architectural foundation. The JSON type would combine them both.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 4: ClickHouse Native JSON Type Implementation (2024)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"ClickHouse doesn't support JSON natively"&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How ClickHouse Implemented the Native JSON Data Type: PR #66444 (August 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66444" rel="noopener noreferrer"&gt;PR #66444&lt;/a&gt; is the single most important commit in ClickHouse's JSON evolution. Authored by Pavel Kruglov, it implements the entirely new JSON data type in 91 commits, closing &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/54864" rel="noopener noreferrer"&gt;RFC #54864&lt;/a&gt; ("Semistructured Columns") authored by Alexey Milovidov.&lt;/p&gt;

&lt;p&gt;The design works as follows. JSON paths are flattened into individual Dynamic-typed subcolumns, each stored in separate column files per data part. Paths exceeding &lt;code&gt;max_dynamic_paths&lt;/code&gt; (default 1024) overflow into a shared data structure. The type supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full SQL support:&lt;/strong&gt; Query, filter, and aggregate on any JSON field using standard SQL. Nested structures and arrays are first-class citizens.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configurable limits:&lt;/strong&gt; &lt;code&gt;max_dynamic_paths&lt;/code&gt; (default 1024) and &lt;code&gt;max_dynamic_types&lt;/code&gt; (default 32) control resource usage
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Materialized known paths:&lt;/strong&gt; Type hints like &lt;code&gt;JSON(key1 UInt32, key2 String)&lt;/code&gt; materialize known paths as physical typed columns for maximum performance, while unknown paths are automatically created with type inference as they are discovered
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path filtering:&lt;/strong&gt; &lt;code&gt;SKIP&lt;/code&gt; and &lt;code&gt;SKIP REGEXP&lt;/code&gt; to exclude noisy paths from columnar storage
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dot-notation access:&lt;/strong&gt; &lt;code&gt;json.a.b&lt;/code&gt; for direct path reads
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-object access:&lt;/strong&gt; &lt;code&gt;json.^prefix&lt;/code&gt; for extracting JSON subtrees
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Array(JSON) support:&lt;/strong&gt; Nested structures and arrays of JSON documents
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Efficient data skipping on dynamic paths:&lt;/strong&gt; JSON subcolumns in primary keys and skip indexes enable granule-level pruning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First shipped in ClickHouse 24.8 LTS under &lt;code&gt;allow_experimental_json_type&lt;/code&gt;. The official blog post "How we built a new powerful JSON data type for ClickHouse" (October 2024) detailed the architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;20x Memory Reduction for Inserts (September 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/69272" rel="noopener noreferrer"&gt;PR #69272&lt;/a&gt; addressed a critical production concern: memory consumption during JSON inserts. Before this PR, inserting JSON data consumed 6.99 GiB of memory. After, 354 MiB. A 20x reduction.&lt;/p&gt;

&lt;p&gt;The fix was adaptive write buffer sizing. Buffers start at 16 KiB and grow exponentially to a maximum of 1 MiB, selectively enabled for dynamic substreams. S3 inserts improved from 23.13 GiB to 7.65 GiB. No throughput regression.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;ALTER String to JSON + Serialization V2 (October 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70442" rel="noopener noreferrer"&gt;PR #70442&lt;/a&gt; delivered two major changes. First, &lt;code&gt;ALTER TABLE ... MODIFY COLUMN col JSON&lt;/code&gt; to convert existing String columns to the JSON type. Conversion happens during background merges, so there is no downtime. Second, Serialization V2 for JSON and Dynamic types with an improved binary layout.&lt;/p&gt;

&lt;p&gt;This was the beginning of clear migration paths. Teams no longer had to reimport data to adopt native JSON.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;JSONExtract Refactoring for Native JSON (July 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66046" rel="noopener noreferrer"&gt;PR #66046&lt;/a&gt; refactored the JSONExtract function family to work with the new type, splitting the implementation into reusable &lt;code&gt;JSONExtractTree.h/cpp&lt;/code&gt; components and adding Dynamic type support. This ensured that existing queries using JSONExtract would continue to work when columns migrated to native JSON.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Introspection Functions (August 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/68463" rel="noopener noreferrer"&gt;PR #68463&lt;/a&gt; added &lt;code&gt;distinctDynamicTypes()&lt;/code&gt;, &lt;code&gt;distinctJSONPaths()&lt;/code&gt;, and &lt;code&gt;distinctJSONPathsAndTypes()&lt;/code&gt;. These are essential schema discovery tools for semi-structured data. They were later optimized in &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92196" rel="noopener noreferrer"&gt;PR #92196&lt;/a&gt; to read only metadata files instead of scanning actual data, making schema diversity views effectively instant.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Subcolumn Optimization Enabled by Default (August 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/68053" rel="noopener noreferrer"&gt;PR #68053&lt;/a&gt; enabled &lt;code&gt;optimize_functions_to_subcolumns&lt;/code&gt; by default. This planner optimization rewrites function calls to read only the specific subcolumns needed, which is transformative for JSON queries. A query accessing &lt;code&gt;json.user.id&lt;/code&gt; reads only that subcolumn's data, not the entire JSON column.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Beta Promotion (November 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72294" rel="noopener noreferrer"&gt;PR #72294&lt;/a&gt; moved JSON, Dynamic, and Variant to beta status, backported to 24.11. This signaled production readiness for early adopters.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;JSON Subcolumns in Primary Key and Skip Indexes (December 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72644" rel="noopener noreferrer"&gt;PR #72644&lt;/a&gt; was a milestone for performance. It enabled JSON subcolumns (&lt;code&gt;json.path.to.key&lt;/code&gt;) in &lt;code&gt;ORDER BY&lt;/code&gt; expressions and data-skipping index definitions. This means ClickHouse applies the same data pruning to JSON fields that it applies to regular typed columns.&lt;/p&gt;

&lt;p&gt;The JSONBench benchmark uses this capability for sub-second queries over 1 billion documents. Without it, JSON columns could not participate in ClickHouse's primary mechanism for reducing scan ranges.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Migration Paths from Every Legacy Format&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;By the end of 2024, clear migration routes existed for every semi-structured representation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source Type&lt;/th&gt;
&lt;th&gt;Migration Method&lt;/th&gt;
&lt;th&gt;PR&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;String&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALTER TABLE ... MODIFY COLUMN ... JSON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70442" rel="noopener noreferrer"&gt;#70442&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Background merge conversion; &lt;code&gt;ALTER UPDATE&lt;/code&gt; for immediate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Map(String, String)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CAST(col AS JSON)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71320" rel="noopener noreferrer"&gt;#71320&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Serialize-then-parse roundtrip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Tuple&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CAST(col AS JSON)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71320" rel="noopener noreferrer"&gt;#71320&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Serialize-then-parse roundtrip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Object('json')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALTER TABLE ... MODIFY COLUMN ... JSON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71784" rel="noopener noreferrer"&gt;#71784&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Must complete before upgrading past v25.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JSON(params_A)&lt;/code&gt; to &lt;code&gt;JSON(params_B)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CAST&lt;/code&gt; or &lt;code&gt;ALTER&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72303" rel="noopener noreferrer"&gt;#72303&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Change max_dynamic_paths, SKIP rules, type hints&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 5: ClickHouse JSON Reaches GA -- Performance and Storage Optimizations (2025)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"JSON queries require full column scans"&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;ClickHouse JSON Production-Ready: GA in v25.3 (March 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77785" rel="noopener noreferrer"&gt;PR #77785&lt;/a&gt;, authored by Alexey Milovidov and expanded by Pavel Kruglov, removed all experimental and beta gates for JSON, Dynamic, and Variant. The commit message references &lt;a href="https://jsonbench.com/" rel="noopener noreferrer"&gt;https://jsonbench.com/&lt;/a&gt;. Backported to ClickHouse 25.3 LTS via cherry-pick PRs &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77974" rel="noopener noreferrer"&gt;#77974&lt;/a&gt; and &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77975" rel="noopener noreferrer"&gt;#77975&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The 25.3 release blog stated: "About 1.5 years ago, we weren't happy with our JSON implementation, so we returned to the drawing board."&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;63x Memory Reduction for Read Prefetches (March 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77640" rel="noopener noreferrer"&gt;PR #77640&lt;/a&gt; addressed memory consumption during read-ahead prefetches of JSON columns in Wide parts. Before: &lt;code&gt;SELECT * WHERE y=1&lt;/code&gt; on 1 million rows with 1,000 JSON paths consumed 69.16 GiB peak memory. After: 1.11 GiB. A 63x reduction.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;4-10x Faster S3 Reads (February 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74827" rel="noopener noreferrer"&gt;PR #74827&lt;/a&gt; introduced prefetches for subcolumn prefix deserialization, a cache for deserialized prefixes, and parallel prefix deserialization for JSON columns on S3. The result: 4x faster full scans and roughly 10x faster &lt;code&gt;LIMIT 10&lt;/code&gt; queries on remote storage. This introduced &lt;code&gt;MergeTreePrefixesDeserializationThreadPool&lt;/code&gt; and benefits any remote filesystem with similar latency characteristics.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;58x Faster Selective Reads: Advanced Shared Data Serialization (August 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;PR #83777&lt;/a&gt; is the most impactful storage optimization in the JSON type's history. It introduced three serialization modes for shared data (the overflow storage for paths beyond &lt;code&gt;max_dynamic_paths&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Read Latency&lt;/th&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;th&gt;Write Cost&lt;/th&gt;
&lt;th&gt;Ideal Use Case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;High for subcolumns&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Writing data, reading whole JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map_with_buckets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Balanced workloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;advanced&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Low for subcolumns&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Reading specific paths&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;advanced&lt;/code&gt; mode creates per-granule &lt;code&gt;.structure&lt;/code&gt;, &lt;code&gt;.data&lt;/code&gt;, and &lt;code&gt;.paths_marks&lt;/code&gt; files with a path index that enables direct lookup of specific paths without scanning the entire shared data structure.&lt;/p&gt;

&lt;p&gt;The benchmarks speak for themselves. Reading a single key from 200,000 rows with 10,000 unique paths improved from 3.63s / 12.53 GiB to 0.063s / 3.89 MiB. That is &lt;strong&gt;58x faster and 3,300x less memory&lt;/strong&gt;. For Compact parts, non-existing key reads improved from 3.4s to 0.3s (roughly 11x faster), memory from 517 MiB to 3.7 MiB (roughly 140x reduction).&lt;/p&gt;

&lt;p&gt;This PR contained 47 commits and is documented in the official ClickHouse blog "Making complex JSON 58x faster, use 3,300x less memory."&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Substream Marks in Compact Parts (March 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77940" rel="noopener noreferrer"&gt;PR #77940&lt;/a&gt; added marks for individual substreams within compact parts, extending selective subcolumn read efficiency to the compact storage format. Previously, reading any subcolumn from a compact part required reading the entire part.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Experimental Settings Obsoleted (v25.8)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85934" rel="noopener noreferrer"&gt;PR #85934&lt;/a&gt; marked the experimental and beta JSON settings as obsolete. JSON was now unconditionally enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Legacy Object('json') Fully Removed (November 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85718" rel="noopener noreferrer"&gt;PR #85718&lt;/a&gt; removed the deprecated &lt;code&gt;Object('json')&lt;/code&gt; implementation entirely. 270 files changed. &lt;code&gt;ColumnObjectDeprecated&lt;/code&gt;, &lt;code&gt;DataTypeObjectDeprecated&lt;/code&gt;, deprecated serialization files, the &lt;code&gt;JSONDataParser&lt;/code&gt;, and all legacy tests were deleted. This was backward-incompatible: any tables or queries referencing &lt;code&gt;Object('json')&lt;/code&gt; must be migrated before upgrading past v25.11.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 6: ClickHouse JSON Query Planner and JSONExtract Interop (2026)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"JSONExtract on String columns is the primary approach"&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;ClickHouse JSONExtract Now Works with Native JSON Columns (2026)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96711" rel="noopener noreferrer"&gt;PR #96711&lt;/a&gt;, by Fisnik Kastrati, extended all JSONExtract*, JSONHas, JSONLength, and JSONType functions to accept native JSON columns directly. More importantly, it introduced a &lt;code&gt;FunctionToSubcolumnsPass&lt;/code&gt; planner optimization that rewrites constant-path JSONExtract calls into direct subcolumn reads.&lt;/p&gt;

&lt;p&gt;This means existing queries that use &lt;code&gt;JSONExtractString(json_col, 'user', 'name')&lt;/code&gt; now bypass text parsing entirely when the column is a native JSON type. The planner rewrites the call to a direct subcolumn read of &lt;code&gt;json_col.user.name&lt;/code&gt;. This closed &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/88370" rel="noopener noreferrer"&gt;issue #88370&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Optimized has(JSON, path) Function (2026)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96927" rel="noopener noreferrer"&gt;PR #96927&lt;/a&gt; added an optimized &lt;code&gt;has(json_col, 'path')&lt;/code&gt; function for fast path-existence checks without text parsing. This is essential for queries that filter based on whether a JSON path exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;SubcolumnPushdownPass in Query Planner (January 2026)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/94105" rel="noopener noreferrer"&gt;PR #94105&lt;/a&gt; introduced &lt;code&gt;SubcolumnPushdownPass&lt;/code&gt;, which pushes subcolumn requirements through CTEs and views. This means wrapping a JSON table in a view or CTE no longer defeats subcolumn optimizations.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Skip Indexes on JSONAllPaths (April 2026)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98886" rel="noopener noreferrer"&gt;PR #98886&lt;/a&gt; enabled bloom and text skip indexes on &lt;code&gt;JSONAllPaths()&lt;/code&gt;, allowing efficient filtering on JSON key presence. This gives ClickHouse the ability to skip entire granules when querying for documents that contain (or don't contain) specific paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;SIMD Tokenizer Refactoring (2026)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/97871" rel="noopener noreferrer"&gt;PR #97871&lt;/a&gt;, by Amos Bird, refactored the tokenizer to a SIMD-ready stateful API, replacing the older iterator API. This lays the groundwork for continued parsing performance improvements.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Combined Subcolumn Access (2026)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98788" rel="noopener noreferrer"&gt;PR #98788&lt;/a&gt; introduced a unified combined subcolumn that returns Dynamic for both scalar and object access at a path. This simplifies queries that need to handle paths where the value might be a scalar or a nested object.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JSON Performance Benchmarks: MongoDB, Elasticsearch, and DuckDB Compared&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The JSON type's performance has been validated by both ClickHouse's internal benchmarks and independent third-party testing. The numbers come from specific, verifiable sources.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;JSONBench: 1 Billion Bluesky Documents (January 2025)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The JSONBench benchmark (&lt;a href="https://jsonbench.com/" rel="noopener noreferrer"&gt;https://jsonbench.com/&lt;/a&gt;) tested the native JSON type against other databases on 1 billion Bluesky social media documents on a single m6i.8xlarge node:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;ClickHouse Advantage&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vs MongoDB aggregations&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2,500x faster&lt;/strong&gt; (405ms vs ~16 min)&lt;/td&gt;
&lt;td&gt;JSONBench&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vs Elasticsearch aggregations&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10x faster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONBench&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vs DuckDB/PostgreSQL analytics&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9,000x faster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONBench&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage vs compressed files&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;20% more compact&lt;/strong&gt; (same algorithm)&lt;/td&gt;
&lt;td&gt;JSONBench&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage vs MongoDB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40% more efficient&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONBench&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak memory (1B document count)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 3 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONBench&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A follow-up benchmark in March 2025 scaled to 4 billion+ documents (1.6 TiB), achieving 91.84 million docs/sec throughput with sub-100ms queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Storage and Memory Improvements&lt;/strong&gt;
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Optimization&lt;/th&gt;
&lt;th&gt;PR&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Insert memory&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/69272" rel="noopener noreferrer"&gt;#69272&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;6.99 GiB&lt;/td&gt;
&lt;td&gt;354 MiB&lt;/td&gt;
&lt;td&gt;20x reduction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 insert memory&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/69272" rel="noopener noreferrer"&gt;#69272&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;23.13 GiB&lt;/td&gt;
&lt;td&gt;7.65 GiB&lt;/td&gt;
&lt;td&gt;3x reduction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read prefetch memory&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77640" rel="noopener noreferrer"&gt;#77640&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;69.16 GiB&lt;/td&gt;
&lt;td&gt;1.11 GiB&lt;/td&gt;
&lt;td&gt;63x reduction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Selective read latency&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;#83777&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;3.63s&lt;/td&gt;
&lt;td&gt;0.063s&lt;/td&gt;
&lt;td&gt;58x faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Selective read memory&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;#83777&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;12.53 GiB&lt;/td&gt;
&lt;td&gt;3.89 MiB&lt;/td&gt;
&lt;td&gt;3,300x reduction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 full scan&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74827" rel="noopener noreferrer"&gt;#74827&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;4x faster&lt;/td&gt;
&lt;td&gt;4x improvement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 LIMIT 10&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74827" rel="noopener noreferrer"&gt;#74827&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;~10x faster&lt;/td&gt;
&lt;td&gt;~10x improvement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Third-Party Validation&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;SigNoz, a ClickHouse-based observability platform, reported 30% faster log queries with the native JSON type. ClickHouse's own observability stack (ClickStack) demonstrated 9x faster queries compared to the previous Map-based approach for OpenTelemetry log attributes.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JSON for OpenTelemetry and Log Analytics: A Real-World Use Case&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The observability domain is where the JSON type's impact is most visible. Before native JSON, log management solutions built on ClickHouse flattened attributes into &lt;code&gt;Map(String, String)&lt;/code&gt; columns, losing type information. Queries like &lt;code&gt;SUM(LogAttributes.response_size)&lt;/code&gt; required explicit casts on every access.&lt;/p&gt;

&lt;p&gt;With the native JSON type, OpenTelemetry log attributes preserve their native types. The performance difference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Legacy (Map)&lt;/th&gt;
&lt;th&gt;Modern (JSON)&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;I/O Efficiency&lt;/td&gt;
&lt;td&gt;Read entire column&lt;/td&gt;
&lt;td&gt;Read specific path subcolumns&lt;/td&gt;
&lt;td&gt;Reduced disk I/O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory Footprint&lt;/td&gt;
&lt;td&gt;High (String parsing)&lt;/td&gt;
&lt;td&gt;Low (Columnar access)&lt;/td&gt;
&lt;td&gt;Lower peak memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema Migration&lt;/td&gt;
&lt;td&gt;Manual ALTER TABLE&lt;/td&gt;
&lt;td&gt;Fully Automatic&lt;/td&gt;
&lt;td&gt;Simplified operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregation Speed&lt;/td&gt;
&lt;td&gt;Slow (Cast required)&lt;/td&gt;
&lt;td&gt;Native (No cast)&lt;/td&gt;
&lt;td&gt;Up to 10x faster queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JSON Limitations and Trade-offs in 2026&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Fairness matters. A few things still require awareness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Path explosion requires attention.&lt;/strong&gt; Without appropriate &lt;code&gt;max_dynamic_paths&lt;/code&gt; settings and SKIP rules, high-cardinality JSON (thousands of unique paths per document) can create many subcolumns. Set limits that match your schema shape, and use &lt;code&gt;SKIP REGEXP&lt;/code&gt; for noisy paths.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subcolumn pruning through &lt;code&gt;SELECT *&lt;/code&gt; in CTEs is not yet supported.&lt;/strong&gt; &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/92455" rel="noopener noreferrer"&gt;Issue #92455&lt;/a&gt; documents this gap. Explicitly name columns in CTEs over JSON tables for now.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy Object('json') migration is mandatory.&lt;/strong&gt; &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85718" rel="noopener noreferrer"&gt;PR #85718&lt;/a&gt; enforces a hard removal. Post-upgrade to v25.12+, any tables or queries referencing Object('json') will fail. Audit schemas and run ALTER before upgrading past v25.11.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correctness fixes are ongoing.&lt;/strong&gt; Edge cases in JSONExtract interop (&lt;a href="https://github.com/ClickHouse/ClickHouse/issues/102018" rel="noopener noreferrer"&gt;issue #102018&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/102079" rel="noopener noreferrer"&gt;#102079&lt;/a&gt;), default value handling (&lt;a href="https://github.com/clickhouse/clickhouse/issues/101721" rel="noopener noreferrer"&gt;issue #101721&lt;/a&gt;), and specific format combinations (&lt;a href="https://github.com/ClickHouse/ClickHouse/issues/101911" rel="noopener noreferrer"&gt;issue #101911&lt;/a&gt;) show that a system this complex requires staying on the latest stable release.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced shared data mode trades write cost for read performance.&lt;/strong&gt; The per-granule path indexes that enable 58x faster reads add write overhead. For write-heavy workloads with infrequent selective reads, the simpler &lt;code&gt;map&lt;/code&gt; mode may be more appropriate.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type hints and path configuration require understanding your data.&lt;/strong&gt; The defaults work well for moderate schemas (up to 1,024 unique paths). Workloads with tens of thousands of unique paths need tuning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are real engineering trade-offs, and understanding them is part of making an informed decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JSON Evolution Timeline (2019-2026)&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;What Changed&lt;/th&gt;
&lt;th&gt;Key PRs&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2019&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONExtract function family with simdjson&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/5235" rel="noopener noreferrer"&gt;#5235&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;SIMD-accelerated extraction from String columns. Full column scan required.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2021&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQL/JSON standard functions&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/24148" rel="noopener noreferrer"&gt;#24148&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;JSONPath support. Still string-based storage.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2022&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Object('json') first attempt. Format ecosystem.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/23932" rel="noopener noreferrer"&gt;#23932&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/40910" rel="noopener noreferrer"&gt;#40910&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/39186" rel="noopener noreferrer"&gt;#39186&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Proved columnar JSON concept. Architectural flaws identified. Schema inference improved.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2024 H1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Variant and Dynamic types. Compact discriminators.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/58047" rel="noopener noreferrer"&gt;#58047&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/63058" rel="noopener noreferrer"&gt;#63058&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62774" rel="noopener noreferrer"&gt;#62774&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Type-preserving foundation built. Efficient storage for homogeneous granules.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2024 H2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Native JSON type. 20x insert memory. Serialization V2. Primary key support. Beta.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66444" rel="noopener noreferrer"&gt;#66444&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/69272" rel="noopener noreferrer"&gt;#69272&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70442" rel="noopener noreferrer"&gt;#70442&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72644" rel="noopener noreferrer"&gt;#72644&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72294" rel="noopener noreferrer"&gt;#72294&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Complete native JSON type ships. Migration paths established. JSON paths in ORDER BY.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GA. 63x read memory. 58x selective reads. S3 optimization. Legacy removal.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77785" rel="noopener noreferrer"&gt;#77785&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77640" rel="noopener noreferrer"&gt;#77640&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;#83777&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/74827" rel="noopener noreferrer"&gt;#74827&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85718" rel="noopener noreferrer"&gt;#85718&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Production-ready. Advanced shared data. 2,500x vs MongoDB. Legacy Object removed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JSONExtract interop. Planner intelligence. Skip indexes on paths.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96711" rel="noopener noreferrer"&gt;#96711&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/94105" rel="noopener noreferrer"&gt;#94105&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98886" rel="noopener noreferrer"&gt;#98886&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96927" rel="noopener noreferrer"&gt;#96927&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Full function compatibility. Subcolumn pushdown through CTEs. Bloom indexes on JSON paths.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;When Should You Use the Native JSON Type in ClickHouse?&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Reasoning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Log and event analytics with semi-structured attributes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Native type preserves types, subcolumn reads minimize I/O, primary key support enables data pruning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenTelemetry / observability data&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Purpose-built for this. ClickStack validates 9x faster queries vs Map approach&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON documents with known high-value fields&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Use type hints for critical paths, SKIP rules for noisy paths, ORDER BY on key fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema-on-read analytics over heterogeneous JSON&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Dynamic type handles mixed schemas. &lt;code&gt;distinctJSONPaths()&lt;/code&gt; provides instant schema discovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrating from MongoDB/Elasticsearch for analytics&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;2,500x faster aggregations (MongoDB), 10x faster (Elasticsearch). Clear migration via ALTER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON with 10,000+ unique paths per document&lt;/td&gt;
&lt;td&gt;Depends&lt;/td&gt;
&lt;td&gt;Set appropriate &lt;code&gt;max_dynamic_paths&lt;/code&gt;. Use SKIP REGEXP for noisy paths. Advanced shared data helps but requires tuning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write-heavy JSON ingestion with rare reads&lt;/td&gt;
&lt;td&gt;Depends&lt;/td&gt;
&lt;td&gt;Simpler serialization modes (&lt;code&gt;map&lt;/code&gt;) may be more appropriate than &lt;code&gt;advanced&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing String/Map JSON columns&lt;/td&gt;
&lt;td&gt;Yes, migrate&lt;/td&gt;
&lt;td&gt;ALTER TABLE converts in background. No downtime. Immediate query performance improvement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;How to Respond When Someone Says "ClickHouse Doesn't Support JSON"&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Run the PR and &lt;a href="https://clickhouse.com/blog/json-bench-clickhouse-vs-mongodb-elasticsearch-duckdb-postgresql" rel="noopener noreferrer"&gt;benchmark&lt;/a&gt; numbers.&lt;/p&gt;

&lt;p&gt;When someone tells you ClickHouse can't handle JSON in 2026, ask them if they've tested against a version that includes the native JSON type (GA since v25.3, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77785" rel="noopener noreferrer"&gt;PR #77785&lt;/a&gt;), primary key support for JSON subcolumns (v24.12, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72644" rel="noopener noreferrer"&gt;PR #72644&lt;/a&gt;), advanced shared data serialization (v25.8, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;PR #83777&lt;/a&gt;), or JSONExtract interop with native JSON columns (v26.2, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96711" rel="noopener noreferrer"&gt;PR #96711&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;If they're referencing &lt;code&gt;Object('json')&lt;/code&gt;, that type was removed in v25.11. If they're recommending JSONExtract on String columns as the primary approach, the native JSON type has made that unnecessary since v24.8. If they're telling you to flatten JSON into columns manually, the type does this automatically with configurable limits and type hints.&lt;/p&gt;

&lt;p&gt;The commit history doesn't lie. 80+ pull requests. Three foundational types. Three generations of storage serialization. Primary key indexing. Planner-level subcolumn optimization. 2,500x faster than MongoDB on real-world data.&lt;/p&gt;

&lt;p&gt;ClickHouse's JSON implementation in 2026 bears no resemblance to the string-based functions and experimental Object type that earned those early warnings. The engineers built a ground-up columnar JSON storage system, and the evidence is in the PRs.&lt;/p&gt;

&lt;p&gt;Test it on your workload. That's the only benchmark that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JSON FAQ&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does ClickHouse support JSON natively in 2026?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. ClickHouse's native JSON type (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/66444" rel="noopener noreferrer"&gt;PR #66444&lt;/a&gt;) stores each JSON path as a separate Dynamic-typed subcolumn in columnar format. It reached GA in v25.3 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/77785" rel="noopener noreferrer"&gt;PR #77785&lt;/a&gt;) with all experimental flags removed. The legacy Object('json') type was fully removed in v25.11 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85718" rel="noopener noreferrer"&gt;PR #85718&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is the most impactful ClickHouse JSON optimization?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Advanced shared data serialization (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;PR #83777&lt;/a&gt;), which delivers 58x faster reads and 3,300x less memory for selective path access. For insert workloads, adaptive write buffers (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/69272" rel="noopener noreferrer"&gt;PR #69272&lt;/a&gt;) with 20x memory reduction are equally important.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;ClickHouse vs MongoDB vs Elasticsearch for JSON: Which Is Faster?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;On the JSONBench benchmark (1 billion Bluesky documents, single node), ClickHouse with the native JSON type is 2,500x faster than MongoDB for aggregations, 10x faster than Elasticsearch, and 9,000x faster than DuckDB/PostgreSQL for analytics. Storage is 20% more compact than compressed files and 40% more efficient than MongoDB.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Should I migrate from JSONExtract on String columns to the native JSON type?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;ALTER TABLE ... MODIFY COLUMN col JSON&lt;/code&gt; converts String columns to native JSON during background merges with no downtime (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70442" rel="noopener noreferrer"&gt;PR #70442&lt;/a&gt;). After migration, queries read only the paths they need instead of scanning the full string. JSONExtract functions continue to work on native JSON columns (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96711" rel="noopener noreferrer"&gt;PR #96711&lt;/a&gt;) and get rewritten to direct subcolumn reads by the planner.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What happened to ClickHouse Object('json') and how to migrate?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Object('json') (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/23932" rel="noopener noreferrer"&gt;PR #23932&lt;/a&gt;) was ClickHouse's first attempt at native JSON storage, shipped in 2022. It suffered from type unification issues, metadata explosion, and race conditions. Rather than patching it, ClickHouse built an entirely new implementation from first principles using Variant, Dynamic, and JSON types. Object('json') was fully removed in v25.11 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85718" rel="noopener noreferrer"&gt;PR #85718&lt;/a&gt;). Tables using it must be migrated via &lt;code&gt;ALTER TABLE ... MODIFY COLUMN ... JSON&lt;/code&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71784" rel="noopener noreferrer"&gt;PR #71784&lt;/a&gt;) before upgrading.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can I use JSON fields in ClickHouse primary keys and indexes?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes, since v24.12. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72644" rel="noopener noreferrer"&gt;PR #72644&lt;/a&gt; enables JSON subcolumns in &lt;code&gt;ORDER BY&lt;/code&gt; and data-skipping index expressions. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98886" rel="noopener noreferrer"&gt;PR #98886&lt;/a&gt; adds bloom and text skip indexes on &lt;code&gt;JSONAllPaths()&lt;/code&gt; for efficient key-presence filtering.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How does ClickHouse handle high-cardinality JSON with thousands of paths?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;max_dynamic_paths&lt;/code&gt; parameter (default 1024) controls how many paths get their own columnar subcolumn. Paths beyond this limit overflow into shared data storage. The advanced serialization mode (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83777" rel="noopener noreferrer"&gt;PR #83777&lt;/a&gt;) makes shared data reads efficient with per-granule path indexes. Use &lt;code&gt;SKIP&lt;/code&gt; and &lt;code&gt;SKIP REGEXP&lt;/code&gt; to exclude noisy paths from columnar storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Is ClickHouse JSON good for logs and observability?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. ClickHouse's own observability stack (ClickStack) uses the native JSON type for OpenTelemetry log attributes, demonstrating 9x faster queries compared to the previous Map-based approach. SigNoz independently validated 30% faster log queries. The type preserves native numeric types, eliminating cast overhead for aggregations on log attributes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Analysis based on 80+ GitHub pull requests, official ClickHouse changelogs, release blogs, and third-party benchmarks covering the period 2019-2026. Every claim maps to a specific merged PR. Verify the evidence yourself -- the commit history is public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>data</category>
      <category>dataengineering</category>
      <category>clickhouse</category>
      <category>json</category>
    </item>
    <item>
      <title>Are ClickHouse JOINs Slow? A 2026 PR-by-PR Analysis</title>
      <dc:creator>Manveer Chawla</dc:creator>
      <pubDate>Wed, 15 Apr 2026 15:14:34 +0000</pubDate>
      <link>https://dev.to/dataengineering/are-clickhouse-joins-slow-a-2026-pr-by-pr-analysis-21e8</link>
      <guid>https://dev.to/dataengineering/are-clickhouse-joins-slow-a-2026-pr-by-pr-analysis-21e8</guid>
      <description>&lt;h2&gt;
  
  
  &lt;strong&gt;TL;DR&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Are ClickHouse JOINs slow? Not since 2022. Over 50 merged PRs between 2022 and 2026 rebuilt the join engine from the ground up. The evidence is in the commit history.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We analyzed 50+ GitHub pull requests, official ClickHouse changelogs, and release blogs to trace the full evolution of JOIN support from 2022 through early 2026.
&lt;/li&gt;
&lt;li&gt;In 2021, the criticism was fair. ClickHouse had one join algorithm (hash join), no disk spilling, no cost-based optimization, and join order followed query syntax. If your right table exceeded memory, the query crashed.
&lt;/li&gt;
&lt;li&gt;By early 2026, ClickHouse ships six distinct join algorithms, cost-based global join reordering with dynamic programming, runtime bloom filters at the storage layer, parallel hash join as the default, correlated subquery decorrelation, and automatic build-side selection. None of this requires manual tuning.
&lt;/li&gt;
&lt;li&gt;The single highest-impact change is equivalence-set filter pushdown (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/61216" rel="noopener noreferrer"&gt;PR #61216&lt;/a&gt;), which delivered 180×+ speedups by propagating predicates across join sides through column equivalence classes. PostgreSQL and Oracle's planners use the same technique, and ClickHouse implements it natively in its columnar vectorized engine.
&lt;/li&gt;
&lt;li&gt;Grace hash join (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/38191" rel="noopener noreferrer"&gt;PR #38191&lt;/a&gt;) eliminated OOM crashes for memory-bound joins. Parallel hash join (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70788" rel="noopener noreferrer"&gt;PR #70788&lt;/a&gt;) became the default and scales near-linearly across CPU cores. Neither requires configuration.
&lt;/li&gt;
&lt;li&gt;Global join reordering with column statistics (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/86822" rel="noopener noreferrer"&gt;PR #86822&lt;/a&gt;) produces 1,450× speedups on TPC-H SF100 by automatically finding the optimal join order. The DPsize dynamic programming algorithm (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/91002" rel="noopener noreferrer"&gt;PR #91002&lt;/a&gt;) further improves this for complex multi-table queries.
&lt;/li&gt;
&lt;li&gt;Runtime bloom filters, enabled by default since February 2026 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/89314" rel="noopener noreferrer"&gt;PR #89314&lt;/a&gt;), dynamically prune probe-side data at the storage scan level. The v25.10 release blog reports a 2.1× speedup and 7× memory reduction on star-schema workloads.
&lt;/li&gt;
&lt;li&gt;Verdict: the "avoid JOINs in ClickHouse" advice made sense in 2020. Repeating it in 2026 is misinformation. ClickHouse's join engine now operates with the planning sophistication of a mature enterprise RDBMS, and it does so inside the columnar vectorized execution model that makes ClickHouse fast in the first place.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Why People Still Say "Avoid JOINs in ClickHouse"&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you've evaluated ClickHouse in the last few years, you've heard the warnings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"Avoid JOINs in ClickHouse"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"ClickHouse doesn't handle JOINs well"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Denormalize everything, always use flat tables"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"JOINs are slow in ClickHouse"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Only hash join available, limited join algorithms"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of these started as legitimate ClickHouse documentation circa 2019–2020 that advised caution with joins. Others were amplified by competitors who found a convenient story: ClickHouse is fast for scans, but it can't join.&lt;/p&gt;

&lt;p&gt;In 2020, the criticism was mostly fair. ClickHouse had a single hash join algorithm, no disk spilling, no cost-based optimizer, and join order followed query syntax. If your right table exceeded memory, the query crashed with OOM.&lt;/p&gt;

&lt;p&gt;Then ClickHouse's engineering team spent four years dismantling every one of those limitations. Over 50 significant pull requests merged. They added six join algorithms, a cost-based optimizer with dynamic programming, runtime bloom filters, and automatic algorithm selection, build-side selection, join reordering, and predicate pushdown.&lt;/p&gt;

&lt;p&gt;This article traces that evolution with PR-level evidence. No marketing claims. No benchmarks on toy datasets. Just the commit history.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Methodology: How We Analyzed ClickHouse's Join Commit History&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;We went through ClickHouse's GitHub commit history, pull requests, changelogs, and release blogs from 2022 through early 2026. The scope covered every PR that touched the join subsystem: algorithm changes, optimizer rewrites, planner passes, correctness fixes, and default configuration changes.&lt;/p&gt;

&lt;p&gt;Each PR was classified by category (algorithm, optimizer, parallelism, correctness), impact severity, and whether it changed default behavior. We cross-referenced PR descriptions against changelog entries and release blog benchmarks to verify the claimed improvements. Where multiple PRs addressed the same subsystem, we traced the dependency chain to understand how the incremental changes compounded.&lt;/p&gt;

&lt;p&gt;The result is a ranked list of 50 pull requests by impact, organized into eight thematic arcs, with full provenance. Every claim in this article maps to a specific merged PR that you can verify yourself on GitHub.&lt;/p&gt;

&lt;p&gt;This isn't a benchmarking exercise. Benchmarks measure peak performance on controlled workloads. This analysis measures the engineering trajectory: what was built, why, and what it means for teams deciding whether to use JOINs in ClickHouse today.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JOIN Features in 2026: What Ships by Default&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The current state, as of early 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Six distinct join algorithms:&lt;/strong&gt; hash, parallel hash, grace hash (disk-spilling), full sorting merge, direct (key-value), and paste join. Each one is optimized for a different workload shape, and the engine selects automatically.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost-based global join reordering:&lt;/strong&gt; Greedy and dynamic programming algorithms find the optimal join order using column statistics. No manual query rewriting needed.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime bloom filters:&lt;/strong&gt; Build-side join keys compile into bloom filters that get pushed down to probe-side storage scans, filtering non-matching rows before they reach the join.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equivalence-set predicate pushdown:&lt;/strong&gt; Filters propagate transitively across multi-table join chains. &lt;code&gt;WHERE t1.id = 5&lt;/code&gt; pushes to t2, t3, and beyond when joined on equivalent keys.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel execution by default:&lt;/strong&gt; Parallel hash join scales near-linearly with cores. No configuration required.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlated subquery support:&lt;/strong&gt; EXISTS, scalar subqueries, and projection-list subqueries are automatically decorrelated into joins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These aren't experimental features hidden behind flags. They're defaults that ship with every ClickHouse installation.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JOIN Myths vs. Reality: A 2026 Checklist&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;The FUD&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Evidence Volume&lt;/th&gt;
&lt;th&gt;Reality (2026)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;"Only hash join available"&lt;/td&gt;
&lt;td&gt;🟢 False since 2022&lt;/td&gt;
&lt;td&gt;6 algorithms, 10+ PRs&lt;/td&gt;
&lt;td&gt;Six algorithms: hash, parallel hash, grace hash, full sorting merge, direct, paste. Auto-selected.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;"JOINs cause OOM crashes"&lt;/td&gt;
&lt;td&gt;🟢 Solved since late 2022&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/38191" rel="noopener noreferrer"&gt;PR #38191&lt;/a&gt; + follow-ups&lt;/td&gt;
&lt;td&gt;Grace hash join spills to disk. Full sorting merge uses bounded memory. OOM joins are a solved problem.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;"JOINs are slow in ClickHouse"&lt;/td&gt;
&lt;td&gt;🟢 Outdated&lt;/td&gt;
&lt;td&gt;50+ optimization PRs&lt;/td&gt;
&lt;td&gt;180× from predicate pushdown, 1,450× from join reordering, 2.1× from runtime filters, all automatic.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;"No query optimizer for JOINs"&lt;/td&gt;
&lt;td&gt;🟢 False since 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/86822" rel="noopener noreferrer"&gt;PR #86822&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/91002" rel="noopener noreferrer"&gt;#91002&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71577" rel="noopener noreferrer"&gt;#71577&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Cost-based global join reordering with DPsize dynamic programming. Statistics-driven. Automatic.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;"Always denormalize everything"&lt;/td&gt;
&lt;td&gt;🟡 Nuanced&lt;/td&gt;
&lt;td&gt;Architecture-dependent&lt;/td&gt;
&lt;td&gt;Denormalization still has value for extreme query latency targets, but normalized star/snowflake schemas now perform well with automatic optimizations.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;"JOINs don't scale across cores"&lt;/td&gt;
&lt;td&gt;🟢 False since late 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70788" rel="noopener noreferrer"&gt;PR #70788&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92068" rel="noopener noreferrer"&gt;#92068&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Parallel hash join is the default. Near-linear scaling. Outer join completion parallelized in 2026.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;"No predicate pushdown across JOINs"&lt;/td&gt;
&lt;td&gt;🟢 False since April 2024&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/61216" rel="noopener noreferrer"&gt;PR #61216&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96596" rel="noopener noreferrer"&gt;#96596&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Equivalence-set pushdown across single and multi-join chains. Disjunctions supported.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;"Can't handle star/snowflake schemas"&lt;/td&gt;
&lt;td&gt;🟢 Outdated&lt;/td&gt;
&lt;td&gt;Runtime filters + reordering&lt;/td&gt;
&lt;td&gt;Runtime bloom filters and cost-based reordering specifically target star/snowflake schemas.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;"No correlated subquery support"&lt;/td&gt;
&lt;td&gt;🟢 False since mid-2025&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/76078" rel="noopener noreferrer"&gt;PR #76078&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85107" rel="noopener noreferrer"&gt;#85107&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Correlated subqueries decorrelated into joins. Beta since August 2025, enabled by default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;"Have to manually tune join order"&lt;/td&gt;
&lt;td&gt;🟢 False since mid-2025&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/86822" rel="noopener noreferrer"&gt;PR #86822&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/89332" rel="noopener noreferrer"&gt;#89332&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/93912" rel="noopener noreferrer"&gt;#93912&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Automatic join reordering using column statistics and runtime hash table size feedback.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 1 (2022): How Many Join Algorithms Does ClickHouse Support?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"ClickHouse only has hash join"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In mid-2022, ClickHouse had exactly one production join algorithm: hash join. The criticism was valid. Then, in roughly six months, three new algorithms landed.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Full Sorting Merge Join: Memory-Bounded Joins (July 2022)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/35796" rel="noopener noreferrer"&gt;PR #35796&lt;/a&gt; introduced full sorting merge join, a classical sort-merge algorithm integrated into ClickHouse's pipeline. Both sides sort by join keys (with external sorting if needed), then merge in streaming fashion. Memory is bounded by the sort buffer, not by hash table size.&lt;/p&gt;

&lt;p&gt;This mattered for two reasons. First, it was the first non-memory-bound join algorithm in ClickHouse, so you could join tables larger than RAM without crashing. Second, it skips sorting entirely when physical row order already matches join keys, which makes it faster than hash join for pre-sorted data.&lt;/p&gt;

&lt;p&gt;A follow-up optimization (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/39418" rel="noopener noreferrer"&gt;PR #39418&lt;/a&gt;) builds an in-memory key set from the smaller table to pre-filter the larger table before sorting. That made full sorting merge competitive with hash join on general workloads, not just pre-sorted ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Grace Hash Join: Disk-Spilling for Out-of-Memory JOINs (November 2022)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/38191" rel="noopener noreferrer"&gt;PR #38191&lt;/a&gt; was arguably the most important foundational change of this era. Grace hash join partitions both inputs into buckets via a secondary hash. Only one bucket pair is processed at a time, and inactive buckets spill to disk.&lt;/p&gt;

&lt;p&gt;Before this PR, a join where the right table exceeded available memory crashed with OOM. After it, the join completed. It just took longer.&lt;/p&gt;

&lt;p&gt;Grace hash initially supported only INNER and LEFT joins. FULL and RIGHT support arrived in July 2023 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/51013" rel="noopener noreferrer"&gt;PR #51013&lt;/a&gt;), and a cache locality optimization (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/72237" rel="noopener noreferrer"&gt;PR #72237&lt;/a&gt;) delivered a ~24% speedup in late 2024. It graduated to GA in v24.3, which closed &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/11596" rel="noopener noreferrer"&gt;issue #11596&lt;/a&gt;, the most upvoted join-related issue in ClickHouse's history, open since June 2020.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Direct Join: O(1) Memory Key-Value Lookups (2022)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/35363" rel="noopener noreferrer"&gt;PR #35363&lt;/a&gt; introduced direct join for EmbeddedRocksDB tables, and &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/38956" rel="noopener noreferrer"&gt;PR #38956&lt;/a&gt; extended it to dictionaries with SEMI/ANTI support.&lt;/p&gt;

&lt;p&gt;Direct join bypasses hash table construction entirely. It performs O(1) key-value lookups against the storage engine for each left-side row, and memory usage stays constant regardless of right table size.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;ConcurrentHashJoin: The Foundation for Parallel Hash Join (May 2022)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/36415" rel="noopener noreferrer"&gt;PR #36415&lt;/a&gt; laid the groundwork for what would become ClickHouse's most impactful default change. ConcurrentHashJoin creates multiple HashJoin instances, one per thread, and partitions both build and probe sides for concurrent execution. This was the foundation for parallel hash join, which became the default two and a half years later.&lt;/p&gt;

&lt;p&gt;By the end of 2022, ClickHouse had five distinct join algorithms where it previously had one. The "only hash join available" criticism had a documented expiration date.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 2 (2023–2024): Does ClickHouse Have a Query Optimizer for JOINs?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"ClickHouse has no query optimizer for JOINs"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The promotion of ClickHouse's new query Analyzer to production status in v24.9 was the catalyst. The Analyzer provides richer semantic information about column relationships than the old parser-based planner did, which enabled a class of optimizations that were previously impossible.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Equivalence-Set Filter Pushdown: 180× Speedup (April 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/61216" rel="noopener noreferrer"&gt;PR #61216&lt;/a&gt; is the single highest-impact join optimization in this entire four-year period. It introduced equivalence-class-based predicate pushdown across join sides.&lt;/p&gt;

&lt;p&gt;The logic is straightforward. When tables are joined on &lt;code&gt;t1.id = t2.id&lt;/code&gt;, a filter &lt;code&gt;WHERE t1.id = 5&lt;/code&gt; is equivalent to &lt;code&gt;t2.id = 5&lt;/code&gt;. The optimizer recognizes this equivalence and pushes the filter to both sides of the join before execution.&lt;/p&gt;

&lt;p&gt;Before this PR, filters were applied only after the join completed, which forced full table scans of both sides. After it, filters propagate to both sides and prune data before it reaches the join. Benchmarks show up to 180×+ improvement.&lt;/p&gt;

&lt;p&gt;This was later extended to work across chains of multiple INNER JOINs (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96596" rel="noopener noreferrer"&gt;PR #96596&lt;/a&gt;) using a Disjoint Set Union data structure to track transitive equalities. For a query joining t1, t2, and t3 on equivalent keys, a filter &lt;code&gt;WHERE t1.id = 42&lt;/code&gt; now pushes to all three tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Automatic OUTER JOIN to INNER JOIN Conversion (April 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62907" rel="noopener noreferrer"&gt;PR #62907&lt;/a&gt; automatically converts OUTER JOINs to INNER JOINs when post-join filter conditions make the outer semantics unnecessary. A &lt;code&gt;LEFT JOIN ... WHERE right_col IS NOT NULL&lt;/code&gt; is functionally an INNER JOIN, and the optimizer now recognizes this.&lt;/p&gt;

&lt;p&gt;This matters beyond the immediate execution improvement (benchmarks show 32s to 0.006s in some cases) because it enables cascading optimizations. INNER JOINs allow predicate pushdown and join reordering that are structurally impossible for OUTER JOINs. Converting the join type first unlocks the full optimization pipeline downstream.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Right-Side Pushdown, OR Conditions, and Common Expression Extraction&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The planner intelligence kept accumulating:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/50532" rel="noopener noreferrer"&gt;PR #50532&lt;/a&gt; extended predicate pushdown to the right side of joins, delivering 27× improvement on applicable queries.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/84735" rel="noopener noreferrer"&gt;PR #84735&lt;/a&gt; enabled pushdown of OR conditions through joins. Previously only AND conditions could be pushed.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71537" rel="noopener noreferrer"&gt;PR #71537&lt;/a&gt; extracted common expressions from WHERE/ON clauses, which reduced redundant hash table instantiation for BI-generated queries with complex OR conditions.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/78877" rel="noopener noreferrer"&gt;PR #78877&lt;/a&gt; moved equality predicates from WHERE into JOIN ON conditions, enabling more efficient hash table lookups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these operates automatically. No query hints, and no manual rewriting. The planner just does the right thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 3 (Late 2024): Do ClickHouse JOINs Scale Across CPU Cores?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"JOINs don't scale across cores in ClickHouse"&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Parallel Hash Join Becomes the Default Algorithm (November 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70788" rel="noopener noreferrer"&gt;PR #70788&lt;/a&gt; changed the default &lt;code&gt;join_algorithm&lt;/code&gt; from &lt;code&gt;'direct,hash'&lt;/code&gt; to &lt;code&gt;'direct,parallel_hash,hash'&lt;/code&gt;. Every ClickHouse installation now uses parallel hash join by default.&lt;/p&gt;

&lt;p&gt;The parallel hash join builds hash tables using multiple threads via hash-based sharding. The probe phase shards the same way for lock-free concurrent execution. No configuration needed, and scaling is near-linear with CPU cores.&lt;/p&gt;

&lt;p&gt;This was the most broadly impactful default configuration change in this period. Every hash join query on every ClickHouse installation benefits without any user action.&lt;/p&gt;

&lt;p&gt;The path to default status was paved by years of incremental improvements. Hash table size statistics caching (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/64553" rel="noopener noreferrer"&gt;PR #64553&lt;/a&gt;) pre-allocates tables on repeat queries. Zero-copy block scattering (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/67782" rel="noopener noreferrer"&gt;PR #67782&lt;/a&gt;) eliminated redundant memory copies. An adaptive threshold (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/76185" rel="noopener noreferrer"&gt;PR #76185&lt;/a&gt;) falls back to single-threaded hash join for small tables where parallelism would add overhead. Two-level hash maps in v25.1 yielded another ~40% speedup.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Parallelizing OUTER JOIN Completion (February 2026)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92068" rel="noopener noreferrer"&gt;PR #92068&lt;/a&gt; addressed the last remaining single-threaded bottleneck in parallel hash join. For FULL and RIGHT OUTER joins, the "non-joined rows" (rows from the build side with no match) were previously emitted by a single thread. That created an Amdahl's Law bottleneck that limited outer join scalability. The fix parallelizes non-joined row emission across all hash table buckets.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Phase 4 (2025–2026): ClickHouse Cost-Based Join Optimization&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"You have to manually optimize join order in ClickHouse"&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Automatic Build-Side Selection for Hash Joins (November 2024)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71577" rel="noopener noreferrer"&gt;PR #71577&lt;/a&gt; introduced &lt;code&gt;query_plan_join_swap_table = 'auto'&lt;/code&gt;. The optimizer estimates table sizes and places the smaller table on the build (right) side of hash joins. This was the first step toward automatic join reordering.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Statistics-Driven Global Join Reordering: 1,450× TPC-H Speedup (v25.9)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/86822" rel="noopener noreferrer"&gt;PR #86822&lt;/a&gt; introduced global join reordering using a greedy algorithm with column statistics. For queries joining three or more tables, the optimizer evaluates estimated cardinalities and selects the join order that minimizes intermediate result sizes.&lt;/p&gt;

&lt;p&gt;The numbers on TPC-H SF100: &lt;strong&gt;1,450× speedup and 25× memory reduction&lt;/strong&gt; compared to syntax-order execution. That kind of improvement turns "don't use JOINs" into "write whatever join order you want, the optimizer will figure it out."&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;DPsize Dynamic Programming Join Reordering (v25.12)&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/91002" rel="noopener noreferrer"&gt;PR #91002&lt;/a&gt; added a dynamic programming algorithm (DPsize) for more exhaustive join order search. The greedy algorithm makes locally optimal choices, but DPsize evaluates subsets of joined relations systematically. It produces ~4.7% further improvement over greedy on TPC-H, with bigger gains on complex multi-table queries.&lt;/p&gt;

&lt;p&gt;The optimizer tries DPsize first and falls back to greedy if the complexity threshold is exceeded. That's how mature query planners work.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Automatic Statistics Collection for the Join Optimizer&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The optimizer is only as good as its statistics. Column statistics moved from manual (&lt;code&gt;ALTER TABLE ADD STATISTICS&lt;/code&gt;) to automatic. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/89332" rel="noopener noreferrer"&gt;PR #89332&lt;/a&gt; enabled &lt;code&gt;allow_statistics_optimize&lt;/code&gt; by default in v25.10.&lt;/p&gt;

&lt;p&gt;Runtime hash table size statistics (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/93912" rel="noopener noreferrer"&gt;PR #93912&lt;/a&gt;) close the feedback loop between execution and planning. Actual observed sizes from previous queries inform future optimization decisions.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse Runtime Bloom Filters and Star Schema JOIN Performance&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"ClickHouse can't handle star/snowflake schemas"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/89314" rel="noopener noreferrer"&gt;PR #89314&lt;/a&gt;, merged February 2026, enabled runtime bloom filters by default. During hash table construction, ClickHouse builds a bloom filter from the build-side join keys and pushes it down to the probe-side scan pipeline. Rows that don't match the bloom filter are discarded at the storage scan level, before they ever reach the join.&lt;/p&gt;

&lt;p&gt;For star-schema workloads where fact tables are orders of magnitude larger than dimension tables, this is transformative. The v25.10 release blog reports a &lt;strong&gt;2.1× overall query speedup and 7× memory reduction&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The implementation was hardened through 10+ follow-up correctness fixes addressing edge cases: Nullable keys (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/94555" rel="noopener noreferrer"&gt;PR #94555&lt;/a&gt;), multi-key ANTI joins (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98871" rel="noopener noreferrer"&gt;PR #98871&lt;/a&gt;), const columns, Merge tables, and more. An adaptive mechanism (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/91578" rel="noopener noreferrer"&gt;PR #91578&lt;/a&gt;) dynamically disables bloom filters at runtime when they become saturated or aren't filtering enough rows, which prevents negative ROI on non-selective joins. Coverage was extended to RIGHT OUTER joins (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96183" rel="noopener noreferrer"&gt;PR #96183&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Runtime filters can also be pushed into PREWHERE (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/95838" rel="noopener noreferrer"&gt;PR #95838&lt;/a&gt;), ClickHouse's storage-layer pre-filtering mechanism, for maximum efficiency.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Does ClickHouse Support Correlated Subqueries? (2025 Decorrelation)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The FUD:&lt;/strong&gt; &lt;em&gt;"ClickHouse can't do correlated subqueries"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This one was true until April 2025. &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/76078" rel="noopener noreferrer"&gt;PR #76078&lt;/a&gt; introduced the first correlated subquery decorrelation support, converting EXISTS with correlated references into joins. Scalar subquery support followed (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79600" rel="noopener noreferrer"&gt;PR #79600&lt;/a&gt;), then projection-list subqueries (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79925" rel="noopener noreferrer"&gt;PR #79925&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85107" rel="noopener noreferrer"&gt;PR #85107&lt;/a&gt; promoted correlated subqueries to beta with default enablement in August 2025. That closed &lt;a href="https://github.com/ClickHouse/ClickHouse/issues/6697" rel="noopener noreferrer"&gt;issue #6697&lt;/a&gt;, one of the longest-standing SQL compatibility gaps in ClickHouse, open since 2019.&lt;/p&gt;

&lt;p&gt;Teams migrating from PostgreSQL, MySQL, or Snowflake no longer need to manually rewrite correlated subqueries into explicit joins. The planner does it automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse Hash Join Internals: Low-Level Optimizations&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Beyond the headline features, ClickHouse's most-used join algorithm received systematic low-level optimization that compounds across every query:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Main loop specialization&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/82308" rel="noopener noreferrer"&gt;PR #82308&lt;/a&gt;): Compile-time elimination of null_map and join_mask checks for single-key joins. No more unnecessary branches on every row.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JoinUsedFlags vector optimization&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/83043" rel="noopener noreferrer"&gt;PR #83043&lt;/a&gt;): Replaced hash-based flag tracking with atomic vectors, removing per-access hash computation in FULL/RIGHT joins.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output size enforcement&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/56996" rel="noopener noreferrer"&gt;PR #56996&lt;/a&gt;): &lt;code&gt;max_joined_block_size_rows&lt;/code&gt; prevents catastrophic memory spikes from ALL JOIN row replication.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache locality improvements&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/60341" rel="noopener noreferrer"&gt;PR #60341&lt;/a&gt;): Right-table reranging by join keys for cache-friendly access patterns.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic dispatch&lt;/strong&gt; (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/79573" rel="noopener noreferrer"&gt;PR #79573&lt;/a&gt;): Optimized &lt;code&gt;ColumnVector::replicate&lt;/code&gt; in the hash join hot path, lowering CPU per output row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these individually make a press release. Together, they compound into a materially faster join engine at every level of the stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JOIN Limitations and Trade-offs in 2026&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Fairness matters. A few things still require awareness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Denormalization still has value for extreme latency targets.&lt;/strong&gt; If you need sub-10ms p99 on dashboard queries and you can afford the storage, flat tables remain faster than joins. The optimizer is good, but it isn't free.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join reordering depends on statistics.&lt;/strong&gt; When statistics are missing or stale, the optimizer can pick suboptimal plans. The system increasingly collects statistics automatically, but monitoring is still your responsibility.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlated subqueries are beta.&lt;/strong&gt; They work for common patterns like EXISTS and scalar subqueries, but edge cases exist. For complex correlated logic, explicit join rewrites may still be necessary.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grace hash join trades speed for completion.&lt;/strong&gt; Disk-spilling joins complete instead of crashing, but they're slower than in-memory execution. If you consistently need to spill, you need more memory or a different data model.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correctness fixes are ongoing.&lt;/strong&gt; The volume of bug fixes following runtime filter enablement (10+ PRs) shows how complex cross-cutting optimizations get when they're enabled by default. ClickHouse's engineering team has been rigorous about correctness, but running the latest stable release matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are real engineering trade-offs, and understanding them is part of making an informed decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JOIN Improvements Timeline (2022–2026)&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;What Changed&lt;/th&gt;
&lt;th&gt;Key PRs&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2022&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Algorithm diversification: hash to 5 algorithms&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/35796" rel="noopener noreferrer"&gt;#35796&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/38191" rel="noopener noreferrer"&gt;#38191&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/35363" rel="noopener noreferrer"&gt;#35363&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/36415" rel="noopener noreferrer"&gt;#36415&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;OOM joins eliminated. Sort-merge and direct join added. Parallel hash foundation laid.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2023&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Grace hash FULL/RIGHT. PASTE JOIN. Output size limits.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/51013" rel="noopener noreferrer"&gt;#51013&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/57995" rel="noopener noreferrer"&gt;#57995&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/56996" rel="noopener noreferrer"&gt;#56996&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Disk-spilling joins cover all join types. Safety valves for memory.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2024&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Planner intelligence. Parallel hash default. Build-side selection.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/61216" rel="noopener noreferrer"&gt;#61216&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/62907" rel="noopener noreferrer"&gt;#62907&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/70788" rel="noopener noreferrer"&gt;#70788&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71577" rel="noopener noreferrer"&gt;#71577&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/71537" rel="noopener noreferrer"&gt;#71537&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;180×+ from predicate pushdown. Multi-core joins default. OUTER to INNER conversion automatic.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cost-based optimization. Correlated subqueries. Statistics infra.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/86822" rel="noopener noreferrer"&gt;#86822&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/91002" rel="noopener noreferrer"&gt;#91002&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/76078" rel="noopener noreferrer"&gt;#76078&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85107" rel="noopener noreferrer"&gt;#85107&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/89332" rel="noopener noreferrer"&gt;#89332&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;1,450× from join reordering. Correlated subqueries work. Statistics automatic.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runtime bloom filters default. Correctness hardening.&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/89314" rel="noopener noreferrer"&gt;#89314&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/92068" rel="noopener noreferrer"&gt;#92068&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/96596" rel="noopener noreferrer"&gt;#96596&lt;/a&gt;, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/98871" rel="noopener noreferrer"&gt;#98871&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;2.1× from runtime filters. Full outer join parallelism. Multi-join pushdown.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;When Should You Use JOINs in ClickHouse?&lt;/strong&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Reasoning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Star/snowflake schema analytics&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Runtime bloom filters, cost-based reordering, and predicate pushdown are purpose-built for this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-table reporting queries&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Global join reordering eliminates the need for manual optimization. Write readable SQL.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Joins exceeding available memory&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Grace hash join and full sorting merge handle this without OOM. Completion is guaranteed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time dashboards with dimension lookups&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Direct join (O(1) memory) and parallel hash join handle typical enrichment patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-series ASOF joins&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;ASOF JOIN with full_sorting_merge is 2× faster and uses 2× less memory than the hash-based version (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/55051" rel="noopener noreferrer"&gt;PR #55051&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-10ms p99 latency on complex joins&lt;/td&gt;
&lt;td&gt;🟡 Depends&lt;/td&gt;
&lt;td&gt;Flat tables still win for extreme latency. Joins add overhead even when well-optimized.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correlated subqueries from legacy SQL&lt;/td&gt;
&lt;td&gt;🟡 Mostly&lt;/td&gt;
&lt;td&gt;Beta support covers common patterns. Edge cases may need manual rewriting.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10+ table joins with no statistics&lt;/td&gt;
&lt;td&gt;🟡 Conditional&lt;/td&gt;
&lt;td&gt;The optimizer needs statistics. Make sure &lt;code&gt;allow_statistics_optimize&lt;/code&gt; is enabled (default since v25.10).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;How to Respond to "Avoid JOINs in ClickHouse"&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Run the PR numbers.&lt;/p&gt;

&lt;p&gt;When someone tells you ClickHouse can't do joins in 2026, ask them if they've tested against a version that includes parallel hash join (default since v24.12), equivalence-set predicate pushdown (v24.4), grace hash join (GA since v24.3), runtime bloom filters (default since v25.10), or cost-based join reordering (v25.9).&lt;/p&gt;

&lt;p&gt;If they're benchmarking against ClickHouse 23.x or earlier, or repeating 2020-era blog posts, they aren't evaluating ClickHouse. They're evaluating a system that no longer exists.&lt;/p&gt;

&lt;p&gt;The commit history doesn't lie. 50+ pull requests. Six algorithms. Cost-based optimization. Runtime filtering. Automatic algorithm selection, build-side selection, join reordering, and predicate pushdown.&lt;/p&gt;

&lt;p&gt;ClickHouse's join subsystem in 2026 bears no resemblance to the one that earned those early warnings. The engineers built a modern join engine, and the evidence is in the PRs.&lt;/p&gt;

&lt;p&gt;Test it on your workload. That's the only benchmark that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;ClickHouse JOINs FAQ&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Are JOINs production-ready in ClickHouse in 2026?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. ClickHouse's join subsystem has been transformed since 2022. Six join algorithms, cost-based global join reordering, runtime bloom filters, and parallel execution all ship enabled by default. The "avoid JOINs" advice is outdated by four years and 50+ merged PRs.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is the most impactful ClickHouse join optimization?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Equivalence-set filter pushdown (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/61216" rel="noopener noreferrer"&gt;PR #61216&lt;/a&gt;), which delivers 180×+ speedups by propagating predicates across join sides. For multi-table workloads, global join reordering (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/86822" rel="noopener noreferrer"&gt;PR #86822&lt;/a&gt;) with 1,450× speedup on TPC-H SF100 is equally transformative.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does ClickHouse still crash with OOM on large joins?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. Grace hash join (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/38191" rel="noopener noreferrer"&gt;PR #38191&lt;/a&gt;) introduced disk-spilling in November 2022 and graduated to GA in v24.3. Full sorting merge join (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/35796" rel="noopener noreferrer"&gt;PR #35796&lt;/a&gt;) provides a memory-bounded sort-merge alternative. Both algorithms guarantee completion regardless of data size.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How many join algorithms does ClickHouse support?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Six: hash join, parallel hash join (default), grace hash join (disk-spilling), full sorting merge join, direct join (O(1) memory key-value), and paste join (positional). The engine selects the appropriate one automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does ClickHouse have cost-based join optimization?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes, since v25.9. The optimizer uses column statistics for greedy join reordering and a DPsize dynamic programming algorithm (v25.12) for exhaustive search. Statistics collection is automatic since v25.10 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/89332" rel="noopener noreferrer"&gt;PR #89332&lt;/a&gt;). Runtime hash table statistics (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/93912" rel="noopener noreferrer"&gt;PR #93912&lt;/a&gt;) feed execution data back into the planner.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Should I denormalize tables in ClickHouse instead of using JOINs?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;It depends on your latency requirements. For sub-10ms p99 dashboard queries, flat tables remain the fastest path. For analytical workloads where query readability, data freshness, and storage efficiency matter, normalized star/snowflake schemas with JOINs are now well-optimized. The "always denormalize" advice is no longer a blanket recommendation.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can ClickHouse handle star and snowflake schemas?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. Runtime bloom filters (default since February 2026, &lt;a href="https://github.com/ClickHouse/ClickHouse/pull/89314" rel="noopener noreferrer"&gt;PR #89314&lt;/a&gt;) specifically target star-schema patterns by filtering fact table rows at the storage layer using dimension table keys. Combined with cost-based join reordering and predicate pushdown, star/snowflake schemas are a first-class workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does ClickHouse support correlated subqueries?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes, since mid-2025. Correlated subquery decorrelation (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/76078" rel="noopener noreferrer"&gt;PR #76078&lt;/a&gt;) automatically converts EXISTS, scalar, and projection-list subqueries into joins. Promoted to beta with default enablement in August 2025 (&lt;a href="https://github.com/ClickHouse/ClickHouse/pull/85107" rel="noopener noreferrer"&gt;PR #85107&lt;/a&gt;). Closes a feature gap that had been open since 2019.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Analysis based on 50+ GitHub pull requests, official ClickHouse changelogs, and release blogs covering the period 2022–2026. Every claim maps to a specific merged PR. Verify the evidence yourself, the commit history is public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>data</category>
      <category>analytics</category>
      <category>dataengineering</category>
      <category>database</category>
    </item>
  </channel>
</rss>
