Skip to content

Split View: ClickHouse lazy materialization — LIMIT 10짜리 트릭이 FINAL과 JOIN까지 자란 과정

|

ClickHouse lazy materialization — LIMIT 10짜리 트릭이 FINAL과 JOIN까지 자란 과정

들어가며 — "같은 SQL이 1,576배 빨라졌다"를 읽는 법

2025년 4월, ClickHouse 공식 블로그에 이런 문장이 실렸습니다. 쿼리 한 줄 바꾸지 않았는데 219초가 0.139초가 됐다 — 1,576배. 이런 숫자를 보면 두 가지 반응이 가능합니다. "와"라고 하고 넘어가거나, 어떤 조건에서 나온 숫자인지 뜯어보거나. 이 글은 후자입니다.

숫자의 주인공은 lazy materialization(지연 물질화)입니다. 25.4에서 도입될 당시에는 LIMIT 10 이하의 쿼리에만 적용되는, 게이트가 아주 좁은 최적화였습니다. 그런데 2025년 말부터 2026년 상반기에 걸쳐 이 게이트가 눈에 띄게 넓어졌습니다 — 25.12에서 실행 모델이 갈아엎어지며 LIMIT 10,000까지, 26.2에서 UNION ALL 전 브랜치로, 26.4에서 ReplacingMergeTree의 FINAL로, 그리고 26.6(2026년 6월 25일 릴리스)에서는 JOIN 뒤의 selective LIMIT까지. 하나의 트릭이 쿼리 플래너 전반의 원칙으로 자라는 과정이고, 그 과정에는 리버트된 PR과 버그 픽스의 흔적도 그대로 남아 있습니다.

ClickHouse의 MergeTree 구조나 벡터화 실행이 처음이라면 ClickHouse 내부 구조 딥다이브실시간 OLAP 최적화 편을 먼저 보고 오셔도 좋습니다. 이 글은 그 위에서 2026년 릴리스들의 변화만 다룹니다.

lazy materialization이 정확히 무엇인가

공식 문서는 이 기능을 ClickHouse I/O 최적화 스택의 마지막 층으로 설명합니다. 스택을 아래에서부터 쌓으면 이렇습니다.

  1. 컬럼 지향 저장. 쿼리에 필요 없는 컬럼은 아예 읽지 않는다.
  2. 스파스 기본 키 인덱스, 스키핑 인덱스, 프로젝션. 인덱스된 컬럼의 필터로 그래뉼(기본 8,192행 블록)을 통째로 걸러낸다.
  3. PREWHERE. 인덱스에 없는 컬럼의 필터도 싼 컬럼부터 순차적으로 평가해서, 뒤 컬럼은 살아남은 행 것만 읽는다.
  4. 쿼리 조건 캐시. 반복 쿼리에서 지난번에 매치 안 된 그래뉼을 기억해 건너뛴다.

여기까지의 공통 가정이 하나 있습니다 — 필터를 통과한 행이라면, SELECT에 있는 컬럼은 정렬이든 집계든 하기 전에 일단 다 읽어야 한다는 것. lazy materialization은 이 가정을 깹니다. 정렬에 필요한 컬럼만 먼저 읽고, 정렬과 LIMIT이 끝나 최종 행이 확정된 다음에야 나머지 SELECT 컬럼을 그 행들 것만 가져옵니다. ORDER BY 작은컬럼 LIMIT 3에 50GiB짜리 텍스트 컬럼이 SELECT로 딸려 오는 Top-N 쿼리라면, 그 텍스트 컬럼은 딱 3행어치만 읽으면 됩니다.

적용 여부는 실행 계획에서 바로 확인할 수 있습니다.

EXPLAIN actions = 1
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3;
Lazily read columns: review_headline, review_body, product_title
  Limit
    Sorting
      ReadFromMergeTree

Lazily read columns 줄이 보이면 적용된 것입니다. 이런 행 단위의 지연 읽기가 가능한 것 자체가 컬럼 지향 저장 덕이라는 점도 문서가 명시합니다 — 행 지향 DB에서는 어차피 행 전체가 같이 읽히니 성립하지 않는 이야기입니다.

벤더 벤치마크의 조건 — 3분의 정체는 디스크였다

이제 그 1,576배가 나온 조건을 봅시다. 아래 숫자는 전부 ClickHouse가 자사 블로그에 공개한 벤더 자체 측정이고, 측정 환경도 블로그에 명시돼 있습니다: AWS m6i.8xlarge(32 vCPU, 128GiB RAM), 1TiB gp3 SSD를 기본 설정 그대로 — 3,000 IOPS, 최대 처리량 125MiB/s — 붙였고, 매 실행 전에 OS 페이지 캐시를 비웠습니다(cold cache). 데이터는 Amazon 리뷰 150.96M 행, 비압축 70.47GiB, ZSTD(1) 압축 후 30.05GiB입니다.

필터 있는 쿼리(날짜·카테고리·구매인증·별점 필터 + ORDER BY helpful_votes DESC LIMIT 3)에 최적화를 한 층씩 쌓은 사다리가 이렇습니다.

단계실행 시간처리 데이터피크 메모리
풀스캔(인덱스·PREWHERE·lazy 전부 끔)219.508초72.13GB953.25MiB
+ 기본 키 인덱스95.865초27.67GB629.00MiB
+ PREWHERE61.148초16.28GB583.30MiB
+ lazy materialization0.181초807.55MB3.88MiB

필터를 다 뗀 순수 Top-N 쿼리에서는 219.071초가 0.139초가 됩니다. 처리 데이터 71.38GB → 1.81GB, 피크 메모리 1.11GiB → 3.80MiB. 이것이 1,576배의 출처입니다.

이 숫자를 해석할 때 필요한 산수가 하나 있습니다. 필터 없는 쿼리가 읽는 네 컬럼의 압축 크기를 블로그에 실린 표에서 합치면 대략 26.8GiB입니다(review_body 21.60GiB + product_title 3.53GiB + review_headline 1.58GiB + helpful_votes 72.11MiB). 이걸 디스크 처리량 상한 125MiB/s로 나누면 약 219초 — 측정치 219.071초와 사실상 같습니다. 즉 베이스라인 3분 39초는 ClickHouse가 느린 시간이 아니라, 캡 걸린 디스크에서 27GiB를 읽는 물리 시간입니다. 블로그 스스로도 디스크 스펙 옆에 달팽이 이모지를 붙여 뒀습니다. lazy materialization의 이득은 "읽지 않은 바이트 ÷ 디스크 속도"이므로, 이 세팅은 그 이득이 가장 크게 보이는 세팅이기도 합니다.

공개되지 않은 것도 명확히 해 둡니다. hot cache(페이지 캐시에 데이터가 이미 있는 상태)에서의 비교 수치는 이 블로그에 없습니다 — 전 구간 cold cache입니다. 더 빠른 NVMe나 로컬 SSD에서의 배수도 없습니다. 그리고 이 쿼리는 SELECT 대상의 압축 기준 약 99.7%가 지연 대상 컬럼(위 세 텍스트 컬럼)인, 이 최적화에 가장 유리한 모양입니다. 블로그도 이 점은 정직하게 적었습니다 — 데이터셋과 쿼리 모양에 따라서는 인덱스나 PREWHERE 쪽이 더 큰 이득을 낼 수 있다고. 여러분의 쿼리가 좁은 컬럼 몇 개만 SELECT한다면 지연시킬 바이트 자체가 없다는 뜻이고, 배수는 전혀 다르게 나옵니다.

왜 처음엔 LIMIT 10까지만이었나 — 게이트가 10 → 100 → 10,000이 된 역사

도입 시점(25.4, 2025년 4월 22일)의 게이트는 놀랄 만큼 보수적이었습니다. query_plan_max_limit_for_lazy_materialization 기본값 10 — LIMIT이 10을 넘으면 최적화가 아예 안 걸립니다. 이유는 25.12 릴리스 노트가 사후에 잘 설명해 줍니다. 초기 구현은 Top-N 행이 확정된 뒤 나머지 컬럼을 행 단위로(row by row) 가져왔습니다. LIMIT이 크면 이 lookup이 잘게 흩어진 랜덤 읽기가 되어, 지연 읽기의 이득을 오버헤드가 잡아먹습니다.

벤더 자신의 데모가 이걸 정량화합니다. 웹 분석 데이터셋(hits, 100M 행, 같은 m6i.8xlarge + gp3)에서 SELECT * FROM hits ORDER BY EventTime LIMIT 100000을 게이트를 끈 채(= 0) 돌리면 — 25.11의 행 단위 방식은 non-order 컬럼 104개 × 100,000행, 약 1천만 번의 개별 lookup이 됩니다. 3회 실행에서 34.2초에서 38.7초. 같은 쿼리에서 lazy를 완전히 꺼 버리면(eager로 전부 선행 읽기) 7.0초에서 7.9초입니다. 즉 이 지점에서는 옛 lazy 방식이 eager보다 5배 가까이 느렸습니다 — 대신 처리 데이터는 1.20GB 대 56.83GB, 피크 메모리는 약 1GiB 대 74–78GiB로 압도적으로 적었지만요. 시간만 보면 게이트가 왜 10이었는지가 그대로 보입니다.

25.12(2025년 12월 18일)에서 이 실행 모델이 갈아엎어졌습니다(PR #90309, 릴리스 노트 표현으로는 "join-style execution model"). 행 단위 lookup 대신, Top-N 행의 식별자 집합을 만들어 베이스 테이블에 조인하듯 일괄 조회합니다 — 일반 조인과 같은 벡터화·병렬 실행의 이득을 그대로 받습니다. 같은 데모 쿼리가 0.513–0.524초로 떨어졌고(벤더 표기로는 옛 방식 대비 약 75배, eager 대비 약 14배 — 다만 이 데모는 3회 반복 실행의 최속치 기준이고 캐시 상태는 명시돼 있지 않습니다), 그 결과로 게이트 기본값이 10,000까지 올라갔습니다. 설정 히스토리 파일에는 이 궤적이 그대로 남아 있습니다 — 25.4에서 10으로 도입, 25.11에서 100("More optimal"), 25.12에서 10,000("Increase the limit after performance improvement").

10,000에서 멈춘 이유도 릴리스 노트에 있습니다. 지연 물질화된 컬럼은 결국 Top-N 행 순서에 맞춰 정렬이 필요한데, LIMIT이 아주 커지면 이 비용이 다시 눈에 띄기 시작한다는 것. 게이트는 트레이드오프의 자백입니다 — 이 최적화는 공짜가 아니라, N이 작을 때 이기는 내기입니다.

한 가지 더: 25.8부터 lazy materialization은 새 analyzer가 켜진 경우에만 적용됩니다(#83791). analyzer가 기본값이 된 지 오래지만, 구버전 호환 때문에 enable_analyzer = 0으로 돌리는 환경이라면 이 글의 어떤 것도 적용되지 않습니다.

2026년: 같은 아이디어가 새 쿼리 모양으로

여기까지가 전사(前史)이고, 2026년 릴리스들은 "정렬·LIMIT 뒤로 읽기를 미룬다"는 같은 아이디어를 새로운 쿼리 모양에 이식하는 작업이었습니다. 전부 changelog와 머지된 PR 원문에서 확인한 내용입니다.

26.2 (2026년 2월 26일) — UNION ALL 전 브랜치. 그동안 lazy materialization은 UNION ALL의 첫 브랜치에만 적용됐습니다. #96832부터 모든 브랜치에 적용됩니다 — 서로 다른 MergeTree 테이블에서 정렬·LIMIT된 읽기를 합치는 쿼리라면 브랜치마다 지연 읽기의 I/O 절감을 받습니다. 참고로 이 항목은 공개 changelog에는 실리지 않아서, 26.2에 들어갔다는 사실은 PR에 남은 머지 기록(26.2.1.706) 기준입니다. 기능 추가라기보다 구현의 구멍을 메운 쪽에 가깝지만, 시계열을 테이블 여러 개로 쪼개 두고 UNION ALL로 묶는 흔한 패턴에는 실질적인 차이입니다.

26.4 (2026년 4월 30일) — ReplacingMergeTree의 FINAL. #101647이 FINAL 읽기에 지연 물질화를 이식했습니다. 메커니즘은 설정 설명에 요약돼 있습니다 — selective한 조건일 때 기본 키 집합을 만들어 그것으로 인덱스 분석을 다시 하는 방식. 다만 주의할 점: changelog에는 성능 개선으로 실렸지만, 마스터 브랜치의 Settings.cpp 기준으로 query_plan_optimize_lazy_final은 아직 기본값 false, 즉 옵트인입니다. 게다가 가드레일 설정이 세 개나 같이 들어왔습니다 — PK 집합이 천만 행 또는 256MB를 넘으면 일반 FINAL로 폴백(max_rows_for_lazy_final, max_bytes_for_lazy_final), 인덱스 분석이 마크의 절반 이상을 걸러내지 못해도 폴백(min_filtered_ratio_for_lazy_final, 기본 0.5). 가드레일의 개수는 그 자체로 신뢰 단계의 표시입니다. 실제로 26.5 changelog에는 lazy FINAL 경로의 픽스가 두 건 실려 있습니다 — 파이프라인 확장 중의 로지컬 에러(#103230)와, PREWHERE 조합에서 릴리스 빌드가 segfault로 죽는 서버 abort(#104177). FINAL이 병목인 ReplacingMergeTree 워크로드라면 켜서 A/B해 볼 가치가 있지만, 기본값이 꺼져 있는 데는 이유가 있다고 읽는 게 맞습니다.

26.6 (2026년 6월 25일) — JOIN 뒤의 selective LIMIT. 이번 릴리스의 JOIN 최적화 묶음 중 하나로, JOIN 다음에 selective한 LIMIT·TopN·또 다른 JOIN이 올 때 왼쪽 테이블의 payload 컬럼(조인 키가 아닌 컬럼)을 바로 물질화하지 않고 selector/replication 인덱스로 들고 갑니다(#106566). 행이 줄어든 뒤에야 물질화하니, 조인 결과 대부분이 LIMIT에서 버려지는 쿼리에서 복사 비용이 줄어듭니다. 게이트는 두 개입니다 — 왼쪽 payload 컬럼이 3개 이상(query_plan_min_columns_for_join_lazy_indexing, 0이면 비활성), LIMIT 1,000 이하(query_plan_max_limit_for_join_lazy_indexing, 0이면 무제한). 본체의 10,000보다 훨씬 좁은 게이트로 시작하는 것도, 도입기의 패턴 그대로입니다.

26.6의 이 기능에는 각주가 하나 있습니다. 원래 #98883으로 2026년 5월 26일에 머지됐는데, 사흘 뒤 CI가 Bad cast from type DB::ColumnReplicated to DB::ColumnString 로지컬 에러를 잡아냈고(#106095), 리버트됐다가 정렬 변환 단계에서 컬럼을 물질화하는 수정과 함께 6월 19일에 재도입됐습니다. 전 과정이 26.5와 26.6 릴리스 사이에서 벌어져 깨진 버전이 스테이블에 실린 적은 없지만, "지연된 컬럼 표현이 파이프라인 구석구석에서 진짜 컬럼인 척해야 한다"는 이 최적화 계열의 구조적 난점을 잘 보여 주는 에피소드입니다.

같은 시기에 "lazy"라는 단어 자체가 코드베이스의 다른 곳으로 번지는 것도 흥미롭습니다. 26.3에는 JSON 컬럼의 타입 힌트 변경을 데이터 재작성 없이 메타데이터 연산으로 처리하는 실험 기능이(allow_experimental_json_lazy_type_hints, #97412), 26.6에는 텍스트 인덱스의 포스팅 리스트를 Roaring Bitmap으로 전부 풀지 않고 커서 기반으로 필요한 만큼만 디코드하는 실험 기능이(allow_experimental_text_index_lazy_apply, #100035) 들어왔습니다. 둘 다 아직 experimental이지만, 방향은 같습니다 — 최후의 순간까지 일을 미루면, 그 일의 상당수는 영영 할 필요가 없어진다.

어디서 안 통하고, 무엇을 조심해야 하나

정리하면, 2026년 중반 기준으로 이 최적화가 걸리려면 쿼리가 다음 조건에 있어야 합니다.

  • Top-N 모양 — ORDER BY ... LIMIT N 계열이어야 하고, N이 10,000 이하(기본값 기준)여야 합니다. JOIN 변형은 N 1,000 이하 + payload 컬럼 3개 이상. FINAL 변형은 직접 켜야 합니다.
  • 새 analyzer가 켜져 있어야 합니다(기본값).
  • arrayJoin이 플랜에 끼면 적용되지 않습니다 — 26.5에서 의도적으로 제외됐습니다(#101644). LIMIT이 지켜지지 않을 수 있는 correctness 문제였고, 성능이 아니라 정확성 때문에 빠진 케이스입니다.

이득이 구조적으로 없는 경우도 그대로 적어 둡니다. SELECT 컬럼이 전부 좁으면(정렬 컬럼과 크기가 비슷하면) 미룰 바이트가 없습니다. 결과를 전부 소비하는 쿼리(집계로 끝나거나 LIMIT이 없는 export성 쿼리)는 애초에 대상이 아닙니다. 그리고 워킹셋이 페이지 캐시에 다 올라가 있는 hot 환경에서의 이득 폭은 벤더가 공개한 적이 없습니다 — cold cache 1,576배를 여러분의 대시보드 지연시간 개선폭으로 번역할 수는 없다는 뜻입니다. 측정은 여러분의 데이터로 해야 합니다. 다행히 A/B는 세션 설정 하나로 됩니다.

-- 끄고 비교
SELECT ... SETTINGS query_plan_optimize_lazy_materialization = false;
-- 게이트 밖 LIMIT에서 강제로 켜 보기 (기본 게이트는 10000)
SELECT ... SETTINGS query_plan_max_limit_for_lazy_materialization = 0;

마지막으로 버그 트레일 이야기를 해야 합니다. 이 기능은 도입 이후 매 릴리스마다 픽스가 이어졌습니다 — Variant 컬럼 읽기(25.8, #84400), 외부 정렬과 겹칠 때의 CORRUPTED_DATA(25.8, #84738), 프로젝션과 겹칠 때의 AMBIGUOUS_COLUMN_NAME(25.6, #80251), ALTER로 추가된 컬럼의 옛 파트 처리(25.12, #91142), 그리고 26.6에도 정렬 컬럼을 잘못 짚어 TYPE_MISMATCH가 나는 케이스의 픽스(#107060)가 실렸습니다. 겁먹으라는 이야기가 아닙니다 — 기본값으로 켜진 최적화의 버그는 여러분이 켠 적 없는 코드 경로의 버그라는 이야기입니다. ClickHouse를 올릴 때 릴리스 노트에서 lazy가 들어간 줄을 읽어야 하는 이유가 여기에 있고, 쿼리가 이상하게 죽거나 느려질 때 query_plan_optimize_lazy_materialization = false가 유효한 1차 격리 수단인 이유이기도 합니다.

마치며

lazy materialization의 지난 1년 남짓 궤적을 한 줄로 줄이면 이렇습니다 — 25.4에서 LIMIT 10짜리 보수적 트릭으로 태어나(PR 자체는 2023년 10월에 열려 1년 반을 묵었습니다), 25.12에서 실행 모델을 조인 스타일로 갈아엎으며 게이트를 1,000배 넓혔고, 2026년 들어 UNION ALL(26.2), FINAL(26.4, 옵트인), JOIN(26.6)으로 적용 범위를 늘리는 중입니다. 쿼리를 한 줄도 바꾸지 않아도 업그레이드만으로 빨라지는 종류의 개선이라는 점에서 실무 가치는 분명합니다.

동시에 이 글에서 확인한 경계도 분명합니다. 헤드라인 배수는 cold cache와 처리량 캡 걸린 디스크, 그리고 지연 대상 컬럼이 바이트의 대부분을 차지하는 쿼리 모양에서 나온 벤더 자체 측정이고, 그 벤더조차 LIMIT이 커지면 옛 방식이 eager보다 5배 느려지는 수치를 함께 공개했습니다. 게이트와 폴백과 옵트인 기본값은 장식이 아니라 이 최적화의 비용 구조에 대한 정직한 문서입니다.

요즘 분석 DB들이 각자 다른 층을 손보고 있는 것도 함께 보면 재미있습니다 — DuckDB는 클라이언트-서버 프로토콜을, Elasticsearch는 벡터 인덱스의 기본값을 갈아 끼우는 동안, ClickHouse는 "읽기를 얼마나 미룰 수 있나"라는 한 가지 질문을 쿼리 모양 하나씩 넓혀 가며 파고 있습니다. 다음 릴리스에서 이 게이트들이 또 어떻게 움직이는지 — SettingsChangesHistory.cpp만 지켜봐도 이 기능의 성숙도를 계속 추적할 수 있습니다.

참고 자료

ClickHouse Lazy Materialization — How a LIMIT 10 Trick Grew Into FINAL and JOIN

Introduction — How to Read "The Same SQL Got 1,576x Faster"

In April 2025, ClickHouse's official blog carried a sentence like this: without changing a single line of the query, 219 seconds became 0.139 seconds — 1,576x. A number like that invites one of two reactions: say "wow" and move on, or take apart the conditions that produced it. This post takes the second path.

The number's protagonist is lazy materialization. When it was introduced in 25.4, it was an optimization with a very narrow gate, applying only to queries with LIMIT 10 or less. But from late 2025 through the first half of 2026, that gate widened noticeably — to LIMIT 10,000 when the execution model was overhauled in 25.12, to every branch of UNION ALL in 26.2, to ReplacingMergeTree's FINAL in 26.4, and to a selective LIMIT after JOIN in 26.6 (released June 25, 2026). It is the story of a single trick growing into a principle that runs through the whole query planner, and the traces of reverted PRs and bug fixes along the way are still visible.

If ClickHouse's MergeTree structure or vectorized execution is new to you, it may help to read ClickHouse Internals Deep Dive and the real-time OLAP optimization piece first. This post builds on that and covers only what changed across the 2026 releases.

What Lazy Materialization Actually Is

The official docs describe this feature as the last layer of ClickHouse's I/O optimization stack. Stacked from the bottom up, it looks like this.

  1. Column-oriented storage. Columns the query doesn't need are never read at all.
  2. Sparse primary key index, skipping indexes, projections. Filters on indexed columns discard whole granules (8,192-row blocks by default).
  3. PREWHERE. Filters on columns that aren't indexed are also evaluated sequentially, cheapest column first, so later columns are read only for the rows that survive.
  4. Query condition cache. On repeated queries, it remembers granules that didn't match last time and skips them.

There's one shared assumption behind everything up to this point — that for a row which passes the filter, every column in the SELECT list has to be read in full before you can sort or aggregate. Lazy materialization breaks that assumption. It reads only the columns needed for sorting first, and only after sorting and LIMIT have settled on the final rows does it fetch the remaining SELECT columns — and only for those rows. In a Top-N query like ORDER BY small_column LIMIT 3 that also drags along a 50GiB text column in the SELECT list, that text column only needs to be read for 3 rows' worth.

Whether it kicks in can be checked directly in the execution plan.

EXPLAIN actions = 1
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3;
Lazily read columns: review_headline, review_body, product_title
  Limit
    Sorting
      ReadFromMergeTree

If you see the Lazily read columns line, it's applied. The docs also make explicit that this kind of row-level lazy reading is only possible because of column-oriented storage in the first place — in a row-oriented database, the whole row gets read together anyway, so the idea doesn't even apply.

The Vendor Benchmark's Conditions — The 3 Minutes Turns Out to Be the Disk

Now let's look at the conditions that produced that 1,576x. Every number below is a vendor-measured figure ClickHouse published on its own blog, and the measurement environment is likewise stated there: an AWS m6i.8xlarge (32 vCPU, 128GiB RAM), fitted with a 1TiB gp3 SSD at default settings — 3,000 IOPS, 125MiB/s maximum throughput — and the OS page cache was cleared before every run (cold cache). The data is 150.96M rows of Amazon reviews, 70.47GiB uncompressed, 30.05GiB after ZSTD(1) compression.

Here's the ladder of stacking optimizations one layer at a time, for a filtered query (date, category, verified-purchase, and star-rating filters plus ORDER BY helpful_votes DESC LIMIT 3).

StageExecution TimeData ProcessedPeak Memory
Full scan (index, PREWHERE, and lazy all off)219.508s72.13GB953.25MiB
+ primary key index95.865s27.67GB629.00MiB
+ PREWHERE61.148s16.28GB583.30MiB
+ lazy materialization0.181s807.55MB3.88MiB

For a pure Top-N query with all filters removed, 219.071 seconds becomes 0.139 seconds. Data processed goes from 71.38GB to 1.81GB, and peak memory from 1.11GiB to 3.80MiB. This is where the 1,576x comes from.

There's one piece of arithmetic worth doing to interpret this number. If you add up the compressed sizes of the four columns the filter-free query reads, from the table published on the blog, you get roughly 26.8GiB (review_body 21.60GiB + product_title 3.53GiB + review_headline 1.58GiB + helpful_votes 72.11MiB). Divide that by the disk's throughput cap of 125MiB/s and you get about 219 seconds — essentially identical to the measured 219.071 seconds. In other words, the baseline's 3 minutes 39 seconds isn't ClickHouse being slow; it's the physical time it takes to read 27GiB off a throughput-capped disk. The blog itself even sticks a snail emoji next to the disk spec. Because lazy materialization's payoff is "bytes not read ÷ disk speed," this setup is also exactly the setup where that payoff looks its largest.

Let's also be clear about what was not disclosed. There are no comparison numbers in this blog for hot cache (data already sitting in the page cache) — every measurement is cold cache. There are no multiples for faster NVMe or local SSDs either. And this particular query is shaped in the way most favorable to this optimization — roughly 99.7% of the SELECT target's compressed bytes belong to the deferred columns (the three text columns above). The blog is honest about this point too — depending on the dataset and query shape, indexes or PREWHERE may deliver a bigger win instead. If your query only selects a few narrow columns, there are no bytes to defer in the first place, and the multiple comes out completely different.

Why It Started at LIMIT 10 Only — The History of the Gate Going 10 → 100 → 10,000

At launch (25.4, April 22, 2025), the gate was surprisingly conservative. query_plan_max_limit_for_lazy_materialization defaulted to 10 — if LIMIT exceeded 10, the optimization simply didn't kick in at all. The 25.12 release notes explain the reason well, in retrospect. The initial implementation fetched the remaining columns row by row after the Top-N rows were settled. When LIMIT was large, this lookup turned into finely scattered random reads, and the overhead ate up the benefit of the deferred reads.

The vendor's own demo quantifies this. Run SELECT * FROM hits ORDER BY EventTime LIMIT 100000 on the web analytics dataset (hits, 100M rows, same m6i.8xlarge + gp3) with the gate switched off (= 0) — the row-by-row approach from 25.11 turns into roughly 10 million individual lookups (104 non-order columns × 100,000 rows). Across 3 runs, that took 34.2 to 38.7 seconds. Turn off lazy entirely on the same query (eager reads everything up front instead), and it's 7.0 to 7.9 seconds. In other words, at this point the old lazy approach was nearly 5x slower than eager — though it processed overwhelmingly less data, 1.20GB versus 56.83GB, and peak memory of roughly 1GiB versus 74–78GiB. Looking at time alone, it's obvious why the gate was set to 10.

In 25.12 (December 18, 2025), this execution model was overhauled (PR #90309; the release notes call it a "join-style execution model"). Instead of row-by-row lookups, it builds a set of identifiers for the Top-N rows and fetches them in bulk from the base table as if joining — getting the same vectorized, parallel-execution benefits as an ordinary join. The same demo query dropped to 0.513–0.524 seconds (roughly 75x over the old approach and roughly 14x over eager, by the vendor's own figures — though this demo is based on the fastest of 3 repeated runs, and the cache state isn't stated). As a result, the gate's default was raised to 10,000. The settings history file preserves this whole trajectory — introduced at 10 in 25.4, raised to 100 in 25.11 ("More optimal"), and to 10,000 in 25.12 ("Increase the limit after performance improvement").

The reason it stopped at 10,000 is also in the release notes: lazily materialized columns still need to be sorted to match the Top-N row order, and when LIMIT gets very large, that cost starts to show up again. The gate is a confession of the tradeoff — this optimization isn't free; it's a bet that wins when N is small.

One more thing: from 25.8 onward, lazy materialization only applies when the new analyzer is enabled (#83791). The analyzer has been the default for a long time now, but if your environment runs with enable_analyzer = 0 for backward compatibility, none of this post applies to you.

2026: The Same Idea, New Query Shapes

Everything so far is prehistory; the 2026 releases were the work of porting the same idea — "defer reading until after sort and LIMIT" — onto new query shapes. Everything below is confirmed from the changelog and the merged PRs themselves.

26.2 (February 26, 2026) — every branch of UNION ALL. Until now, lazy materialization only applied to the first branch of a UNION ALL. As of #96832, it applies to every branch — a query that combines sorted, LIMIT'd reads from different MergeTree tables now gets the deferred-read I/O savings on each branch. Note that this item never made it into the public changelog, so the fact that it landed in 26.2 rests on the merge record left in the PR (26.2.1.706). It reads more like patching a hole in the implementation than a new feature, but it's a real difference for the common pattern of splitting a time series across several tables and stitching them back together with UNION ALL.

26.4 (April 30, 2026) — ReplacingMergeTree's FINAL. #101647 ported lazy materialization to FINAL reads. The mechanism is summarized in the setting's description — under selective conditions, it builds a set of primary keys and re-runs index analysis against it. One caveat, though: while the changelog lists this as a performance improvement, as of the master branch's Settings.cpp, query_plan_optimize_lazy_final still defaults to false — that is, it's opt-in. On top of that, three guardrail settings shipped alongside it: if the PK set exceeds 10 million rows or 256MB, it falls back to regular FINAL (max_rows_for_lazy_final, max_bytes_for_lazy_final), and if index analysis fails to filter out at least half of the marks, it also falls back (min_filtered_ratio_for_lazy_final, default 0.5). The sheer number of guardrails is itself a signal of the trust level here. In fact, the 26.5 changelog lists two fixes to the lazy FINAL path — a logical error during pipeline expansion (#103230) and a server abort where release builds segfault under a PREWHERE combination (#104177). If FINAL is a bottleneck in your ReplacingMergeTree workload, it's worth turning on and A/B testing — but it's fair to read the off-by-default state as being that way for a reason.

26.6 (June 25, 2026) — selective LIMIT after JOIN. As part of this release's bundle of JOIN optimizations: when a selective LIMIT, TopN, or another JOIN follows a JOIN, the left table's payload columns (the ones that aren't join keys) are no longer materialized immediately — instead they're carried as a selector/replication index (#106566). Materialization happens only after the row count has shrunk, cutting copy costs on queries where most of the join result gets discarded by the LIMIT. There are two gates: at least 3 left-side payload columns (query_plan_min_columns_for_join_lazy_indexing, 0 disables it), and LIMIT no greater than 1,000 (query_plan_max_limit_for_join_lazy_indexing, 0 means unlimited). Starting with a gate far narrower than the main feature's 10,000 is the same pattern seen at the original launch.

This 26.6 feature has a footnote. It was originally merged as #98883 on May 26, 2026, but three days later CI caught a Bad cast from type DB::ColumnReplicated to DB::ColumnString logical error (#106095), it was reverted, and it was reintroduced on June 19 with a fix that materializes the column during the sort-conversion step. The whole cycle played out between the 26.5 and 26.6 releases, so a broken version never shipped in a stable release — but it's a good illustration of the structural difficulty running through this whole family of optimizations: a deferred column representation has to pretend to be a real column everywhere in the pipeline.

It's also interesting that, around the same time, the word "lazy" itself has been spreading to other parts of the codebase. 26.3 brought an experimental feature that handles JSON column type-hint changes as a metadata operation instead of rewriting data (allow_experimental_json_lazy_type_hints, #97412), and 26.6 brought an experimental feature that decodes only as much of a text index's posting list as needed, cursor-based, instead of fully unpacking it into a Roaring Bitmap (allow_experimental_text_index_lazy_apply, #100035). Both are still experimental, but the direction is the same — if you put off the work until the last possible moment, a good chunk of it turns out to never need doing at all.

Where It Doesn't Apply, and What to Watch For

To sum up, as of mid-2026, a query has to meet the following conditions for this optimization to kick in.

  • A Top-N shape — it has to be an ORDER BY ... LIMIT N style query, with N at or below 10,000 (default gate). The JOIN variant needs N at or below 1,000 plus 3 or more payload columns. The FINAL variant has to be enabled manually.
  • The new analyzer has to be enabled (the default).
  • If arrayJoin appears in the plan, it doesn't apply — this was deliberately excluded in 26.5 (#101644). It was a correctness issue where LIMIT might not be respected, so this exclusion is about correctness, not performance.

Let's also spell out the cases where there's structurally no benefit. If every SELECT column is narrow (comparable in size to the sort column), there are no bytes to defer. Queries that consume the entire result (ones that end in an aggregation, or export-style queries with no LIMIT) aren't a target to begin with. And the vendor has never published the size of the benefit in a hot environment, where the working set is entirely in the page cache — meaning you cannot translate the cold-cache 1,576x into how much your own dashboard's latency will improve. You have to measure it with your own data. Fortunately, an A/B test is just one session setting away.

-- turn it off and compare
SELECT ... SETTINGS query_plan_optimize_lazy_materialization = false;
-- force it on for a LIMIT outside the gate (default gate is 10000)
SELECT ... SETTINGS query_plan_max_limit_for_lazy_materialization = 0;

Last, we have to talk about the bug trail. This feature has had fixes land in nearly every release since it launched — reading Variant columns (25.8, #84400), CORRUPTED_DATA when it overlapped with external sorting (25.8, #84738), AMBIGUOUS_COLUMN_NAME when it overlapped with projections (25.6, #80251), handling of old parts for columns added via ALTER (25.12, #91142), and even in 26.6, a fix for a case where the sort column was picked wrong and produced TYPE_MISMATCH (#107060). This isn't meant to scare you — it's meant to point out that a bug in an optimization that's on by default is a bug in a code path you never chose to turn on. That's exactly why you should read the lines mentioning "lazy" in the release notes when you upgrade ClickHouse, and why query_plan_optimize_lazy_materialization = false is a valid first line of isolation when a query dies strangely or slows down.

Closing

Boiled down to one line, lazy materialization's trajectory over the past year or so is this: born in 25.4 as a conservative LIMIT-10 trick (the PR itself was opened back in October 2023 and sat for a year and a half), overhauled into a join-style execution model in 25.12 that widened the gate 1,000x, and through 2026 it's been extending its reach to UNION ALL (26.2), FINAL (26.4, opt-in), and JOIN (26.6). Its practical value is clear in that this is the kind of improvement that gets faster from an upgrade alone, without changing a single line of your query.

At the same time, the boundaries confirmed in this post are just as clear. The headline multiple is a vendor-measured figure that came out of cold cache, a throughput-capped disk, and a query shape where the deferred columns account for most of the bytes — and even the vendor published, alongside it, the figure showing the old approach running 5x slower than eager once LIMIT gets large. The gates, fallbacks, and opt-in defaults aren't decoration; they're honest documentation of this optimization's cost structure.

It's also interesting to look at this alongside what other analytical databases are tinkering with these days — while DuckDB is swapping out its client-server protocol and Elasticsearch is replacing its vector index default, ClickHouse is digging into a single question — "how much can reading be deferred?" — one query shape at a time. Watching how these gates keep moving in future releases — just following SettingsChangesHistory.cpp is enough to keep tracking how mature this feature gets.

References