This guide records the migration process used for codedb and is intended to be reusable in other Zig repositories.
The tested compiler is:
0.17.0-dev.813+2153f8143
Zig development snapshots are moving targets. Pin the full version (including the build metadata) in CI and local setup while migrating. Upgrade to a newer snapshot deliberately, rerun the full matrix, and update the pin only after it passes.
Before changing compilers:
git status --short
zig version
zig build
zig build testRecord existing failures and preserve unrelated working-tree changes. If the project has benchmarks, capture the benchmark-critical results before migration so regressions can be compared against a real baseline.
This guide uses marler8997/zigup, not an unrelated tool with a similar name. On a clean machine, install a pinned zigup release from its release assets, verify the downloaded asset against the checksum recorded by your organization, put the binary on PATH, and confirm zigup help works. Avoid piping a remote installer directly into a shell.
Install/select the exact compiler used by codedb:
zigup fetch 0.17.0-dev.813+2153f8143
zigup default 0.17.0-dev.813+2153f8143
zigup keep 0.17.0-dev.813+2153f8143
zig versionzigup default displays the selected version when called without an argument. zigup keep is optional; it only prevents a later zigup clean from deleting the pinned compiler. Do not use zigup --version: zigup treats positional input as a requested Zig version rather than exposing that conventional flag.
For exploratory work, zigup master selects the latest development compiler. Do not use a moving master selection in CI or release automation.
In CI, download the exact host archive from https://ziglang.org/builds/, verify a repository-pinned SHA-256 before extraction, and assert that zig version equals the pin. A versioned URL alone does not protect a privileged release job from replaced or compromised bytes.
Update build.zig.zon:
.{
// ...
.minimum_zig_version = "0.17.0-dev.813+2153f8143",
}A full development build pin makes the tested compiler explicit. If a library intentionally supports a wider range of 0.17 snapshots, lower the minimum only after testing the oldest claimed snapshot.
Run zig build and fix build.zig errors before source errors. Zig configures dependency build scripts too, so an incompatible transitive build.zig can stop the build before your own source is compiled.
The Build.args field was removed.
Before:
const run = b.addRunArtifact(exe);
if (b.args) |args| run.addArgs(args);After:
const run = b.addRunArtifact(exe);
run.addPassthruArgs();This preserves commands such as:
zig build run -- --helpApply the same change to benchmark and utility run steps.
b.getInstallPath was removed. Prefer artifact and lazy-path APIs so the build graph can track dependencies.
For a command that operates on the compiled executable:
const command = b.addSystemCommand(&.{ "tool", "--flag" });
command.addFileArg(exe.getEmittedBin());
install_artifact.step.dependOn(&command.step);For codesigning, codedb signs the emitted artifact and makes installation depend on the signing step. Avoid constructing zig-out paths manually because --prefix and cross-target installation can change them.
The value ** count array/string repetition syntax is gone. Use @splat with a result type supplied by the destination.
Before:
var flags: [256]bool = [_]bool{false} ** 256;
var matrix: [256][256]u64 = .{.{0} ** 256} ** 256;After:
var flags: [256]bool = @splat(false);
var matrix: [256][256]u64 = @splat(@splat(0));Before:
const payload = "x" ** 300;
consume(payload);After:
const payload: [300]u8 = @splat('x');
consume(&payload);Zig 0.17 no longer implicitly coerces an array value to a slice in this case; use &payload when the callee expects []const u8.
Give @splat an explicit array type when no destination type is available:
try writer.writeAll(&@as([40]u8, @splat(0)));A useful first-pass search is:
grep -R -n ' \*\* ' src --include='*.zig'Review matches rather than replacing blindly: glob documentation and comments legitimately contain **.
std.fmt.bufPrintZ became std.fmt.bufPrintSentinel:
const path_z = try std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0);For a large codebase, a compatibility helper keeps the migration focused:
pub fn bufPrintZ(
buf: []u8,
comptime fmt: []const u8,
args: anytype,
) std.fmt.BufPrintError![:0]u8 {
return std.fmt.bufPrintSentinel(buf, fmt, args, 0);
}Call sites can then move to the helper without changing their return types.
The direct io.vtable.netRead field is no longer available. Use the public stream operation:
Before:
var iov: [1][]u8 = .{dest};
const n = try io.vtable.netRead(io.userdata, stream.socket.handle, &iov);After:
var iov: [1][]u8 = .{dest};
const n = try stream.read(io, &iov);The public method routes through Io.operate and remains compatible with the selected backend. Treat direct vtable access as an implementation detail unless the standard library explicitly documents it as public API.
The unmanaged ArrayList(u8).writer(allocator) helper is no longer available. Use std.Io.Writer.Allocating when the output is fundamentally a byte stream:
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
const writer = &out.writer;
try writer.print("value={d}", .{value});
const bytes = try out.toOwnedSlice();toOwnedSlice() resets the allocating writer, so the deferred deinit() is safe after ownership transfers.
Compile every freestanding/WASM target separately. A native build can hide target-specific problems:
- baseline
wasm32supports atomic integers only up to 32 bits in this toolchain; use 32-bit counters/generations when wraparound is acceptable, or explicitly choose and validate a WebAssembly threads/atomics target; - guard filesystem, subprocess, environment, mmap, clock, and pthread paths with
builtin.os.tag == .freestanding; - provide no-op locks only where the target is intentionally single-threaded;
- avoid reaching
std.posixor libc declarations from exported WASM call graphs.
Use a larger reference trace to locate an unexpected host-only dependency:
zig build wasm -freference-trace=20A project is not migrated if a clean checkout still depends on a package whose build script or source requires an older compiler.
For each dependency:
- Run its own tests under the pinned compiler.
- Prefer an upstream 0.17-compatible release or immutable commit.
- If no compatible release exists, temporarily vendor or fork it.
- Preserve its license and source history/attribution.
- Point
build.zig.zonat the reproducible source, not a patched local cache. - Add the vendored directory to the root package's
.pathsif releases must include it.
Example local dependency:
.dependencies = .{
.some_library = .{
.path = "vendor/some-library",
},
},
.paths = .{
"src",
"vendor/some-library",
"build.zig",
"build.zig.zon",
},Never treat edits inside .zig-cache, a global package cache, or an ignored dependency cache as the final fix. They make only one workstation pass.
For codedb, nanoregex is vendored until its upstream package is 0.17-compatible. The previous mcp-zig dependency supplied only small protocol-neutral JSON helpers and a root value type, so those helpers were moved into codedb instead of retaining a second server-framework dependency.
During the error-fixing loop, format only touched files:
zig fmt build.zig build.zig.zon src/file_you_changed.zigA new Zig formatter can produce repository-wide canonical-format changes. Run zig fmt src only when that churn is intentional, and review the diff separately from semantic migration changes.
Use a narrow-to-wide loop:
# Build graph and main executable
zig build
# Individual project steps while iterating
zig build test-core
zig build test-mcp
# Full unit suite
zig build test
# Optional targets your project publishes
zig build wasm
zig build -Doptimize=ReleaseFastFor codedb, MCP changes also require:
zig build test
python3 scripts/e2e_mcp_test.py \
--binary zig-out/bin/codedb \
--project "$PWD"The E2E suite checks root handshakes, normal explicit-root startup, and no-roots-client behavior. Run target/platform CI after local verification, especially Windows and cross-compiled release targets whose code may be skipped on the host.
Tests that invoke zig build may report only that a child process exited nonzero. Run the nested command directly to expose the compiler diagnostic:
zig build
zig build test-mcpAlso check for stale assumptions in test helpers, CI images, release workflows, editor configuration, and documentation. Search for the old version string and removed API names:
grep -R -n '0\.16\|b\.args\|getInstallPath\|bufPrintZ\|vtable\.netRead' \
--include='*.zig' --include='*.zon' --include='*.md' .- Exact Zig development snapshot selected and recorded
-
build.zig.zonminimum updated - Build graph compiles without deprecated/removed APIs
- Source repetition syntax migrated to
@splat - Sentinel formatting calls migrated
- Public I/O operations replace direct vtable access
- Every dependency builds from a clean, reproducible source
-
zig buildpasses -
zig build testpasses - Optional targets and release modes pass
- Project-specific E2E tests pass
- CI, README, and contributor setup reference the pinned compiler
- Diff reviewed for unrelated formatter churn and pre-existing work preserved
This section records the concrete release changes behind the reusable guidance above. It is intentionally explicit so a future compiler upgrade can distinguish compatibility edits from retrieval-sensitive performance work.
| Area | Files | Change |
|---|---|---|
| Compiler/package pin | build.zig.zon, README.md, .github/copilot-instructions.md, docs/agents.md |
Require the exact tested 0.17.0-dev.813+2153f8143 snapshot. |
| Build graph | build.zig |
Replace removed argument and install-path APIs, keep benchmark argument forwarding, and preserve codesign/install dependencies through emitted lazy paths. |
| CI and releases | .github/workflows/bench-regression.yml, .github/workflows/release-binaries.yml |
Download verified pinned compilers and assert zig version. Release targets use the 0.17 snapshot; transition benchmarks select 0.16 or 0.17 from each revision's minimum_zig_version so the cross-compiler regression gate cannot silently skip its base. |
| Dependencies | build.zig.zon, vendor/nanoregex/, src/mcp_json.zig |
Vendor the compatible regex implementation and internalize the small JSON protocol helpers formerly taken from mcp-zig. |
src/cio.zigadapts sentinel formatting and Zig I/O entry points while retaining POSIX/Windows behavior.src/main.zig,src/cli_proxy.zig,src/commands.zig,src/server.zig, andsrc/mcp.zigadapt argv and stream handling. Parsed arguments borrow the process-lifetime bootstrap argv; they are not retained beyond that lifetime.src/explore.zig,src/hot_cache.zig,src/codegraph.zig,src/snapshot.zig, andsrc/telemetry.ziguse target-compatible atomics, writers, and host-operation guards. Freestanding/WASM builds do not enter filesystem, process, telemetry, or mmap paths.- Removed repetition syntax was converted to typed
@splatexpressions across runtime and tests. Large fixtures that only encoded compiler-stress behavior were reduced or removed where Zig 0.17 no longer accepts the old construct; retrieval assertions were retained in focused suites. src/wasm.ziguses a 32-bit generation on baselinewasm32and keeps native 64-bit generations elsewhere, preserving cache-invalidation behavior within each target's supported atomic widths.
- Exact-word deduplication (
src/index.zig). Valid posting lists are sorted by(doc_id, line_num), sosearchDedupedwrites directly into one caller-owned allocation and removes adjacent duplicates. It detects any ordering break before returning and reruns the complete list through the old hash-dedup contract. The fallback preserves first-occurrence ordering for malformed, legacy, and incrementally fragmented data. - Tier-0 document aggregation (
src/explore.zig). A nondecreasing posting list guarantees one contiguous run per document, allowing direct result construction without a slots array or hash map. Nonmonotonic lists use the defensive pre-migration aggregation path. Scores, line selection, ranking, and caps are not changed. - Word-index lock lifetime (
src/explore.zig). The common completed-index path keeps its original shared lock. Only an incomplete index releases the lock for exclusive rebuild and reacquires it before lookup. - Release startup (
src/main.zig). Debug keeps the 64MB worker-stack trampoline. Optimized builds run the measured approximately 190KB frame on the process stack, removing worker creation/join overhead. Mainstream native process stacks have ample headroom; constrained-stack targets should retain or reintroduce the trampoline after target-specific validation. - Worker directory reuse (
src/watcher.zig). Initial-scan and trigram-shard workers open the project root once and pass the directory handle through file reads instead of reopening it per file. Every worker closes its own handle; root-open failures follow the existing null-result/error publication paths. - Rolling trigram extraction (
src/watcher.zig). Shared extraction rollsc0..c3and normalizedn0..n3, loading and normalizing each incoming byte once. It preserves whitespace-triple suppression, case folding, location and following-character masks, and the no-next-byte boundary on the final trigram. - Persistence invariants (
src/index.zig). Trigram posting insertion keeps doc IDs sorted after ID reuse, and disk remapping follows ascending in-memory IDs. Streamed writes preserve mmap merge/binary-search ordering requirements.
Baseline and candidate:
- baseline: clean
428d8df, Zig 0.16.0, native arm64ReleaseFast; - candidate: Zig 0.17 migration plus the optimizations above, compiler
0.17.0-dev.813+2153f8143, native arm64ReleaseFast; - corpus: immutable 631-file
git archiveof the baseline, outside the working repository; - direct queries: 20 alternating A/B pairs, 200 iterations per query;
- CLI: Hyperfine, 5 warmups, 40 measured runs, isolated
HOMEand cache,CODEDB_ALLOW_TEMP=1,CODEDB_NO_CLI_DAEMON=1, andCODEDB_NO_TELEMETRY=1.
| Layer | Zig 0.16.0 | Zig 0.17 optimized | Result |
|---|---|---|---|
| Cold single-token CLI | 127.0 ± 26.0ms | 105.7 ± 10.7ms | 1.20× faster |
| Warm snapshot CLI | 117.2 ± 27.8ms | 102.8 ± 13.3ms | 1.14× faster |
| Exact-word geometric mean | — | — | 3.217× faster |
| All direct queries geometric mean | — | — | 1.405× faster |
| Search-only geometric mean | — | — | 1.007× faster |
| Symbol geometric mean | — | — | 1.013× faster |
| Generic full initial scan, median | 166ms | 169ms | 1.8% slower |
Hyperfine identified outliers in both CLI samples, so means are reported with standard deviation and should not be interpreted as deterministic single-run latency. Direct-query hit counts were asserted equal. The generic initial-scan gap is dominated by parse plus serial commit; changing that ownership/pipeline is a separate, higher-risk project rather than a compatibility migration.
The release requires all of the following before tagging:
- exact ordered output parity between grouped Tier-0 postings and semantically equivalent fragmented postings that force the defensive fallback;
- duplicate suppression for naturally built and malformed/non-adjacent word postings, including first-occurrence order;
- exact trigram candidate and posting-mask parity between canonical extraction
and worker-sharded rolling extraction, including 3-byte input, whitespace,
mixed case, location-bit rollover, and final
next_maskboundaries; - sequential/parallel initial scan and word-shard parity;
- heap, persisted, and mmap trigram candidate parity after file removal/doc-ID reuse;
- lazy word-index rebuild parity with an eagerly complete index;
- the full Zig test suite and MCP E2E scenarios from the project instructions.
These are retrieval-quality checks, not only crash or hit-count checks: ordered paths, line numbers/text, scores where applicable, candidate sets, and mask metadata must remain equal.