Split View: Nix 2.35가 플레이크 소스를 store에 덜 복사한다 — 6년 8개월 걸린 이슈, 그리고 업스트림이 lazy trees 대신 고른 길
Nix 2.35가 플레이크 소스를 store에 덜 복사한다 — 6년 8개월 걸린 이슈, 그리고 업스트림이 lazy trees 대신 고른 길
- 들어가며 — nix build는 왜 당신의 저장소를 통째로 복사하는가
- 2,437일짜리 이슈 — #3121
- lazy trees — 두 번의 시도
- 그 사이, Determinate Nix는 그냥 출시했다
- 핵심 — lazy-locks, 그리고 완전한 게으름의 가격
- Lix의 대답 — 고치지 말고 얼려라
- Nix 2.35가 실제로 한 일
- 무엇이 여전히 안 고쳐졌나
- 그래서, 당신은 무엇을 해야 하나
- 마치며
- 참고 자료
들어가며 — nix build는 왜 당신의 저장소를 통째로 복사하는가
플레이크를 쓰는 저장소에서 파일 하나를 고치고 nix build를 돌려 본 적이 있다면, 이 동작을 봤을 겁니다.
$ vim README.md # 한 글자 고침
$ nix build .#myapp
copying '/home/me/big-monorepo' to the store...
빌드에 README가 필요하든 말든, 소스 트리 전체가 /nix/store로 복사됩니다. 다음 번에 또 한 글자를 고치면, 또 복사됩니다. 트리가 수 기가바이트짜리 모노레포라면 이건 농담이 아닙니다.
이게 버그가 아니라 설계였다는 게 중요합니다. Nix 2.35.0 릴리스 노트의 표현을 그대로 옮기면 이렇습니다 — 역사적으로 플레이크 소스 트리는 결정론적이고 헤르메틱한 평가를 보장하기 위해, 그 결과 store 객체가 derivation 입력으로 쓰이지 않더라도 eager하게 store로 가져와 거기서 평가되어 왔다. 구현은 단순해졌지만, 큰 저장소에서 플레이크를 못 쓰게 만들었고 소스 트리가 바뀔 때마다 불필요한 쓰기를 했다.
이 글은 그 설계를 고치는 데 왜 6년 8개월이 걸렸는지, Nix 2.35.0이 실제로 무엇을 고쳤는지, 그리고 무엇을 아직 고치지 못했는지에 관한 이야기입니다. 마지막 항목이 가장 중요합니다.
2,437일짜리 이슈 — #3121
이슈 NixOS/nix#3121 "Copy local flakes to the store lazily"는 2019년 10월 7일에 열렸습니다. 연 사람은 Eelco Dolstra — Nix를 만든 사람 본인입니다.
이 이슈는 2026년 6월 9일에 닫혔습니다. 2,437일, 약 6년 8개월, 댓글 69개. 닫은 사람은 Dolstra가 아니었고, 닫으면서 남긴 코멘트는 다섯 단어였습니다 — "Completed in #15711".
#15711은 Dolstra의 PR이 아닙니다. 여기서부터가 이야기입니다.
lazy trees — 두 번의 시도
Dolstra의 답은 lazy trees였습니다. 아이디어는 이렇습니다. 소스 트리를 store에 복사해 두고 거기서 읽는 대신, 가상 파일시스템을 하나 만들어 평가자에게 "store에 있는 것처럼" 보여 준다. 그리고 실제로 파일이 필요해질 때만, 필요한 만큼만 읽는다. nixpkgs#hello를 빌드하는데 nixpkgs 전체를 복사할 이유는 없으니까요.
PR NixOS/nix#6530 "Lazy trees"는 2022년 5월 12일에 열렸습니다. 커밋 402개, 파일 56개, +1363/-364까지 자랐습니다. 그리고 2025년 5월 18일, 머지되지 않은 채 닫혔습니다. 1,102일, 3년 만이었습니다.
닫히기 하루 전인 2025년 5월 17일, Dolstra는 PR #13225 "Lazy trees v2"를 열었습니다. 훨씬 작습니다 — 커밋 1개, 파일 41개, +539/-133. 이 글을 쓰는 2026년 7월 16일 현재 이 PR은 여전히 열려 있고, GitHub이 보고하는 병합 가능 상태는 dirty(충돌)이며, 마지막 업데이트는 2025년 10월 9일입니다. 댓글 16개에 리뷰 코멘트 17개가 붙은 채로 9개월째 멈춰 있습니다.
여기서 조심할 것이 있습니다. PR이 안 움직이는 이유를 저는 모릅니다. 리뷰 논쟁의 내용을 요약해서 "누가 막았다"고 쓰는 건 이 글의 범위 밖이고, 검증 가능한 사실도 아닙니다. 확인 가능한 사실은 이것뿐입니다 — 열려 있고, 충돌 상태이고, 9개월간 갱신이 없습니다.
그 사이, Determinate Nix는 그냥 출시했다
Dolstra는 Determinate Systems의 공동창업자입니다. 그리고 Determinate Systems는 Determinate Nix라는 자체 Nix 배포판을 냅니다.
2025년 5월 15일, Determinate Systems의 Luc Perkins가 lazy trees가 Determinate Nix 3.5.2에 들어갔다고 공지했습니다. 업스트림 Nix 2.28.3 기반이었고, 처음에는 옵트인이었습니다.
# /etc/nix/nix.custom.conf
lazy-trees = true
같은 글에 이런 문장이 있습니다 — "저희 공동창업자 Eelco Dolstra가 현재 lazy trees를 업스트림 Nix에 넣기 위한 PR을 열어 두고 있고, 곧 머지되기를 바랍니다." 그 PR이 #13225입니다. 1년 2개월 뒤에도 열려 있습니다.
두 달 뒤인 2025년 7월 11일, Graham Christensen이 Determinate Nix 3.8.0을 공지하며 이렇게 적었습니다 — 지난 릴리스에서 Determinate Nix 사용자의 5퍼센트에게 lazy trees를 롤아웃했고, 5퍼센트 롤아웃이 성공적이어서 이제 20퍼센트로 확대한다.
오픈소스 패키지 매니저를 퍼센티지 단계 롤아웃으로 배포하는 것 — 이건 상당히 새로운 광경입니다. 사용자는 determinate-nixd version을 쳐서 자기가 그 20퍼센트에 들어갔는지 확인할 수 있고, 같은 글에 opt-out하려면 support@determinate.systems로 연락하라고 적혀 있습니다.
성능 수치도 있습니다. 다만 이건 벤더 자체 측정입니다. 3.5.2 공지 글은 Nixpkgs 저장소 안에서의 평가를 예로 들며 "wall time 3배 이상 감소와 디스크 사용량 20배 이상 감소를 자주 봤다(frequently seen)"고 적었고, "가끔은 이보다 훨씬 큰 감소"도 있었다고 덧붙였습니다. 여기서 빠진 것을 그대로 적어 두겠습니다 — 하드웨어, 측정 방법론, 비교 기준 버전, 몇 회 반복인지, "자주"가 몇 퍼센트인지가 모두 없습니다. 방향은 믿을 만하지만, 저 배수를 당신의 저장소에 그대로 옮겨 적으면 안 됩니다.
핵심 — lazy-locks, 그리고 완전한 게으름의 가격
이제 이 이야기의 기술적 심장입니다. Determinate Nix 소스(v3.21.7 기준)의 설정 정의를 보면 두 개가 나란히 있습니다.
Setting<bool> lazyTrees{
this, false, "lazy-trees",
R"(
If set to true, flakes and trees fetched by builtins.fetchTree are only
copied to the Nix store when they're used as a dependency of a derivation.
This avoids copying (potentially large) source trees unnecessarily.
)"};
Setting<bool> lazyLocks{
this, false, "lazy-locks",
R"(
If enabled, Nix only includes NAR hashes in lock file entries if they're
necessary to lock the input (i.e. when there is no other attribute that
allows the content to be verified, like a Git revision).
This is not backward compatible with older versions of Nix.
If disabled, lock file entries always contain a NAR hash.
)"};
lazy-locks 문서의 저 문장 — "This is not backward compatible with older versions of Nix" — 이 전부를 설명합니다.
논리는 이렇습니다. flake.lock의 각 항목에는 narHash가 들어갑니다. 이건 입력 소스 트리를 NAR로 직렬화한 것의 해시입니다. 이 해시를 계산하려면 트리 전체를 읽어야 합니다. 즉 lock 파일에 narHash를 넣기로 한 이상, 게으름에는 천장이 있습니다 — 파일 하나만 필요해도 트리 전부를 훑어야 하니까요.
그래서 진짜 끝까지 게을러지려면 narHash를 lock 파일에서 빼야 합니다. 그게 lazy-locks = true입니다. 그리고 그렇게 만든 lock 파일은 예전 Nix가 읽지 못합니다. 팀원 한 명이 그 옵션을 켜고 flake.lock을 커밋하면, 안 켠 사람의 빌드가 깨집니다.
두 설정 모두 코드상 기본값이 false라는 점도 짚어 둡니다. Determinate가 롤아웃한 20퍼센트는 코드 기본값이 아니라 자기네 데몬이 관리하는 설정으로 켜 준 것입니다.
Lix의 대답 — 고치지 말고 얼려라
세 번째 구현체도 있습니다. 2024년에 갈라져 나온 포크 Lix입니다.
Lix 위키의 Flakes feature freeze 문서(2025년 10월 5일 작성, 2026년 2월 1일 갱신)는 코어 팀이 플레이크의 기능 집합과 시맨틱을 현 시점에 동결하기로 했다고 적습니다 — 버그픽스 외에는 새 기능을 넣지 않고, 새 입력 타입도, 기존 페처의 새 기능도, 플레이크 중심의 평가자 변경도 넣지 않는다. 이유는 플레이크 구현 코드가 극도로 취약하고 유지보수 부담이 크다는 것입니다.
같은 문서에 이 글의 맥락에서 가장 날카로운 문장이 있습니다. 플레이크 락킹 설계가 unsound하다는 지적인데, 요지는 이렇습니다 — lock 파일에 있는 것이 fetch 가능하더라도, 이 Nix 구현체가 같은 입력에 대해 생성했을 값이 아니면 락 비교가 실패한다. 그래서 libfetchers의 입력 포맷이나 lock 파일 포맷에 어떤 divergence가 생기든 구현체 간 비호환이 발생한다.
Determinate의 lazy-locks가 정확히 그 divergence입니다. Lix 위키는 그 클래스의 문제를 일반론으로 지목했고, Determinate는 그걸 옵트인 플래그 뒤에 두었고, 업스트림은 아예 하지 않기로 했습니다. 같은 문제에 세 개의 다른 답입니다.
Nix 2.35가 실제로 한 일
Nix 2.35.0은 2026년 7월 13일에 태그되고 같은 날 공지됐습니다. (릴리스 노트 문서 상단에 적힌 날짜는 2026-06-22입니다 — 노트를 자른 날과 실제 태그가 3주 차이 납니다. 태그와 공지가 같은 날인 2026-07-13이 실제 릴리스 날짜입니다.)
머지된 것은 PR #15711 "Don't copy flakes to the store unnecessarily (maybe 3rd time's the charm)"입니다. 작성자는 Sergei Zimmerman(@xokdvium), 2026년 4월 19일에 열려 4월 27일에 머지됐습니다. 커밋 3개, 파일 14개, +180/-37.
lazy trees v1이 402커밋에 56파일, +1363/-364였다는 걸 떠올려 보면 자릿수가 다릅니다.
PR 본문에 접근법이 그대로 적혀 있습니다.
3rd time's the charm! (...) This repurposes a slightly less lazy (but also more deterministic) approach than determinate nix has taken. We do still pay to cost of hashing an input once to compute the store path and narHash daemon-client-side.
"Determinate nix가 택한 것보다 약간 덜 게으르지만 더 결정론적인 접근". 그리고 "입력을 한 번 해싱하는 비용은 여전히 낸다".
작동 방식은 릴리스 노트에 있습니다 — 복사 횟수를 줄이기 위해, Nix는 이제 먼저 복사하지 않고 해싱하며, .outPath가 derivation 어트리뷰트에 들어가지 않을 것이므로 실제로 store에 가져올 필요가 없으리라고 가정한다. 이 가정이 틀렸을 경우 약간의 추가 작업이라는 비용이 들지만, 전형적인 사용 사례에서는 작업이 줄어든다.
즉 복사(copy)를 없앤 것이지, 순회(walk)와 해싱을 없앤 게 아닙니다.
이게 Dolstra와 무관하게 진행된 것도 아닙니다. Zimmerman은 이슈 #3121에 2026년 4월 19일 이렇게 적었습니다 — "#15711에서 시도해 봤습니다. @edolstra가 그 PR을 가능하게 만드는 좋은 작업을 많이 했고, 저는 조각을 맞췄을 뿐입니다." 실제로 이 PR이 딛고 선 PR #14050 "Fix fetchToStore caching"은 Dolstra가 2025년 10월 6일 업스트림에 머지한 것입니다.
fetchTarball 쪽도 같은 패턴입니다. Determinate Nix 3.21.0(2026년 5월 22일)이 "Lazy fetchTarball"을 실었고, 5일 뒤인 5월 27일 업스트림에 PR #15920 "fetchTarball: Use the tarball fetcher"가 머지됐습니다. 양쪽 다 작성자는 Dolstra입니다. (두 패치가 같은 것이라고 단정하진 않겠습니다 — 확인한 건 작성자와 날짜뿐입니다.)
성능 수치는 릴리스 노트에 한 줄 있습니다 — 이 변경은 전형적인 평가에서 필요한 디스크 사용량을 크게 줄일 것으로 기대되며(expected to), nixpkgs 타르볼을 가져와 푸는 데 fetchTree/플레이크 경로든 fetchTarball 경로든 약 2배 속도 향상을 낸다. 이건 프로젝트 자체 측정이고, 하드웨어·방법론·비교 대상 버전이 명시돼 있지 않습니다. 디스크 쪽은 "기대된다"이고 속도 쪽만 단정형이라는 원문의 온도차도 그대로 옮겨 둡니다.
업스트림에는 lazy-trees나 lazy-locks 같은 설정이 없습니다. GitHub 코드 검색 기준 NixOS/nix 저장소에 두 문자열의 히트는 0입니다. 켤 것도 끌 것도 없이 기본 동작이 바뀌었습니다. 이게 Determinate 방식과의 실질적 차이입니다 — 플래그를 늘리는 대신, 호환을 깨지 않는 만큼만 게을러졌습니다.
무엇이 여전히 안 고쳐졌나
여기가 이 글에서 제일 중요한 절입니다. 릴리스 노트가 스스로 한계를 적어 뒀는데, 이런 문장은 대개 요약에서 사라집니다.
원문은 소스 트리 전체를 만지고 NAR 직렬화를 해싱하는 일은 불가피하다(unavoidable)고 말하고, 이유를 두 개 답니다.
- 플레이크 입력의 경우
narHash무결성을 eager하게 검사해야 한다. - 플레이크의
outPath어트리뷰트는 미리 알려져 있어야 하고, 하위 호환을 위해 content-addressed store path 문자열이어야 한다.
그러니까 3기가바이트 모노레포에서 파일 하나를 고치면, nix build는 이제 3기가바이트를 store에 복사하지는 않지만 3기가바이트를 읽고 해싱하기는 합니다. 큰 트리에서 이건 여전히 눈에 띄는 비용입니다. 사라진 것은 store 쓰기와 디스크 점유이지, 파일시스템 순회가 아닙니다.
그 앞 단계인 2.32에서 이미 절반이 정리돼 있었다는 점도 릴리스 노트가 짚습니다 — Nix 2.32 이후로 평가 중 소스 읽기의 모든 I/O는(path:와 hg+: 스타일 입력 제외) 원래 파일시스템 위치로, 타르볼 기반 입력은 ~/.cache/nix/tarball-cache-v2 bare git 저장소로 통로가 잡혔다. 남아 있던 것이 store path 계산을 위한 fetch였고, 2.35가 그걸 걷어낸 것입니다.
그리고 가정이 틀리는 경우가 있습니다. .outPath가 실제로 derivation 어트리뷰트에 들어가는 표현식이라면 — 릴리스 노트의 표현으로 "가정이 틀렸을 경우" — 추가 작업 비용을 냅니다. 노트는 이걸 "slight"라고 부르고 구체적인 수치는 주지 않습니다. 그런 수치는 발표된 적이 없으니 여기서 지어내지 않겠습니다.
마지막으로 이건 재현성을 개선하는 변경이 아닙니다. 성능 변경입니다. 결과 store path는 그대로여야 하고, 평가자 관점에서는 여전히 복사가 일어난 것처럼 동작합니다 — toString ./.은 계속 컨텍스트 없는 content-addressed store path 문자열을 내고, 플레이크의 outPath에서 파일을 읽는 코드도 계속 동작하며 IFD로 취급되지 않습니다. 노트가 이 호환성 목록을 길게 나열하는 이유가 있습니다. 하위 호환이 이 설계의 제약 조건 그 자체였으니까요. 참고로 이 "재현성의 비용"이라는 주제를 배포판 쪽에서 다른 각도로 보고 싶다면, Debian이 재현 불가능한 패키지의 testing 이행을 막기 시작한 이야기가 같은 날 올라와 있습니다.
그래서, 당신은 무엇을 해야 하나
업그레이드가 값을 하는 경우
- 큰 저장소·모노레포에서 플레이크를 쓰고,
copying '...' to the store가 반복 루프의 체감 비용인 경우. 복사가 사라지는 것만으로 이득이 납니다. - CI에서 nixpkgs 타르볼을 반복해서 가져와 푸는 경우. 릴리스 노트가 약 2배를 이야기하는 지점이 정확히 여기입니다(프로젝트 자체 측정).
/nix/store의 디스크 압박이 실제 문제인 경우. 매 평가마다 소스 트리 사본이 쌓이던 게 줄어듭니다.
별 차이 없거나, 서두를 이유가 없는 경우
- 트리가 작은 경우. 순회와 해싱이 남아 있고 원래 복사도 쌌다면, 남는 게 별로 없습니다.
- 플레이크를 안 쓰는 경우. 이 변경은 플레이크와
fetchTree/fetchTarball경로 이야기입니다. - 모노레포의 병목이 평가가 아니라 빌드 자체인 경우. 3기가바이트를 여전히 읽고 해싱한다는 사실은 안 바뀝니다.
Determinate Nix의 lazy-trees를 켤지 고민 중이라면 — 이건 2.35와 별개의 물건입니다. 더 게으르지만, 끝까지 게을러지려면 lazy-locks가 필요하고 그건 문서가 명시적으로 하위 호환을 깬다고 말합니다. 팀 전원이 같은 Nix 구현체를 쓰는 게 보장되지 않는다면 lazy-locks는 켜지 마십시오. flake.lock은 팀 공유 파일이고, 구현체 간 lock 비호환은 Lix 위키가 이미 일반론으로 지적해 둔 알려진 함정입니다.
Lix를 쓴다면 플레이크에 새 기능이 오지 않습니다. 이건 사고가 아니라 공표된 정책이고, 장기 계획은 플레이크를 코드베이스 밖 플러그인으로 빼는 것입니다.
마치며
정리하면 이렇습니다. 2019년에 Nix 창시자가 연 "소스를 게으르게 복사하자"는 이슈가 2026년에 닫혔습니다. 닫은 건 그의 402커밋짜리 PR이 아니라, 다른 사람이 그의 선행 작업 위에 올린 180줄짜리 PR이었습니다. 그 사이 같은 기능이 다운스트림 상용 배포판에서 퍼센티지 롤아웃으로 먼저 출시됐고, 또 다른 포크는 이 영역 전체를 동결했습니다.
기술적 교훈은 단순합니다. 이 문제의 어려움은 게으름을 구현하는 데 있지 않았습니다 — Determinate가 2025년에 증명했듯 그건 됩니다. 어려움은 하위 호환을 깨지 않고 게을러지는 데 있었고, 그래서 업스트림에 들어간 답은 "약간 덜 게으르지만 더 결정론적인" 것이었습니다. narHash를 lock 파일에 계속 넣기로 한 이상 트리 전체 해싱은 남고, 그건 릴리스 노트가 직접 "unavoidable"이라고 적은 대로입니다.
그리고 이게 이 이야기에서 가장 오래 남을 문장 같습니다. 큰 diff가 문제를 푸는 게 아니라, 제약 조건을 받아들인 작은 diff가 문제를 푼다는 것. PR 제목이 이미 알고 있었습니다 — maybe 3rd time's the charm.
참고 자료
- Nix 2.35.0 released — NixOS Discourse 공지 (2026-07-13)
- Nix 2.35 릴리스 노트 원문 (rl-2.35.md, 태그 2.35.0)
- NixOS/nix#3121 — Copy local flakes to the store lazily (2019-10-07 ~ 2026-06-09)
- NixOS/nix#15711 — Don't copy flakes to the store unnecessarily (maybe 3rd time's the charm)
- NixOS/nix#15920 — fetchTarball: Use the tarball fetcher
- NixOS/nix#14050 — Fix fetchToStore caching
- NixOS/nix#6530 — Lazy trees (머지 없이 종료)
- NixOS/nix#13225 — Lazy trees v2 (여전히 열림)
- Determinate Systems — Changelog: introducing lazy trees (3.5.2, 2025-05-15)
- Determinate Systems — lazy trees rolled out to 20% of users (3.8.0, 2025-07-11)
- Determinate Nix 소스 —
lazy-trees/lazy-locks설정 정의 (v3.21.7) - Lix Wiki — Flakes feature freeze
- Debian이 재현 불가능한 패키지의 testing 이행을 막기 시작했다 (관련 글)
Nix 2.35 Copies Flake Sources to the Store More Lazily — a 6-Year-8-Month Issue, and the Path Upstream Chose Instead of Lazy Trees
- Introduction — Why Does nix build Copy Your Entire Repository?
- A 2,437-Day Issue — #3121
- Lazy Trees — Two Attempts
- Meanwhile, Determinate Nix Just Shipped It
- The Core — lazy-locks, and the Price of Total Laziness
- Lix's Answer — Don't Fix It, Freeze It
- What Nix 2.35 Actually Did
- What's Still Not Fixed
- So, What Should You Do
- Closing
- References
Introduction — Why Does nix build Copy Your Entire Repository?
If you've ever fixed a single file in a flake-using repository and run nix build, you've seen this behavior.
$ vim README.md # fix a single character
$ nix build .#myapp
copying '/home/me/big-monorepo' to the store...
Whether or not the build actually needs README, the entire source tree gets copied to /nix/store. Fix one more character next time, and it copies again. If the tree is a multi-gigabyte monorepo, this is no joke.
What matters is that this was not a bug — it was the design. To quote Nix 2.35.0's release notes directly: historically, flake source trees have been eagerly fetched into the store and evaluated from there, in order to guarantee deterministic, hermetic evaluation, even when the resulting store object was never used as a derivation input. The implementation stayed simple, but it made flakes unusable on large repositories and wrote unnecessarily every time the source tree changed.
This post is about why fixing that design took 6 years and 8 months, what Nix 2.35.0 actually fixed, and what it still hasn't fixed. That last part matters most.
A 2,437-Day Issue — #3121
The issue NixOS/nix#3121 "Copy local flakes to the store lazily" was opened on October 7, 2019. It was opened by Eelco Dolstra — the person who created Nix.
The issue closed on June 9, 2026. 2,437 days, roughly 6 years and 8 months, 69 comments. The person who closed it wasn't Dolstra, and the closing comment was five words — "Completed in #15711."
#15711 is not Dolstra's PR. That's where the story starts.
Lazy Trees — Two Attempts
Dolstra's answer was lazy trees. The idea: instead of copying the source tree into the store and reading it from there, build a virtual filesystem that presents itself to the evaluator "as if it were in the store." Then read only what's actually needed, only when it's actually needed. There's no reason to copy all of nixpkgs just to build nixpkgs#hello.
PR NixOS/nix#6530 "Lazy trees" opened on May 12, 2022. It grew to 402 commits, 56 files, +1363/-364. Then, on May 18, 2025, it closed without being merged. 1,102 days — 3 years.
The day before it closed, on May 17, 2025, Dolstra opened PR #13225 "Lazy trees v2". It's much smaller — 1 commit, 41 files, +539/-133. As of this writing, July 16, 2026, the PR is still open, GitHub reports its mergeable state as dirty (conflicting), and it was last updated on October 9, 2025. It's been stalled for nine months with 16 comments and 17 review comments attached.
One thing worth being careful about here: I don't know why the PR isn't moving. Summarizing the substance of the review debate and writing "so-and-so blocked it" is outside the scope of this post, and it isn't a verifiable fact either. The only verifiable facts are these — it's open, it's in a conflicted state, and it hasn't been updated in nine months.
Meanwhile, Determinate Nix Just Shipped It
Dolstra is a co-founder of Determinate Systems. And Determinate Systems ships its own Nix distribution, Determinate Nix.
On May 15, 2025, Determinate Systems' Luc Perkins announced that lazy trees had landed in Determinate Nix 3.5.2. It was based on upstream Nix 2.28.3, and it was opt-in at first.
# /etc/nix/nix.custom.conf
lazy-trees = true
The same post has this line — "our co-founder Eelco Dolstra currently has a PR open to bring lazy trees to upstream Nix, and we're hoping it lands soon." That PR is #13225. It's still open 1 year and 2 months later.
Two months later, on July 11, 2025, Graham Christensen announced Determinate Nix 3.8.0, writing that the prior release had rolled lazy trees out to 5 percent of Determinate Nix users, and that since the 5 percent rollout went well, it was now expanding to 20 percent.
Shipping an open-source package manager via a percentage-staged rollout — that's a fairly novel sight. Users can check whether they've landed in that 20 percent by running determinate-nixd version, and the same post says to contact support@determinate.systems to opt out.
There are performance numbers too. But these are vendor-measured. The 3.5.2 announcement, using evaluation inside the Nixpkgs repository as its example, said it had "frequently seen" more than a 3x reduction in wall time and more than a 20x reduction in disk usage, adding that "sometimes the reduction is far larger." I'll state plainly what's missing here — hardware, measurement methodology, the baseline version compared against, how many runs, and what percentage "frequently" means are all absent. The direction is credible, but you shouldn't carry those multipliers over to your own repository as-is.
The Core — lazy-locks, and the Price of Total Laziness
Now for the technical heart of this story. Look at the settings definitions in the Determinate Nix source (as of v3.21.7), and you'll find two settings side by side.
Setting<bool> lazyTrees{
this, false, "lazy-trees",
R"(
If set to true, flakes and trees fetched by builtins.fetchTree are only
copied to the Nix store when they're used as a dependency of a derivation.
This avoids copying (potentially large) source trees unnecessarily.
)"};
Setting<bool> lazyLocks{
this, false, "lazy-locks",
R"(
If enabled, Nix only includes NAR hashes in lock file entries if they're
necessary to lock the input (i.e. when there is no other attribute that
allows the content to be verified, like a Git revision).
This is not backward compatible with older versions of Nix.
If disabled, lock file entries always contain a NAR hash.
)"};
That line in the lazy-locks docs — "This is not backward compatible with older versions of Nix" — explains everything.
The logic goes like this. Every entry in flake.lock includes a narHash. That's the hash of the input source tree serialized as a NAR. Computing that hash requires reading the entire tree. In other words, as long as you keep putting narHash in the lock file, there's a ceiling on laziness — even if you only need one file, you still have to walk the whole tree.
So to get genuinely, all-the-way lazy, you have to drop narHash from the lock file. That's lazy-locks = true. And a lock file built that way can't be read by older Nix. If one team member turns that option on and commits flake.lock, everyone else's build who hasn't turned it on breaks.
Worth noting too: both settings default to false in the code. The 20 percent Determinate rolled out to weren't getting the code default — they were turned on by settings their own daemon manages.
Lix's Answer — Don't Fix It, Freeze It
There's a third implementation too. The fork Lix, which split off in 2024.
The Lix wiki's Flakes feature freeze document (written October 5, 2025, updated February 1, 2026) states that the core team decided to freeze the flake feature set and semantics as they currently stand — no new features beyond bug fixes, no new input types, no new features on existing fetchers, no flake-focused evaluator changes. The reason given is that the flake implementation code is extremely fragile and carries a heavy maintenance burden.
The same document contains the sharpest sentence for this post's purposes. It's a claim that the flake locking design is unsound — the gist being that even if what's in the lock file is fetchable, lock comparison fails unless it's the value this particular Nix implementation would have generated for the same input. So any divergence in libfetchers' input format or the lock file format produces incompatibility across implementations.
Determinate's lazy-locks is exactly that kind of divergence. The Lix wiki named that class of problem in the abstract, Determinate put it behind an opt-in flag, and upstream decided not to do it at all. Three different answers to the same problem.
What Nix 2.35 Actually Did
Nix 2.35.0 was tagged and announced on the same day, July 13, 2026. (The date at the top of the release notes document reads 2026-06-22 — a three-week gap between when the notes were cut and the actual tag. The actual release date is July 13, 2026, when the tag and the announcement coincide.)
What got merged is PR #15711 "Don't copy flakes to the store unnecessarily (maybe 3rd time's the charm)". Its author is Sergei Zimmerman (@xokdvium); it opened on April 19, 2026, and merged on April 27. 3 commits, 14 files, +180/-37.
Recall that lazy trees v1 was 402 commits, 56 files, +1363/-364 — this is a different order of magnitude.
The PR body states the approach plainly.
3rd time's the charm! (...) This repurposes a slightly less lazy (but also more deterministic) approach than determinate nix has taken. We do still pay to cost of hashing an input once to compute the store path and narHash daemon-client-side.
"A slightly less lazy but more deterministic approach than the one Determinate Nix took." And, "the cost of hashing an input once" is still paid.
How it actually works is in the release notes — to cut down on the number of copies, Nix now hashes before copying, assuming that .outPath will not end up in a derivation attribute and therefore won't actually need to be brought into the store. If that assumption is wrong, it costs a bit of extra work, but for the typical use case, it means less work overall.
In other words, the copy is gone, not the walk-and-hash.
Nor did this happen independently of Dolstra. Zimmerman wrote on issue #3121 on April 19, 2026 — "Gave it a shot in #15711. @edolstra did a lot of great groundwork that made that PR possible, I just put the pieces together." Indeed, the PR this one stands on, PR #14050 "Fix fetchToStore caching", is one Dolstra merged upstream on October 6, 2025.
The same pattern shows up on the fetchTarball side. Determinate Nix 3.21.0 (May 22, 2026) shipped "Lazy fetchTarball," and five days later, on May 27, PR #15920 "fetchTarball: Use the tarball fetcher" merged upstream. Both are authored by Dolstra. (I won't claim the two patches are the same thing — all I've confirmed is the author and the dates.)
There's a single performance figure in the release notes — this change is expected to significantly reduce the disk usage needed for a typical evaluation, and it delivers roughly a 2x speedup for fetching and unpacking the nixpkgs tarball, whether through the fetchTree/flake path or the fetchTarball path. This is a project self-measurement, and hardware, methodology, and the comparison version aren't specified. I'm also carrying over the temperature difference in the original wording — disk usage is "expected," while speed is stated as a plain fact.
There are no lazy-trees or lazy-locks-equivalent settings upstream. Searching the NixOS/nix repository on GitHub, both strings return zero hits. There's nothing to flip on or off — the default behavior itself changed. That's the practical difference from Determinate's approach — instead of adding more flags, it got as lazy as it could without breaking compatibility.
What's Still Not Fixed
This is the most important section of this post. The release notes state their own limits, and sentences like this tend to disappear from summaries.
The original text says that touching the whole source tree and hashing its NAR serialization is unavoidable, and gives two reasons.
- For flake inputs,
narHashintegrity has to be checked eagerly. - A flake's
outPathattribute has to be known in advance, and for backward compatibility it has to be a content-addressed store path string.
So if you fix one file in a 3-gigabyte monorepo, nix build no longer copies 3 gigabytes to the store, but it still reads and hashes 3 gigabytes. On large trees, that's still a visible cost. What disappeared is the store write and the disk footprint, not the filesystem walk.
The release notes also point out that half of this was already cleaned up in the previous release, 2.32 — since Nix 2.32, all I/O for reading sources during evaluation (except for path: and hg+:-style inputs) has been routed to its original filesystem location, and tarball-based inputs to a bare git repository at ~/.cache/nix/tarball-cache-v2. What remained was the fetch needed to compute the store path, and that's what 2.35 stripped out.
And there's a case where the assumption is wrong. If .outPath really is an expression that ends up in a derivation attribute — in the release notes' own words, "if that assumption is wrong" — you pay the cost of extra work. The notes call this "slight" and give no specific figure. Since no such figure has ever been published, I won't invent one here.
Last point: this is not a change that improves reproducibility. It's a performance change. The resulting store path has to stay the same, and from the evaluator's point of view, it still behaves as if the copy happened — toString ./. still produces a context-free content-addressed store path string, and code that reads files from a flake's outPath still works and isn't treated as IFD. There's a reason the notes list out this compatibility list at such length. Backward compatibility was the constraint this whole design had to fit inside. If you want to look at this same theme — "the cost of reproducibility" — from a different angle, from the distribution side, the story of Debian blocking unreproducible packages from migrating to testing went up on the same day.
So, What Should You Do
When upgrading pays off
- You use flakes on a large repository or monorepo, and
copying '...' to the storeis the felt cost of your iteration loop. Just losing the copy is a win. - You repeatedly fetch and unpack nixpkgs tarballs in CI. This is exactly where the release notes cite roughly 2x (project self-measured).
- Disk pressure on
/nix/storeis a real problem. Fewer source-tree copies pile up per evaluation.
When it makes little difference, or there's no rush
- Your tree is small. The walk and the hash remain, and if the copy was cheap to begin with, there isn't much left to gain.
- You don't use flakes. This change is about the flake and
fetchTree/fetchTarballpaths. - Your monorepo's bottleneck is the build itself, not evaluation. The fact that you still read and hash 3 gigabytes doesn't change.
If you're considering turning on Determinate Nix's lazy-trees — that's a separate thing from 2.35. It's lazier, but getting all the way lazy requires lazy-locks, and the docs explicitly say that breaks backward compatibility. Don't turn on lazy-locks unless you're guaranteed that your whole team is on the same Nix implementation. flake.lock is a file your team shares, and lock incompatibility across implementations is a known pitfall the Lix wiki has already flagged in the abstract.
If you use Lix, no new features are coming to flakes. That's not an accident — it's stated policy, and the long-term plan is to move flakes out into a plugin outside the codebase.
Closing
To sum up: an issue titled "let's copy sources lazily," opened by Nix's creator in 2019, closed in 2026. What closed it wasn't his 402-commit PR — it was someone else's 180-line PR, built on top of his groundwork. In between, the same feature shipped downstream first, on a commercial distribution, via a percentage rollout, and another fork froze this entire area.
The technical lesson is simple. The difficulty in this problem was never implementing laziness — Determinate proved in 2025 that that part works. The difficulty was getting lazy without breaking backward compatibility, and so the answer that landed upstream was "slightly less lazy, but more deterministic." As long as narHash keeps going into the lock file, hashing the whole tree stays — exactly as the release notes themselves put it, "unavoidable."
And this feels like the sentence from this story that will stick around longest: a big diff doesn't solve the problem — a small diff that accepts the constraint does. The PR title already knew it — maybe 3rd time's the charm.
References
- Nix 2.35.0 released — NixOS Discourse announcement (2026-07-13)
- Nix 2.35 release notes source (rl-2.35.md, tag 2.35.0)
- NixOS/nix#3121 — Copy local flakes to the store lazily (2019-10-07 to 2026-06-09)
- NixOS/nix#15711 — Don't copy flakes to the store unnecessarily (maybe 3rd time's the charm)
- NixOS/nix#15920 — fetchTarball: Use the tarball fetcher
- NixOS/nix#14050 — Fix fetchToStore caching
- NixOS/nix#6530 — Lazy trees (closed without merging)
- NixOS/nix#13225 — Lazy trees v2 (still open)
- Determinate Systems — Changelog: introducing lazy trees (3.5.2, 2025-05-15)
- Determinate Systems — lazy trees rolled out to 20% of users (3.8.0, 2025-07-11)
- Determinate Nix source —
lazy-trees/lazy-lockssettings definitions (v3.21.7) - Lix Wiki — Flakes feature freeze
- Debian Has Started Blocking Unreproducible Packages From Migrating to Testing (related post)