Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Zig++ is to Zig what TypeScript is to JavaScript: a superset that adds the features people kept asking for. Every valid Zig program is a valid Zig++ program, unless it names something priv (write @"priv" instead).

Like Zig, Zig++ is a general-purpose programming language and toolchain for maintaining robust, optimal, and reusable software. Unlike Zig, it has private fields, it will never drop LLVM, it welcomes AI, and its BDFL is Matthew Neel. Zig++ is a fork of Zig, and nearly all of the compiler was written by upstream Zig contributors.

The source is at github.com/mattneel/zigpp, where this book’s sources live as doc/book/.

What Zig++ adds

  • Private fields. A struct or union field marked priv can only be named from the file that declares its type. See Private fields.
  • LLVM forever, and a blessed path to GPUs: std.gpu runs Zig++ and its standard library on NVIDIA, AMD, and Apple GPUs. See LLVM is forever and GPU Programming.
  • AI in the toolchain. See AI Policy and Governance.
  • Any Zig version, automatically. A project’s build.zig.zon can pin the exact compiler version it is built with, and zig runs that version instead of itself, downloading it on first use. See Any Zig Version.
  • A BDFL and one rule: talk about code. See Governance.

Questions people ask

Does Zig++ compile to Zig, the way TypeScript compiles to JavaScript? No. It compiles to machine code, C, WebAssembly, PTX, AMD GPU code objects, and Metal libraries.

Is Zig++ stable? Zig++ follows semantic versioning exactly as closely as TypeScript does. The compiler is at 0.17.0-dev, every push to master is a release, and Versions and Releases explains what the version string means.

What version is Zig++? None. Every push to master is a release, and every release is kept forever: you pin the build you use. A build is named for the upstream version it tracks, its commit height, and its commit, as in 0.17.0-dev.2469+zigpp.04926fc36. See Live at Head.

When is 1.0? 1.0 is a milestone, not a version. The milestone closes when Zig++ gets its borrow checker, and the build that closes it ships like every other build. There is no Zig++ 1.0.

Can upstream Zig build Zig++? No. Zig++ changed std.lang.Type, and an upstream Zig binary cannot compile against it. Use the CMake build, bootstrap.c, or an existing Zig++ binary; see Building from Source.

Which LLVM does Zig++ use? LLVM, Clang, and LLD 23.1.2.

Where is the standard library documentation? At /std/, generated from the newest release, and the language reference is at /langref.html. Both are also served for the version you installed: doc/langref.html is in every release archive, and zig std serves the autodocs and opens a browser tab.

Where to start

  1. Installing — download a release, check its SHA256SUMS, and run zig.
  2. What Zig++ Adds — private fields, LLVM, std.gpu.
  3. GPU Programming — kernels, the CUDA, HIP, and Metal host APIs, and the standalone test that runs them.
  4. Building from Source — the CMake build, the bootstrap compiler, and the devkits that CI uses.

Installing

Installing Zig++ is one line. On Linux and macOS:

curl -fsSL https://zigpp.lol/ppup | sh

On Windows, in PowerShell:

irm https://zigpp.lol/ppup.ps1 | iex

Both run ppup, Zig++’s installer and toolchain manager. It unpacks the newest release, makes it the default zig, installs ppup itself, and puts it on your PATH so you can install more versions later. From then on:

zig version
0.17.0-dev.2380+zigpp.add158e97

ppup

CommandWhat it does
ppupInstall the newest release as the default toolchain, install ppup, and add it to PATH
ppup updateInstall the newest release and make it the default
ppup install <version|latest>Install a version, e.g. ppup install 0.17.0-dev.2380+zigpp.add158e97
ppup default [<version>]Show the default toolchain, or make a version the default
ppup listList the installed toolchains, marking the default
ppup uninstall <version>Remove a toolchain; the default one refuses until another is the default
ppup self updateReplace ppup with the newest one from zigpp.lol
ppup self uninstallRemove every toolchain, ppup, and its PATH entry
ppup helpList the commands, and ppup --version prints ppup’s own version

Where the toolchains live:

Linux, macOS~/.zigpp: toolchains/<version>/ holds each release, bin/zig is the default compiler, bin/ppup is ppup
Windows%LOCALAPPDATA%\zigpp: toolchains\<version>\ holds each release, current is a directory junction to the default one, bin\ppup.ps1 (and a bin\ppup.cmd shim) is ppup

ppup adds two lines to the profile of your shell—~/.zshrc, ~/.bashrc, ~/.config/fish/conf.d/ppup.fish, or ~/.profile: a # Zig++ (ppup) comment and the line that puts its bin directory on PATH. ppup self uninstall removes them again. The environment variables PPUP_HOME and PPUP_NO_MODIFY_PATH=1 (the --no-modify-path option does the same) move the installation and keep ppup out of your profile:

curl -fsSL https://zigpp.lol/ppup | sh -s -- --no-modify-path
PPUP_HOME=/opt/zigpp curl -fsSL https://zigpp.lol/ppup | sh

The installed ppup keeps to the home it is installed in, so PPUP_HOME is needed only for the install.

Zig++ publishes releases for these four targets; anything else (an x86_64 Mac, say) fails with a message listing them:

TargetArchive
x86_64-linuxzig-x86_64-linux-<version>.tar.xz
aarch64-linuxzig-aarch64-linux-<version>.tar.xz
aarch64-macoszig-aarch64-macos-<version>.tar.xz
x86_64-windowszig-x86_64-windows-<version>.zip

The Windows build is x86_64 only, so on ARM64 Windows ppup installs that one and says that it runs under emulation. The Release workflow publishes a release on every push to master, and the Downloads chapter lists the archives of every release with their SHA-256 checksums; Versions and Releases explains what <version> looks like.

There is no package manager for Zig++ and no pre-built binary from anywhere but these releases: ppup, or unpacking an archive by hand, are the two ways in. A compiler built from source is the other option; see Building from Source.

Unpack an archive anywhere

A Zig installation is two things: the zig executable, and the lib/ directory. At runtime, the executable searches up the file system for lib/, relative to itself:

  • lib/
  • lib/zig/
  • ../lib/
  • ../lib/zig/
  • and so on

In other words, you can unpack a release of Zig++ anywhere and begin using it immediately, with no installation step, although the search also supports installing it globally (/usr/bin/zig and /usr/lib/zig/). This is all ppup does: it unpacks toolchains/<version>/ and points bin/zig at it.

A release archive holds:

zig-x86_64-linux-0.17.0-dev.2361+zigpp.5b96e6d21/
    zig                 the compiler
    lib/                the standard library, builtin headers, and build system
    doc/langref.html    the language reference
    LICENSE
    README.md

On Windows the executable is zig.exe, and the archive is a .zip.

tar -xJf zig-x86_64-linux-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz
./zig-x86_64-linux-0.17.0-dev.2361+zigpp.5b96e6d21/zig version
0.17.0-dev.2361+zigpp.5b96e6d21

Verify the download

Each release has a SHA256SUMS asset: the SHA-256 of every archive and of index.json, in the format sha256sum reads. Download the release’s assets into one directory, then check them:

sha256sum -c SHA256SUMS

If you downloaded only the archive for your target, check that one line and skip the rest:

sha256sum -c --ignore-missing SHA256SUMS
zig-aarch64-linux-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz: OK
zig-aarch64-macos-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz: OK
zig-x86_64-linux-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz: OK
zig-x86_64-windows-0.17.0-dev.2361+zigpp.5b96e6d21.zip: OK
index.json: OK

ppup checks the same sums itself, and unpacks nothing that fails the check.

The same hashes, with the size of each archive, are in index.json, which uses the format of ziglang.org’s download index, so tools that read that one can read this one.

Documentation for the version you have

  • The language reference is doc/langref.html inside the archive, and this site serves the newest release’s copy at /langref.html.

  • The standard library documentation is served by the compiler itself:

    zig std
    

    It generates the autodocs for the lib/ next to the executable and opens a browser tab. This site serves the newest release’s copy at /std/.

Next

What Zig++ Adds

Zig++ is a superset of Zig: every valid Zig program is a valid Zig++ program, unless it names something priv (write @"priv" instead). This chapter covers what it adds to the language and to the toolchain.

Private fields

Struct fields are public by default, meaning that they can be accessed from any file. A field declared with the priv keyword is private: it can only be accessed by name from within the file which declares the struct, just like a declaration which is not marked pub. Private fields allow a type to protect its invariants, and communicate which fields are implementation details rather than part of its API.

/// A fixed-capacity buffer. `len` is private, so code in other files cannot
/// break the invariant that `len` never exceeds the capacity of `bytes`.
pub const Buffer = struct {
    bytes: [16]u8 = undefined,
    priv len: usize = 0,

    pub fn append(buf: *Buffer, byte: u8) error{Overflow}!void {
        if (buf.len == buf.bytes.len) return error.Overflow;
        buf.bytes[buf.len] = byte;
        buf.len += 1;
    }

    pub fn slice(buf: *const Buffer) []const u8 {
        return buf.bytes[0..buf.len];
    }
};

Code in the file that declares the type names len normally, including in tests. Code in any other file cannot name it, whether to read it, write it, take its address, call it, or specify its value in an initialization expression:

const Buffer = @import("test_private_fields.zig").Buffer;

test "access a private field from another file" {
    var buf: Buffer = .{};
    try buf.append('a');
    buf.len = 100;
}

// error: field 'len' of struct 'test_private_fields.Buffer' is private

Private fields are still a part of every value of the type. If a private field has no default value, the struct can only be initialized within the file which declares it, typically by a public initialization function.

Field privacy only applies to syntax which names a field. @typeInfo reports private fields along with all other fields, indicating their privacy with the @"priv" field attribute, and builtins which name fields with strings, such as @field, @FieldType, and @offsetOf, can be used with any field. This allows generic code, such as formatting, comparison, hashing, and serialization, to operate on all types:

const info = @typeInfo(Buffer).@"struct";
try expect(std.mem.eql(u8, info.field_names[1], "len"));
try expect(info.field_attrs[1].@"priv");

// Builtins which name fields with strings are not subject to field privacy.
var buf: Buffer = .{};
try buf.append('z');
try expect(@field(buf, "len") == 1);

A type created with @Struct or @Union has private fields where the @"priv" field attribute is set; these fields are private to the file containing the reification builtin call.

Fields of unions can be marked priv as well. Outside of the file which declares the union, a private field cannot be accessed, initialized, or have its payload captured by a switch prong which names it. The tag of a union is not affected by field privacy, so it can still be compared against and switched on, and else and inline else prongs can capture any payload.

Enum fields and tuple fields cannot be marked priv.

priv precedes comptime in a field declaration, so the grammar rule for a container field is:

ContainerField <- doc_comment? KEYWORD_priv? KEYWORD_comptime? (IDENTIFIER COLON)? TypeExpr ByteAlign? (EQUAL Expr)?

Because priv is a keyword, an identifier with that name is written @"priv". That is the whole of the incompatibility with Zig: a Zig program that declares, or names, something priv needs quotes around it, and one that expects to reach private fields from other files needs a different design.

Upstream closed the proposal for private fields as not planned. Zig++’s release smoke test compiles a struct with a private field, runs it, and checks that naming that field from another file fails with a diagnostic that mentions private.

The full rules, with the tests above as runnable examples, are in the language reference: Private Fields.

LLVM is forever

Upstream Zig plans to drop its dependency on the LLVM libraries, and to eliminate its dependency on the LLVM library API calls. Zig++ will never phase out LLVM. In package.json terms, LLVM stays in dependencies.

Zig++ builds with LLVM, Clang, and LLD 23.1.2. The CMake build requires the 23.x development libraries, and refuses a llvm-config older than 23 or newer than 24.

The LLVM-less build path exists only to bootstrap: cc -o bootstrap bootstrap.c && ./bootstrap produces a zig2 that is a stage2 build without LLVM extensions, and it lacks release-mode optimizations, some ELF, COFF/PE, and WebAssembly linking features, the ability to create static archives from object files, the ability to compile assembly files, and the ability to compile C, C++, Objective-C, and Objective-C++. It is enough to run ./zig2 build and produce a real Zig++ compiler. See Building from Source.

LLVM, and MLIR above it, are how Zig++ goes the final stretch on GPUs: the blessed path lowers Zig++ directly to PTX, to AMD GPU code objects, and to Metal libraries, with first-class GPU intrinsics.

std.gpu

std.gpu is a port of ugpu into the standard library. Kernels are plain Zig functions, and they can use the rest of the standard library as long as they avoid operating system services:

  • Kernels are exported functions with the .kernel calling convention, and std.gpu has CUDA’s indexing (threadIdx, blockIdx, blockDim, gridDim, globalId), syncThreads (the new @workGroupBarrier builtin), warp shuffles, votes and reductions, atomics, fast math approximations, and print.
  • The standard library runs on the GPU: std.fmt, std.json, std.mem, std.base64, hash maps, and array lists, with allocators for shared memory and for the CUDA device heap in std.gpu.allocators. A panic in a kernel reports its message to the host.
  • Every NVPTX and AMDGPU module carries the compiler-rt routines that it calls, so @sin, @exp, @log, f128, and float parsing work in kernels, with the same results as on the host, bit for bit. Upstream Zig crashes LLVM on @sin for NVPTX.
  • std.gpu.cuda loads the CUDA driver at run time, so programs build without the CUDA toolkit, and launches kernels from the host. std.gpu.hip does the same with the HIP runtime of AMD GPUs, on Linux and on Windows, where it needs no libc.
  • std.gpu.metal loads the Metal framework at run time and runs kernels that Zig++ compiles for the air64 target into a .metallib, in process, with no Xcode, Metal toolchain, or macOS SDK. Apple GPUs have no f64, no print, and only relaxed atomics, which are compile errors in their kernels.

GPU Programming covers the device API, the host APIs, and how to build and run the kernels. Still to come: MLIR lowering for tensor cores and kernel fusion.

std.Io.Threadz

std.Io.Threadz is an Io implementation whose unit of work is a task: a function on its own stack that parks at every Io call that has to wait, while its worker thread runs the next task. Code written against std.Io runs on it unchanged, so moving a program between Io.Threaded and Io.Threadz changes one declaration:

var threadz: std.Io.Threadz = undefined;
try threadz.init(gpa, .{});
defer threadz.deinit();
const io = threadz.io();
  • The thread that calls init becomes the first worker and runs as the main task. More workers start as work arrives, one per CPU by default; setWorkerLimit lowers the count, and -j sets it in a compiler built with -Dio-mode=evented.
  • Every task gets its own mapping with a guard page below the stack, and the OS commits the stack as the task touches it. Tasks reserve 256 KiB unless InitOptions.stack_size says otherwise.
  • A task’s affinity decides where it runs whenever it becomes runnable. .sticky, the default, resumes it on the worker that last ran it, and another worker takes it only from a worker with more than one task waiting. .pinned keeps it on one worker, for tasks that own that worker’s resources. .free lets whichever worker is idle take it. concurrentWith and groupConcurrentWith take the affinity and the stack size of one task.
  • A task may complete 128 Io operations that nobody has to wait for before the next one goes to the back of its worker’s queue, with its affinity unchanged. A worker takes the task it will run next, and a stuck worker’s slot for that task is the watchdog’s to hand to the shared queue, so that a worker pays no locked instruction for its own: the watchdog has the backend issue a process-wide barrier, re-reads what that worker wrote at its last switch, and takes the slot only when the worker has not switched since. Counting operations without parking bounds what one task can take from a worker without ever blocking on it, and the count starts over at every park or yield. scheduler.budget is the 128.
  • Every task has an id, unique among the tasks the program made, and a name: the function it was spawned with, from Io.spawnedName. A log line and, in step 7, the task dump use them.
  • Io.blocking(fn, args) is for a call that blocks without making an Io call of its own, such as a C library call or a raw syscall. It returns fn’s result, errors and all. On Threadz it runs on a dirty pool, an Io.Threaded instance the Threadz instance starts with the first such call, while the calling task parks; the arguments and the result stay on the task’s stack, where the pool thread reads them and writes the result back. Sixteen calls that each block for 200 ms finish in 205 ms, and the workers go on running tasks while they block. The call is not cancelable once it has started: a cancelation request takes effect at the calling task’s next cancelation point after it returns. Io.Threaded, and the implementations with no pool of their own, make the call on the calling thread.
  • One watchdog thread per instance, started with the first task the instance makes or runs, samples every worker every 10 ms. A worker whose current task has not switched out for 100 ms is stuck. Two rounds ten milliseconds apart, and the worker thread’s CPU time between them, tell the kinds apart: a thread that used less than a fifth of the wall time is blocked, in the kernel or in a C call that never said it blocks, and its task is named in one std.log warning scoped .threadz, with its id, the function it was spawned with, and how long it has run; its queued tasks, including the ones others made runnable for it and the one in its slot for the next task, become takeable by every worker whatever their number; and one replacement worker is started for it, so that parallelism stays, which stops once it has nothing to run and the stuck worker switches out again. A thread that kept using its time is computing, which is what a compiler task is: its queued work is taken just the same, but no worker is replaced — -j and the worker limit mean what they say — and nothing is logged. An instance whose worker limit is one is covered for a blocked task, whose replacement is where the tasks behind it run; a computing task holds the only worker, which is what a limit of one means. Pinned tasks on a stuck worker wait for the worker, which they own the resources of. Threadz.stats() reads the counters: workers running, replacements, workers stuck now, the episodes of each kind, the rounds of samples the watchdog made, and the task and kind of the last episode. A worker pays one store per switch for the watchdog, of the task it runs, and one load of a flag when it takes the task in its slot. The watchdog waits without a timeout while every worker is parked, so an idle program is not woken to sample workers that are all asleep.
  • Threads outside the pool can use the synchronization primitives, whose futexes they wait on in the kernel. Other Io calls from those threads are not supported yet.
  • io.arena() returns an allocator whose memory belongs to the task: it is created at the first call, out of the implementation’s own allocator, and released in one step when the task returns, whether the task freed it or not. It is where a task puts what it builds up and throws away, such as one request’s parsed headers, and a value that outlives the task must not point into it: move what crosses a task boundary by owned transfer, into another allocator, or by copying it. The main thread has one too, released by deinit, and io.blocking runs on a thread of its own, which sees neither the calling task’s arena nor its scopes.
  • io.Scoped(T) is the task-local value. The key is declared once at container level, const current = std.Io.Scoped(u32), bound around a call with current.run(io, value, function, args), and read with current.get(io) - in that call, and in every task spawned inside it, a child of a child as much as a child, each by its own copy, until it returns. Outside the call the binding is gone. threadlocal cannot serve this on Threadz: a task migrates at a park point, and the fiber switch does not move the thread’s TLS base, so threadlocal is worker-local here, not task-local, and Io.Scoped is what a per-task value uses. The compiler’s per-thread ids and a logger’s context are the intended movers.
  • std.Io.Supervisor is a group of tasks with OTP’s restart policy. Children are added with add - the function they run and its arguments, which are copied - before the first run. one_for_one restarts the child that failed, one_for_all cancels and restarts every child, and rest_for_one the child that failed and the ones added after it. A child that returns normally is not restarted, which is OTP’s transient and the only restart type in this cut, and a panic stays fatal. More restarts than intensity within period - three in five seconds by default - cancel every child and return error.RestartIntensityExceeded, with the error it gave up on in lastChildError. Canceling run cancels the children and returns error.Canceled.
  • Io.Queue takes its overflow policy at initWithOptions: .block, the default and the behaviour a queue always had; .drop, where a put that finds the queue full drops what does not fit, counts it, and never waits, for a mailbox that keeps the newest sample; and .drop_and_resync, which does the same and makes the next get return error.Resync once, for a consumer that must notice a drop rather than act on stale data. droppedElements counts what was dropped. The get error set has error.Resync in it, which only a drop_and_resync queue returns.

On Linux, Threadz runs on io_uring, with a ring per worker: sockets, DNS, processes, timers, futexes, and file reads are ring operations. The file operations io_uring can only finish on a kernel thread of its own, such as writes to a file, statx, and creating a file, are made on the worker instead, because the trip to that thread and back costs more than the call.

A task that drives io_uring itself, such as a server’s event loop, can borrow its worker’s ring instead of making one of its own. acquireRing lends it to a task pinned to that worker. The task queues SQEs on the ring with ring_owner_bit set in their user_data, and Ring.waitCqes parks it until one of its completions arrives. The worker handles every other completion as before and runs its other tasks while the loop is parked. fromIo finds the Threadz behind an Io, and workerLimit says how many workers a program may pin tasks to.

zix’s io_uring servers for HTTP/1 and WebSocket, HTTP/2, gRPC and HTTP/3 run on Threadz this way, each loop a task pinned to its own worker, and keep their multishot receives, buffer rings and batched submissions. On a 32-thread laptop, across the 45 load tiers of their HttpArena profiles, they served at a geometric mean of 1.01 times the throughput of the same servers on threads of their own, with single tiers from 0.83 to 1.23 times, inside the laptop’s own run-to-run spread.

zig build test-threadz runs the Io tests on Threadz, and the test runner’s --io=threadz runs any test on it. The design, and the steps still to come (the kqueue and IOCP cores, arenas and supervisors, the task dump and the scheduler gauges, and the async/await keywords), are in the Threadz proposal.

AI in the toolchain

Upstream Zig bans LLMs from issues, patches, and bug tracker comments. Zig++ welcomes them, and it is putting AI on both sides of compilation: in the programs it builds and in the build itself; see AI Policy and Governance.

GPU Programming

std.gpu runs Zig++ on NVIDIA, AMD and Apple GPUs. This chapter is written from the documentation comments in lib/std/gpu.zig, lib/std/gpu/*.zig, and from the end-to-end test of everything described here, test/standalone/gpu.

// kernels.zig
const gpu = @import("std").gpu;

export fn wave(data: [*]f32, amplitude: f32, n: u32) callconv(.kernel) void {
    const i = gpu.globalId(.x);
    if (i < n) data[i] = amplitude * @sin(data[i]);
}

An exported function with the .kernel calling convention is a kernel. Kernels can use the rest of the standard library as long as they avoid operating system services: std.fmt, std.json, std.mem, std.base64, hash maps, array lists, and the device allocators all work on the GPU, up to the limits that a target has (see “What is not there yet” at the end of this chapter).

The device API

The device-side functions in std.gpu are implemented for NVPTX, AMDGPU and air64, the target of Apple GPUs. The indexing functions and syncThreads use builtins that also exist for SPIR-V.

Indexing

pub const Dim = enum(u2) { x, y, z };

pub inline fn threadIdx(comptime dim: Dim) u32
pub inline fn blockIdx(comptime dim: Dim) u32
pub inline fn blockDim(comptime dim: Dim) u32
pub inline fn gridDim(comptime dim: Dim) u32
pub inline fn globalId(comptime dim: Dim) u32

These are CUDA’s indexing functions. globalId(dim) is blockIdx(dim) * blockDim(dim) + threadIdx(dim): the index of the calling thread within the whole grid. threadIdx, blockIdx, and blockDim are @workItemId, @workGroupId, and @workGroupSize. On Apple GPUs the hardware provides gridDim and globalId directly, as the air.threadgroups_per_grid and air.thread_position_in_grid builtins, one component per dimension that the kernel asks for.

Synchronization

pub inline fn syncThreads() void

Waits until every thread of the block has reached this call, and makes the memory writes that each thread made before the call visible to the others. Every thread of the block must reach the same call; anything else is undefined behavior. It is @workGroupBarrier().

Warps

pub const warp_size: comptime_int
pub const WarpMask = @Int(.unsigned, warp_size);
pub inline fn laneId() u32

warp_size is 32, except on AMD: 64 before GFX10, and 32 from GFX10 unless wavefrontsize64 or wavefrontsize32 selects otherwise. laneId() is the index of the calling thread within its warp, from 0 to warp_size - 1.

An Apple GPU runs a thread in a SIMD group of 32 lanes, which is what a warp is there: warp_size is 32, and laneId() is the thread’s position in its SIMD group. Apple’s ballot is a 64-bit word with a bit per lane, of which the lanes of the calling group are the low 32 bits, and that is what ballot returns.

pub inline fn shflDown(value: anytype, delta: u32) @TypeOf(value)
pub inline fn shflUp(value: anytype, delta: u32) @TypeOf(value)
pub inline fn shflXor(value: anytype, lane_mask: u32) @TypeOf(value)
pub inline fn shflBroadcast(value: anytype, src_lane: u32) @TypeOf(value)

Warp shuffles. Every thread of the warp that has not exited must call them together, and a value read from a thread that has exited is undefined. shflUp and shflDown read delta lanes below or above, and yield the caller’s own value when that lane is outside the warp; shflXor reads laneId() ^ lane_mask; shflBroadcast reads src_lane modulo warp_size. Shuffles support values of at most 64 bits.

pub inline fn all(predicate: bool) bool
pub inline fn any(predicate: bool) bool
pub inline fn uniform(predicate: bool) bool
pub inline fn ballot(predicate: bool) WarpMask
pub inline fn popcount(predicate: bool) u32

pub fn warpReduceSum(value: anytype) @TypeOf(value)
pub fn warpReduceMax(value: anytype) @TypeOf(value)
pub fn warpReduceMin(value: anytype) @TypeOf(value)

Votes and reductions. all and any are true when every, or any, lane’s predicate is true; uniform is true when the predicate has the same value for every lane; ballot has a set bit for each true lane, and popcount counts them. The reductions return the result to every lane, and integer overflow is checked like +, except on Apple GPUs: there each reduction of an f32, i32 or u32 is one hardware instruction for the whole SIMD group, and the integer form of the sum wraps rather than reporting an overflow the way the checked + of the other targets does.

Atomics

pub inline fn atomicAdd(ptr: anytype, operand: @TypeOf(ptr.*)) @TypeOf(ptr.*)
pub inline fn atomicExchange(ptr: anytype, operand: @TypeOf(ptr.*)) @TypeOf(ptr.*)
pub inline fn atomicCAS(ptr: anytype, expected: @TypeOf(ptr.*), new_value: @TypeOf(ptr.*)) @TypeOf(ptr.*)
pub inline fn atomicMin(ptr: anytype, operand: @TypeOf(ptr.*)) @TypeOf(ptr.*)
pub inline fn atomicMax(ptr: anytype, operand: @TypeOf(ptr.*)) @TypeOf(ptr.*)

Each returns the value the location held before the operation. They use relaxed ordering, visible to the whole device, and ptr may be a global, shared, or generic pointer. For other orderings, use @atomicRmw and @cmpxchgStrong.

On Apple GPUs, the operations are exactly the ones the Metal language has: only the relaxed orderings, .monotonic and .unordered, exist, and a stronger one is a compile error. The compare-exchange is the weak form that AIR has, which @cmpxchgStrong and @cmpxchgWeak both lower to. An atomic through a pointer without an address space, which is a pointer into function-local memory, is a compile error as well: give it addrspace(.global) or addrspace(.shared).

Fast math

pub const fast = struct {
    pub inline fn sin(x: f32) f32
    pub inline fn cos(x: f32) f32
    pub inline fn sqrt(x: f32) f32
    pub inline fn fma(a: f32, b: f32, c: f32) f32
};

Hardware approximations: PTX sin.approx.f32, cos.approx.f32, and sqrt.approx.f32, AMD v_sin_f32 and v_cos_f32, or Apple’s air.fast_sin, air.fast_cos, and air.fast_sqrt. They are faster than @sin, @cos, and @sqrt, and their error is bounded in absolute terms rather than relative to the input. AMD’s square root is not one of them: its instruction is accurate to one unit in the last place, so fast.sqrt is a compile error there.

fast.fma(a, b, c) is a * b + c with a single rounding, the fused multiply-add that each of the three GPUs has an instruction for. It is the one function of the namespace whose result is exact: air.fma.f32 on Apple GPUs, and @mulAdd on the others, which is LLVM’s llvm.fma.f32, fma.rn.f32 on PTX and v_fma_f32 on AMD.

Printing and panics

pub fn print(comptime fmt: []const u8, args: anytype) void
pub fn assertFail(message: []const u8) noreturn

print formats like std.fmt and writes to the host’s standard output, truncating at a 256-byte stack buffer. NVIDIA prints at the next host/device synchronization. AMD writes into a 1 MiB buffer that hip.Context.loadModule connects and hip.Context.synchronize drains, dropping output beyond that; a code object loaded by another host prints nothing.

assertFail stops the launch and reports the message with the block and thread that failed, truncated to 255 bytes. std.debug.defaultPanic calls it for .cuda and .amdhsa targets, so a panic in a kernel reports its message to the host. After a failed assertion, CUDA’s next Context.synchronize returns error.Assert and the context cannot run more kernels, while HIP’s next synchronization writes to stderr, returns error.Assert, and leaves the context usable.

On Apple GPUs, both functions are compile errors, and the error names the reason: Metal has neither printf nor __assertfail. The default panic namespace of an air64 module is therefore std.debug.no_panic: a failed safety check traps without formatting a message, which stops the launch and leaves its results unwritten.

Allocators

pub const device_heap: std.mem.Allocator

pub fn BumpAllocator(comptime size: usize) type

device_heap is the CUDA device heap: memory that outlives a block, freed with free. Its default size is 8 MiB, unless the host changes cuda.Limit.malloc_heap_size before the heap is first used. It is implemented with NVPTX-only syscalls, and is a compile error on other architectures.

BumpAllocator(size) carves allocations out of block-shared memory, which is gone when the block finishes:

var heap: [16 * 1024]u8 addrspace(.shared) = undefined;

export fn kernel() callconv(.kernel) void {
    var bump = std.gpu.allocators.BumpAllocator(heap.len).init(&heap);
    if (std.gpu.threadIdx(.x) == 0) {
        var list: std.ArrayList(u32) = .empty;
        list.append(bump.allocator(), 42) catch return;
    }
}

Every thread of the block must call init, with the same buffer, before any of them allocates: it waits at a barrier for the thread that writes the offset of the unused memory. used() reports how much has been allocated, allocations may race with each other, and only the most recent allocation can be returned to the allocator. Buffers are limited to 4 GiB.

For memory used by a single thread, std.heap.FixedBufferAllocator needs no synchronization. All three implement std.mem.Allocator, so the containers in std work in kernels.

Compiling kernels

For NVIDIA, compile the kernel module to PTX with zig build-obj, then load the PTX with cuda.Context.loadModule:

zig build-obj -target nvptx64-cuda -mcpu=sm_75 -O ReleaseFast -fno-emit-bin -femit-asm=kernels.ptx kernels.zig

PTX for an older -mcpu runs on newer GPUs, because the driver compiles it for the GPU that loads it.

For AMD, compile it to a code object with zig build-lib -dynamic, then load it with hip.Context.loadModule:

zig build-lib -dynamic -target amdgcn-amdhsa -mcpu=gfx1036 -O ReleaseFast kernels.zig

A code object only runs on the architecture that -mcpu names; hip.Device.archName reports it, as gfx1036, or with features as gfx90a:sramecc+:xnack-, whose compiler spelling is -mcpu=gfx90a+sramecc-xnack. On AMD, the first shared variable can live at address 0, so index shared variables or @addrSpaceCast them to generic pointers instead of building a shared pointer with @ptrFromInt(0).

For Apple GPUs, compile the kernel module to the .metallib container that the Metal runtime loads, then load the container with metal.Context.loadModule:

zig build-obj -target air64-macos -O ReleaseFast -femit-bin=kernels.metallib kernels.zig

The object file of the air64-macos target is the library itself, so no linker, no Metal toolchain, and no macOS SDK are part of the pipeline: the GPU’s driver compiles the AIR of the module when the host builds a pipeline. -femit-llvm-bc writes the downgraded AIR bitcode that goes into the container, and -femit-asm and -femit-llvm-ir are errors, because there is no AIR assembly and no textual IR that this compiler could write.

A Metal library carries the AIR version, the Metal language version, and the container version of one macOS release, which the deployment target of the compile selects: macOS 13 gives AIR 2.5, Metal 3.0, and container 1.2.7; 14 gives 2.6, 3.1, and 1.2.7; 15 gives 2.7, 3.2, and 1.2.8; 26 gives 2.8, 4.0, and 1.2.9; and 27 gives 2.9, 4.1, and 1.2.9. Name the release as the version of the OS in the target, as in -target air64-macos.26.0, or as .os_version_min in a Target.Query.

An Apple kernel’s parameters are the ones a Metal host binds by index: a pointer into the device, constant, or threadgroup address space is a buffer, such as [*]addrspace(.global) const f32, which the host passes with setBuffer:offset:atIndex:, and a scalar or an aggregate is passed as bytes with setBytes:length:atIndex:, which the compiler loads through a constant pointer. Threadgroup memory is a module-level variable in the shared address space, whose size must be known at compile time, because the runtime takes it from the pipeline: 32 KiB on current Apple GPUs, 16 KiB on some older families. Dynamic threadgroup memory is not part of the API. The position of the calling thread is not a parameter; the dispatch supplies it.

In a build script, compile kernels with b.addObject and embed getEmittedAsm() for PTX, or b.addLibrary with .linkage = .dynamic and embed getEmittedBin() for a code object:

const kernels = b.addObject(.{
    .name = "kernels",
    .root_module = b.createModule(.{
        .root_source_file = b.path("kernels.zig"),
        .target = kernel_target,
        .optimize = .fast,
    }),
});
exe.root_module.addAnonymousImport("kernels.ptx", .{
    .root_source_file = kernels.getEmittedAsm(),
});

An Apple container is an emitted binary as well: b.addObject with an air64-macos target, whose getEmittedBin() is the .metallib to embed.

test/standalone/gpu/build.zig builds all three kinds, in a debug and a fast variant: the CUDA and HIP images, with the AMD code objects for a set of architectures given as -Damdgpu-arch (comma-separated, default gfx1030), and the containers of metal_kernels.zig for air64-macos, with a macOS 26 deployment target.

The host APIs

std.gpu.cuda is the CUDA driver API and std.gpu.hip is the HIP runtime library. They expose the same names, and a program switches between them by changing the import. std.gpu.metal is the Metal framework, whose objects and calls are Metal’s own rather than a third copy of the other two:

std.gpu.cudastd.gpu.hipstd.gpu.metal
Loads at run timelibcuda.so.1libamdhip64.so.7, .so.6, .so, or amdhip64_7.dll, _6.dllMetal.framework, /usr/lib/libobjc.A.dylib, /usr/lib/libSystem.B.dylib
PlatformLinux, with libcLinux with libc, and Windows without it (ntdll.LdrLoadDll)macOS, with libc
Toolkit needed to buildnonenonenone
Module imagePTX, null-terminated: [:0]const u8a code object for one architecture: []const u8a .metallib: []const u8
Allocation failureerror.OutOfDeviceMemoryerror.OutOfMemoryerror.OutOfMemory

The CUDA runtime library, NVRTC, events, and graphs are not part of std.gpu.cuda; events, graphs, the memory pools of stream-ordered allocation, and the rest of the runtime are not part of std.gpu.hip; and the rest of the Metal framework, the Metal Shading Language compiler, and Metal’s own runtime libraries are not part of std.gpu.metal, which sends its messages to the framework’s Objective-C objects with objc_msgSend. All three require the Driver to stay open, and not to move, while objects that point to it are in use. The Metal framework is loaded with dlopen, which is part of libc, so a program that uses std.gpu.metal links libc and needs neither the macOS SDK nor an Objective-C compiler.

The CUDA and HIP namespaces export the same Driver, Device, Context, Module, Function, Buffer(T), Stream, LaunchConfig, DevicePtr, Version, ComputeCapability, Attribute, Limit, ModuleOptions, Error, and OpenError, and the sequence for a kernel launch is the same in both:

var driver = try cuda.Driver.open();
defer driver.close();

const context = try (try driver.device(0)).retainPrimaryContext();
defer context.release();

const module = try context.loadModule(@embedFile("kernels.ptx"), .{});
defer module.unload();

var data: [1000]f32 = undefined;
for (&data, 0..) |*x, i| x.* = @floatFromInt(i);

const buffer = try context.alloc(f32, data.len);
defer buffer.free();
try buffer.copyFromHost(&data);

const wave = try module.function("wave");
try wave.launch(cuda.LaunchConfig.linear(data.len, 256), .{ buffer, @as(f32, 2), @as(u32, data.len) });

try context.synchronize();
try buffer.copyToHost(&data);

Driver.open() returns error.DriverNotFound when the library is absent and error.IncompatibleDriver when it lacks a function Zig++ needs. retainPrimaryContext makes the context current. Module.function looks up a kernel by the name it was exported with, and takes a null-terminated name.

std.gpu.metal is the same shape where Metal matches the other two, and its own where it does not. Driver.open reports error.FrameworkNotFound on any system other than macOS, error.NoDevice when the framework finds no GPU, and error.NotSupported on an Intel Mac, where the MTLSize of a message passes and returns by other rules than the arm64 ABI that the messages of this namespace are built for. Driver.device() is the GPU of the machine, and Device.createContext makes the context that holds the command queue, because Metal has no primary context. Context.loadModule takes the bytes of a .metallib; Module.function finds a kernel by the name it was exported with; and Function.pipeline is where the Metal compiler runs, so it is the call that reports a kernel the compiler refused, with what it said in Options.error_log. Pipeline.launch encodes the dispatch:

var driver = try metal.Driver.open();
defer driver.close();

const context = try driver.device().createContext();
defer context.release();

const module = try context.loadModule(@embedFile("kernels.metallib"), .{});
defer module.unload();

const wave = try module.function("wave");
defer wave.release();
const pipeline = try wave.pipeline(.{});
defer pipeline.release();

var data: [1000]f32 = undefined;
for (&data, 0..) |*x, i| x.* = @floatFromInt(i);

const buffer = try context.alloc(f32, data.len);
defer buffer.free();
buffer.copyFromHost(&data);

try pipeline.launch(metal.LaunchConfig.linear(data.len, 256), .{ buffer, @as(f32, 2), @as(u32, data.len) });

try context.synchronize();
buffer.copyToHost(&data);

A Buffer of std.gpu.metal is unified memory: values() is the memory as a slice of T, the same bytes the kernels read and write, and copyFromHost, copyToHost, and zero copy and fill it with @memcpy and @memset rather than calling into the framework, so no copy of the framework’s is between the host and the memory. A launch binds its arguments in parameter order: a Buffer with setBuffer:offset:atIndex:, and anything else with setBytes:length:atIndex:, at the index of the argument, from which the compiler loads a scalar through a constant pointer; the position of the calling thread comes from the dispatch. A threadgroup of no threads, or one larger than the kernel allows, is error.InvalidValue before anything is encoded, and a kernel that fails on the GPU is reported by Context.synchronize, which is why the launch is asynchronous.

The errors of the framework are named after what happened rather than after the numeric code: the codes of the MTLLibraryErrorDomain and MTLCommandBufferErrorDomain become error.InvalidLibrary, error.CompileFailure, error.FunctionNotFound, error.PageFault, and the rest, and a failure of a domain or a code that this version does not know is reported as the failure of the call. Options.error_log receives what the framework said, as the domain, the code, and the message.

Launch configuration

pub const Dim3 = struct { x: u32 = 1, y: u32 = 1, z: u32 = 1 };
pub const LaunchConfig = struct {
    grid: Dim3 = .{},
    block: Dim3 = .{},
    shared_memory: u32 = 0,
    stream: ?Stream = null,
};

shared_memory is dynamic shared memory per block, on top of any the kernel declares statically, and a null stream selects the null stream. LaunchConfig.linear(n, block_size) covers n threads with blocks of block_size, rounding the grid up; block_size must not be zero, and n == 0 produces no blocks, which launch rejects with error.InvalidValue.

std.gpu.metal’s LaunchConfig has grid and block only: Metal has no dynamic shared memory, and a context has one command queue rather than a choice of streams.

The launch arguments are passed in parameter order, one per kernel parameter:

  • a Buffer(T) passes the device address it holds;
  • a DevicePtr (an enum(u64) { _ }, a device memory address) passes unchanged;
  • integers, floats, booleans, enums, vectors, and extern or packed structs pass by value.

A slice or a host pointer as a kernel argument, an untyped compile-time number, a value with an unsupported layout, or a non-tuple argument list is a compile error. Scalars need an explicit type: @as(u32, data.len).

std.gpu.metal binds the arguments the same way, with one difference: it has no DevicePtr, and every argument that is not a Buffer binds its bytes at the index of the parameter.

pub const DevicePtr = enum(u64) { _ };

pub fn Buffer(comptime T: type) type

Buffer(T) has the methods free, copyFromHost, copyToHost, and zero in all three namespaces, and CUDA’s and HIP’s have the fields driver, ptr, and len. Copies may be shorter than the buffer, never longer. Stream has destroy and synchronize, and HIP’s stream synchronization does not itself flush the GPU print and assert output: that happens at Context.synchronize.

Attributes, limits, and error sets

Attribute and Limit are enums of the vendor’s numeric codes: Attribute has the same tags in std.gpu.cuda and std.gpu.hip, with different numeric values, so use the tags. Limit is stack_size, printf_fifo_size, and malloc_heap_size, and HIP’s runtime reports error.UnsupportedLimit for printf_fifo_size, which is not the buffer that std.gpu.print uses.

OpenError is error{ DriverNotFound, IncompatibleDriver } plus Error. Beyond the difference in allocation failure, HIP’s set has EccNotCorrectable, SetOnActiveProcess, and no PTX-JIT or profiler codes; CUDA’s has EccUncorrectable, PrimaryContextActive, and the PTX and profiler codes. Both have error.Assert, which is what a kernel assertion turns into at the next synchronization.

Running and testing on a GPU

test/standalone/gpu is the end-to-end test: a port of the examples of the ugpu project, plus kernels that cover the rest of std.gpu. Its host program computes the expected results on the CPU and compares them with what the GPU produced, and each of the twenty test groups — vector_add, reduce, histogram, warp, matrix_mul, convolution, stencil, stdlib, hashmap, base64, string_search, json, dynamic, printf, hello_gpu, builtin_math, f128, parse_float, device_heap, and bump_allocator — reports its launches, checks, and failures, and exits non-zero if any check fails.

cd test/standalone/gpu
zig build test

The CUDA variant is built on Linux, and the HIP variant on Linux and Windows. Each kernel image is built in a debug and a fast variant, and the host program runs once normally and once with assert as an argument, which launches a kernel that indexes out of bounds and checks that the reported message names the index, the length, and the failing thread.

The Metal variant is built wherever the suite is. build.zig compiles metal_kernels.zig for air64-macos with the compiler under test, in a debug and a fast variant, installs the two containers next to the host program gpu_metal_host, and runs the host on each of them, so the suite exercises the air64 target on any host. The host runs a vector add, a reduction whose partial sums go through threadgroup memory and a device atomic counter, a kernel with scalar parameters, an index computed with a usize multiply (which a debug build checks for overflow), the 128-bit products and overflow flags of 64-bit multiplies, the overflow flags of adds and subtracts of every width, tables of integers and of structs in the constant address space, a table of strings, @errorName, std.fmt.parseFloat, and allocations through std.mem.Allocator, checking the results against the CPU bit for bit and reporting the SIMD width of the kernels; -Dmetallib=a.metallib,b.metallib adds the containers that Apple’s own metal compiler or the spike built, to compare Apple’s code with this one’s. A machine without the Metal framework skips every library it was given, a library without one of the kernels skips its test, and a run with no library at all skips everything: all three exit 0. The suite therefore builds its containers and its host on any host, and only a Mac runs the kernels. On macOS, a framework that will not open and a library that will not load are failures.

A machine without the driver or without a device is not a failure: the test reports it and passes, for error.DriverNotFound, error.NoDevice, a driver that sees no devices, or an AMD code object that matches no device (it suggests the -Damdgpu-arch value to build for). Everything else fails.

CI runs it as part of the standalone tests:

zig build test-standalone -Dskip-non-native -Dskip-release

CI’s Linux machine runs the CUDA variant on an RTX 5090 Laptop GPU, and its Mac runs the Metal variant on an M4. On a machine with no GPU driver, the suite compiles its kernels for all three vendors and then skips itself.

What is not there yet

  • MLIR lowering for tensor cores and kernel fusion.
  • Warp shuffles of values wider than 64 bits, and std.gpu.allocators.device_heap on architectures other than NVPTX, both of which are compile errors.
  • On Apple GPUs, f64 is a compile error, the atomics are the relaxed ones only, print and assertFail are compile errors, and threadgroup memory must be a static variable sized by the pipeline’s 32 KiB. Metal has no generic address space, so a pointer into constant data (a string literal, a table) that meets a pointer to thread memory is a compile error naming the function, and a checked multiplication of i128 values is supported only where both operands fit in 64 bits.
  • On Apple GPUs, a table of function pointers, like an allocator’s vtable, works only where inlining makes the calls through it direct, as it does for std.heap.FixedBufferAllocator through std.mem.Allocator. A call through such a table that stays indirect is a compile error that names the table, because Metal has no indirect calls and Apple’s toolchain relocates no address in constant data.
  • The AIR version, the Metal language version, and the container version of a Metal library are the ones of the macOS release row that the deployment target selects, not a choice of the program.
  • On HIP, ModuleOptions.error_log is ignored, while CUDA’s PTX JIT fills it in when a module fails to load.

Live at Head

Live at Head. Zig++ has no versions. Every push to master is a release, and every release is kept forever. You pin the build you use, the way you pin every dependency, and you upgrade when you are ready. Zig++ does not number its releases; upstream does it for us.

The rules

  1. Every push to master is a release. The Release workflow publishes one for every push, whatever the push changes, and no push’s run cancels another’s. Versions and Releases says what a release holds.

  2. Builds are forever. Every published build stays downloadable at a stable URL with its checksums. It is never deleted and never replaced: a release is published once, with all of its files, and never changed after. GitHub enforces this for every release published from 26 September 2026 on, which are immutable: their files and their tags cannot be changed. The twelve published before, 0.17.0-dev.2373 to 0.17.0-dev.2469, are kept by the same rule without that lock, because GitHub makes a release immutable only as it is published.

  3. Master is append-only. No force pushes, no rewritten history, no retagging. A pin is a commit, and commits do not move. GitHub enforces this too: master refuses a push that is not a fast-forward and refuses to be deleted, and no tag in the repository can be moved or deleted.

  4. Version identity comes from upstream tags. Zig++ does not create bare semantic-version tags such as 1.0.0. zigpp-* tags identify individual Zig++ builds and releases. They are immutable artifact locators and are excluded from version derivation. zigpp-* tags never move and never disappear.

    A build’s version is upstream’s version in development, the number of commits since upstream’s last release tag, and the commit it was built from: 0.17.0-dev.2469+zigpp.04926fc36. build.zig derives it with git describe --match "*.*.*" --exclude "zigpp-*", so only upstream’s tags take part, and the build tag of that build is zigpp-0.17.0-dev.2469.

  5. The root project’s pin wins. Dependencies state minimums. A pin older than a dependency’s minimum is an error that names the dependency and the build to move to. The root pin wins today: zig runs the build that the nearest build.zig.zon names and reads no dependency’s pin. The minimum check is planned: #39.

  6. Breakage is announced at the upgrade, not by a number. Every commit that breaks existing code carries a Breaking: trailer saying what breaks and what to write instead:

    Breaking: std.zig.Server.serveErrorBundle takes the message tag first. Write serveErrorBundle(.error_bundle, bundle).
    

    Merges of upstream Zig are the largest source of breakage, so the merge commit carries Breaking: trailers for upstream’s changes in the merged range. The trailers are in force today. Collecting them into the download index is planned in #37, and printing them when a project upgrades in #38.

  7. There are no LTS branches. Security fixes land on head, and advisories name the affected range of builds. The advisory format, and a warning on every compile with an affected build, are planned: #40.

  8. Docs travel with the pin. The build you run serves its own std docs and language reference. zig std serves the standard library documentation of the build that runs it, from that build’s own sources, and from 0.17.0-dev.2476 on it serves that build’s language reference at /langref.html too. Every release archive carries the language reference at doc/langref.html.

  9. Upstream Zig and every vendored dependency are tracked continuously and pinned the same way. Upstream Zig is merged into master, never rebased onto, and the libraries Zig++ carries in lib/ come with it. LLVM is pinned by the devkit Zig++ builds against. The packages Zig++ blesses follow the same rule: raylibz pins its raylib by commit, from a fork that merges raylib’s master.

  10. 1.0 is a milestone, not a version. The build that closes it ships like every other build. There is no Zig++ 1.0, and the build system enforces it. build.zig derives every version from upstream’s tags, so a bare 1.0.0 tag on master would stop every build from then on: the tagged commit fails with the first error, and every commit after it with the second.

    error: zig version "0.17.0" does not match Git tag "1.0.0"
    error: zig version 0.17.0 must be greater than tagged ancestor "1.0.0"
    

Pinning a build

A project pins the build it is built with in its build.zig.zon:

.minimum_zig_version = "0.17.0-dev.2469+zigpp.04926fc36",

Any zig command in the project then runs that build, downloading it the first time; see Any Zig Version. To install a build by hand, ppup install 0.17.0-dev.2469+zigpp.04926fc36, or take its archive from Downloads and check it against the release’s SHA256SUMS.

Upgrading today

  1. Pick a newer build on Downloads, or read .master.version from /download/index.json.

  2. Read what broke between your build and that one. The version of each ends in its commit, so in a clone of Zig++:

    git log --grep='^Breaking:' --format='%h %s%n%(trailers:key=Breaking,valueonly,separator=%x0A)%n' 04926fc36..70dc1eaec
    
  3. Move the pin, build, and fix what the notes name.

zig upgrade will do the first two steps for you. It is planned: #38.

Where the tooling stands

RuleToday
Every push to master is a releaseEnforced by the Release workflow.
Builds are foreverEnforced: immutable releases from 26 September 2026 on.
Master is append-onlyEnforced by the repository’s rules.
Version identity comes from upstream tagsEnforced by build.zig and CMakeLists.txt.
The root project’s pin winsWorks. Dependency minimums: planned, #39.
Breaking: trailersIn force. Collected notes and zig upgrade: planned, #37 and #38.
No LTS branches, advisoriesNo LTS branches. Advisories and compile warnings: planned, #40.
Docs travel with the pinWorks: std docs always, the language reference from 0.17.0-dev.2476.
Upstream and vendored code tracked and pinnedBy practice: merges of upstream, a pinned LLVM devkit.
1.0 is a milestoneEnforced by build.zig.

Credit, and prior art

“Live at Head” is Abseil’s phrase, from Titus Winters’ CppCon 2017 talk “C++ as a ‘Live at Head’ Language”.

Versions and Releases

What a version says

0.17.0-dev.2361+zigpp.5b96e6d21
└────┬───┘ └─┬─┘ └──────┬──────┘
     │       │          └─ build metadata: zigpp, and the commit it was built from
     │       └─ commits since the 0.16.0 tag
     └─ the upstream version it is based on

The base version, 0.17.0, is upstream’s version in development: build.zig names it, and it must be greater than the last upstream release this commit descends from, 0.16.0. That release’s tag is where the commit count starts, so dev.2361 is 2361 commits after it. The count comes from git describe, which considers only upstream’s tags, and the commit is its abbreviated hash. A checkout where git describe cannot answer is built as the plain base version. Zig++ numbers nothing else; see Live at Head.

The version is what zig version prints, what zig env reports, and what names the archives of a release. It is also what tells two Zig++ builds apart: unlike 0.17.0-dev, it is impossible for two different Zig++ compilers to report the same version.

The builder takes -Dversion-string to set it by hand, which the release workflow does not need: it reads the version out of the compiler it just built.

Rolling releases

The Release workflow publishes a release on every push to master, for four targets:

TargetRunner that checks it
x86_64-linuxubuntu-24.04
aarch64-linuxubuntu-24.04-arm
aarch64-macosmacos-latest
x86_64-windowswindows-2025

The jobs are: build a bootstrap compiler (with the newest release, or from source alone when the release cannot build it) and, with it, the one language reference every archive carries, pack an archive for each target, unpack and smoke-test each archive on a runner of that architecture, and publish. The smoke test runs zig version and zig env, compiles and runs a program that uses private fields, checks that naming a private field from another file is rejected, and compiles and runs C with the Clang and libc the archive carries.

Every push to master is a release, including one that changes only the book, and no push’s run cancels another’s: a burst of pushes publishes one release for each of them. Their runs can finish in any order.

A pull request that changes the release machinery builds the archives and checks them, and publishes nothing.

The release’s tag is the version without its build metadata — zigpp-0.17.0-dev.2361 — because + is legal in a tag but awkward in URLs. build.zig and CMakeLists.txt exclude zigpp-* tags when they derive the version, so the tag itself does not change it.

Each release carries:

  • one archive per target, named zig-<arch>-<os>-<version>.tar.xz, or .zip for Windows;
  • index.json, the download index described below;
  • SHA256SUMS, the SHA-256 of every archive and of index.json.

A published release is never changed. The assets go up to a draft, and the draft is published once they are all there, so a run that fails partway leaves a draft that nobody could download: running it again replaces the draft’s files and publishes it. Running it again after it published changes nothing. The newest version is the latest release, whichever run finishes last.

The download index

/download/index.json collects every release in one document, in the format of ziglang.org’s own download index, so tools that read that one can read this one. master is the newest release, and every release also has a key of its own:

{
  "master": {
    "version": "0.17.0-dev.2361+zigpp.5b96e6d21",
    "date": "2026-09-24",
    "x86_64-linux": {
      "tarball": "https://github.com/mattneel/zigpp/releases/download/zigpp-0.17.0-dev.2361/zig-x86_64-linux-0.17.0-dev.2361%2Bzigpp.5b96e6d21.tar.xz",
      "shasum": "e3b0c442...",
      "size": "52345678"
    },
    "aarch64-linux": { "...": "..." },
    "aarch64-macos": { "...": "..." },
    "x86_64-windows": { "...": "..." }
  },
  "0.17.0-dev.2361+zigpp.5b96e6d21": { "...": "same as master" }
}

Each target key holds the URL of its archive, its SHA-256, and its size in bytes. Every release has the same four in the same place, so a script that wants the newest Linux build for its architecture reads .master["x86_64-linux"].tarball and checks it against .shasum.

Because every push to master is a release, master in this index is a real build, not a nightly snapshot: it is the newest published release.

What the site serves

The standard library documentation at /std/ and the language reference at /langref.html are generated from the newest release, so they describe the compiler you download from Downloads rather than an unreleased master.

Until the first release is published, both are stand-ins that say so, and the Downloads chapter explains what a release will contain.

Any Zig Version

A project can pin the exact compiler it is built with, in its build.zig.zon:

.minimum_zig_version = "0.15.1",

When it does, every zig command run in that project, or in any directory below it, runs that version instead of the zig that was invoked: zig build, zig test, zig env, and zig cc alike. The first time a version is needed, Zig++ downloads it into the global cache and verifies it; after that, it only reads the project’s build.zig.zon and starts the installed compiler.

zig init writes the version of the compiler that ran it, so a new project keeps building with the compiler that made it.

The version names the exact compiler, the way anyzig reads it, not a lower bound. It can be:

VersionWhere it comes fromVerified with
a Zig++ release, 0.17.0-dev.2361+zigpp.5b96e6d21the GitHub release zigpp-0.17.0-dev.2361the SHA-256 in the release’s index.json
an upstream Zig release, 0.15.1ziglang.org/downloadthe SHA-256 in upstream’s download index, and the minisign signature
an upstream dev build, 0.16.0-dev.1234+abcdef012ziglang.org/buildsthe minisign signature

Every upstream archive is checked against the minisign signature that ziglang.org publishes next to it, made with the Zig Software Foundation’s key, and the signature has to name that archive. ziglang.org keeps only its recent dev builds, so an archive it no longer has comes from one of the community mirrors, tried in a random order, whose copy has to pass the same check. An archive that fails its check is never installed.

Running a version by hand

zig any 0.15.1 version   # run that exact version, installing it if needed
zig any list             # the installed versions, one per line
ZIG_ANY=off zig version  # the zig that was invoked, whatever the project pins

zig any <version> takes any command after the version, the same way zig does.

Where versions go

Each version lives in <global cache>/any/<version>/, next to the packages that zig fetch downloads; zig env prints the global cache directory. An install downloads and unpacks in a temporary directory and moves the result into place in one step, so a version is either complete or absent, even if the download is interrupted. Two zig commands that need the same new version at the same time end up with one install of it.

Deleting a version’s directory uninstalls it.

What the other version sees

The other version runs with the same arguments and the same environment, except that ZIG_LIB_DIR is removed: it points at the standard library of the zig that was invoked.

A compiler that runs from <global cache>/any/<version>/ never dispatches to that version again, so an installed Zig++ does not restart itself. Every other zig still dispatches: a build step that runs zig from PATH gets the pinned version too, and a project nested inside another one, pinning a different version, gets its own. zig any <version> starts its compiler with ZIG_ANY=off, so the version asked for runs even inside a project that pins another one.

If no source has the version, zig says what it tried, and that ZIG_ANY=off runs the compiler that was invoked instead.

A project created by a Zig++ that was never released, such as one you built from source, pins a version that has nothing to download. Build it with ZIG_ANY=off, or change minimum_zig_version to a released version.

Blessed Packages

Zig++ blesses one way to use a C library from Zig++: fetch the library at a pinned commit, build it with its own build script, translate its headers with translate-c, and put a Zig face on the translation. A blessed package is a package made that way, which Zig++ maintains and tests. The first is raylibz.

raylibz

raylibz is the Zig++-sanctioned wrapper for raylib, the library for making games by Ramon Santamaria (raysan). Thank you to raysan and raylib’s contributors, who have given raylib away under the zlib license since 2013. If raylibz gets you a window, please consider sponsoring raysan.

raylibz is a thin, one-to-one wrapper. Every function of raylib.h has raylib’s name in Zig case, InitWindow is initWindow, and raymath’s functions are methods on the vector and matrix types: Vector2Add(a, b) is a.add(b). The C declarations it wraps are raylib’s own translation, and they are always there as raylibz.c. raylib’s cheatsheet is raylibz’s documentation too.

A window, from nothing

These steps take a machine with nothing on it to a raylib window, on Linux, macOS or Windows.

  1. Install Zig++. On Linux and macOS:

    curl -fsSL https://zigpp.lol/ppup | sh
    

    On Windows, in PowerShell:

    irm https://zigpp.lol/ppup.ps1 | iex
    

    Then open a new terminal, so that zig is on your PATH. See Installing.

  2. On Linux, install the headers raylib’s window layer builds against. On Debian and Ubuntu:

    sudo apt install libx11-dev libxcursor-dev libxrandr-dev libxinerama-dev libxi-dev libxext-dev libxfixes-dev libgl-dev
    

    macOS and Windows need nothing more.

  3. Make a project.

    mkdir hello-raylib
    cd hello-raylib
    zig init
    

    zig init writes build.zig, build.zig.zon and src/. The build.zig.zon it writes pins the Zig++ build that made it, in minimum_zig_version, so the project keeps building with that build whichever zig runs it. See Any Zig Version.

  4. Fetch raylibz, pinned to a commit. raylibz has no tags. Pick a commit; the newest on main is a good one:

    zig fetch --save https://github.com/mattneel/raylibz/archive/bc0ec551a3e7de06739c60dd67b1760e9f432870.tar.gz
    

    That records the URL, and the hash of what it downloaded, in build.zig.zon under the name raylibz.

  5. Give your program the module. In build.zig, after the line that starts const exe = b.addExecutable(, and after the call it opens has closed, add:

    const raylibz = b.dependency("raylibz", .{ .target = target, .optimize = optimize });
    exe.root_module.addImport("raylibz", raylibz.module("raylibz"));
    
  6. Write the program. Replace everything in src/main.zig with:

    const rl = @import("raylibz");
    
    pub fn main() void {
        rl.initWindow(800, 450, "hello, raylib");
        defer rl.closeWindow();
        rl.setTargetFPS(60);
    
        while (!rl.windowShouldClose()) {
            rl.beginDrawing();
            defer rl.endDrawing();
            rl.clearBackground(rl.Color.raywhite);
            rl.drawText("Congrats! You created your first window!", 190, 200, 20, rl.Color.lightgray);
        }
    }
    
  7. Run it.

    zig build run
    

    The first build takes a minute or more, because it compiles Zig++’s build runner and raylib. A window opens; close it, or press Escape.

What just happened

  • zig fetch downloaded raylibz. zig build then fetched raylibz’s own dependency, raylib: mattneel/raylib, raysan’s raylib with its build scripts ported to Zig++’s build API and one fix in the gamepad library it vendors, following raylib’s master.
  • raylib’s own build.zig compiled raylib’s C with the Clang inside Zig++, and chose raylib’s window layer: GLFW, on X11 on Linux. Every one of raylib’s build options passes through raylibz unchanged: add .platform = .rgfw to the b.dependency call from step 5, and the program runs on RGFW instead.
  • The same build script ran translate-c over raylib.h, raymath.h and rlgl.h. Zig++’s translator reads C headers and writes Zig declarations for them, and raylib’s build script publishes those as modules.
  • raylibz imported the translations and put the Zig face on them: initWindow takes a string literal as it is, Color.raywhite is a Zig value, and loadTexture returns error{LoadFailed}!Texture2D, where C returns a texture you must remember to check. raylibz’s tests prove the face against the translation when it compiles: every struct’s layout, every enum value, and that no raylib function is missing.
  • raylib’s own declarations stay one step away: rl.c.InitWindow(800, 450, "hello") calls the translation directly.

Your own C library

A library whose build script does not translate its headers gets the same treatment from your build.zig:

const mylib_h = b.addTranslateC(.{
    .root_source_file = b.path("vendor/mylib/include/mylib.h"),
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("mylib", mylib_h.createModule());

@import("mylib") is then the translated header. Link the library itself as its build script produces it, for example with exe.root_module.linkLibrary on the artifact of its package, and wrap what you use. raylibz’s source shows each of its rules at work.

Downloads

Zig++ publishes a release on every push to master, for x86_64-linux, aarch64-linux, aarch64-macos, and x86_64-windows. The newest is 0.17.0-dev.2531+zigpp.5f8c0c6bc, published 2026-09-27 05:12 UTC.

What a release contains

An archive is named zig-<arch>-<os>-<version>.tar.xz, or .zip on Windows. It holds the zig executable, lib/, doc/langref.html, LICENSE, and README.md. The Installing chapter covers how the compiler finds the lib/ it carries.

SHA256SUMS lists the SHA-256 of every archive and of index.json.

0.17.0-dev.2531+zigpp.5f8c0c6bc

2026-09-27 05:12 UTC · zigpp-0.17.0-dev.2531

SHA256SUMS:

d61b0353aeeb6ea72bb6598de5dfc1c7a6882e490a6c842a1f9e4aad713370f9  zig-aarch64-linux-0.17.0-dev.2531+zigpp.5f8c0c6bc.tar.xz
af610074761d49e83c4dd6244f931737b44fd97c4414c8eb10528d3b1c9ac56b  zig-aarch64-macos-0.17.0-dev.2531+zigpp.5f8c0c6bc.tar.xz
69552cda2ebfbcb3456eb529973c4388a57a31254edcb5029089ac7b0232b7e3  zig-x86_64-linux-0.17.0-dev.2531+zigpp.5f8c0c6bc.tar.xz
3a8ef82747b494efb8dc790b94276e89d2a8f2a3afae642476e121e3bc59e137  zig-x86_64-windows-0.17.0-dev.2531+zigpp.5f8c0c6bc.zip

0.17.0-dev.2529+zigpp.5ebb0ae8a

2026-09-27 04:45 UTC · zigpp-0.17.0-dev.2529

SHA256SUMS:

537f62c35e5d47b9abc8143872ce210f7f43bb2338cce6af0d8dc76fe1d25a48  zig-aarch64-linux-0.17.0-dev.2529+zigpp.5ebb0ae8a.tar.xz
f525c98ad2b2d1b5ba1f59fb6b1c9945c15fab5e5d1fbda3374d0d0efae8a3f4  zig-aarch64-macos-0.17.0-dev.2529+zigpp.5ebb0ae8a.tar.xz
735efd6b610c7be6b71d96fcaa2ebab9deb213e8923233885875d0761804f50e  zig-x86_64-linux-0.17.0-dev.2529+zigpp.5ebb0ae8a.tar.xz
3318919a59658c94c3c2cbeb69a2f0399a62b32970e9bb3a0b85ae143fb85ebe  zig-x86_64-windows-0.17.0-dev.2529+zigpp.5ebb0ae8a.zip

0.17.0-dev.2527+zigpp.959fb520b

2026-09-27 03:59 UTC · zigpp-0.17.0-dev.2527

SHA256SUMS:

289eed2dcc9280ac38c136e6996ba031990bb1895ba41cd16ceb4b32ed600916  zig-aarch64-linux-0.17.0-dev.2527+zigpp.959fb520b.tar.xz
00506a4997c5d12d09ce749ba5d798943cf437d589d118f8278a35250fa21ead  zig-aarch64-macos-0.17.0-dev.2527+zigpp.959fb520b.tar.xz
0c662698955ab16fb2650449460b02930e13f313f9a993579c66e75b912f55aa  zig-x86_64-linux-0.17.0-dev.2527+zigpp.959fb520b.tar.xz
4be5205a7f367a653d2afc705ecd913903055905be085581533233e9d460d75b  zig-x86_64-windows-0.17.0-dev.2527+zigpp.959fb520b.zip

0.17.0-dev.2525+zigpp.61e078103

2026-09-27 03:57 UTC · zigpp-0.17.0-dev.2525

SHA256SUMS:

bb861e262dbde6e709ef63253b019141623006b137df9af965b26b924c7e94c8  zig-aarch64-linux-0.17.0-dev.2525+zigpp.61e078103.tar.xz
86532c6e3411a2791e096fb5f227f83b1d089d5a527d9748c1b3c3d89d037ba1  zig-aarch64-macos-0.17.0-dev.2525+zigpp.61e078103.tar.xz
8072e412d2d2608e49f1101014361ff81a45aaef588e833a589d81f729fd55c9  zig-x86_64-linux-0.17.0-dev.2525+zigpp.61e078103.tar.xz
1a6231345805a823c1b7e202dc1dcad796b9f22519531c507c3826ee57864951  zig-x86_64-windows-0.17.0-dev.2525+zigpp.61e078103.zip

0.17.0-dev.2514+zigpp.d1be2e3f0

2026-09-27 03:11 UTC · zigpp-0.17.0-dev.2514

SHA256SUMS:

ebad84f8de57a4a553bff9a543777beaf3100227cd4155ac8d3bd7142d9f7589  zig-aarch64-linux-0.17.0-dev.2514+zigpp.d1be2e3f0.tar.xz
f4c975641b7c2f7d09c55e050f5ee8605b61680e3387d39232cdbdc30835a1ae  zig-aarch64-macos-0.17.0-dev.2514+zigpp.d1be2e3f0.tar.xz
89905779d5bc1e1e8984645ac0f2481ae196d9504cc8f6cb2d1f137c5b72f530  zig-x86_64-linux-0.17.0-dev.2514+zigpp.d1be2e3f0.tar.xz
5ff35f0eb2fd124d15310aeacca97354b64d79113f069b9a857152c1e6d7ce93  zig-x86_64-windows-0.17.0-dev.2514+zigpp.d1be2e3f0.zip

0.17.0-dev.2512+zigpp.f7be783f7

2026-09-27 03:08 UTC · zigpp-0.17.0-dev.2512

SHA256SUMS:

9ce21f8d71498bb2a8452413ac183fb22f239e90a87329f8cb1632897dd3a8db  zig-aarch64-linux-0.17.0-dev.2512+zigpp.f7be783f7.tar.xz
27fef4b41026ffe177297702c991bab38fcc01a36127d04951a9a044d5d0bafe  zig-aarch64-macos-0.17.0-dev.2512+zigpp.f7be783f7.tar.xz
1ea04bf32e24b8fdba46d05ad6de71c05f199fea0a1ba087098b33406c27f20f  zig-x86_64-linux-0.17.0-dev.2512+zigpp.f7be783f7.tar.xz
3081568fdf722276fccaad56ccfd59ecf86a91f94b506d08eec161efd32fc3ba  zig-x86_64-windows-0.17.0-dev.2512+zigpp.f7be783f7.zip

0.17.0-dev.2510+zigpp.60a1816dd

2026-09-26 22:52 UTC · zigpp-0.17.0-dev.2510

SHA256SUMS:

a2e16786e85bf001740ce77e88d8c1e45d7a0b97c113513a34500260377f7edc  zig-aarch64-linux-0.17.0-dev.2510+zigpp.60a1816dd.tar.xz
b84bd66dfb3a2bcdb78f52918e35737de4774b7ed2422a541fd13cbb0acfe77d  zig-aarch64-macos-0.17.0-dev.2510+zigpp.60a1816dd.tar.xz
ffb0738c7ecf0957a0e2dffe3e0ab5782895044522efdf32368b6bba413ae7d7  zig-x86_64-linux-0.17.0-dev.2510+zigpp.60a1816dd.tar.xz
afc900c728c9a4e5e94da6e1df583d85da1ae8885604e69ff43eb8cacd26872a  zig-x86_64-windows-0.17.0-dev.2510+zigpp.60a1816dd.zip

0.17.0-dev.2508+zigpp.091bb7527

2026-09-26 22:48 UTC · zigpp-0.17.0-dev.2508

SHA256SUMS:

7007ab5db61d50dbe3d7c9720dbfd51a722632405f40b52fcde6bea2e3fef40b  zig-aarch64-linux-0.17.0-dev.2508+zigpp.091bb7527.tar.xz
128791a0a58edf839266f83616a3a262381215407f59b3d1afdd1b9dc6cda8ba  zig-aarch64-macos-0.17.0-dev.2508+zigpp.091bb7527.tar.xz
ac9ee21ae0b2aa7f8c910d35699cf792fcdcec64cb45b6ab735de4e59fb94645  zig-x86_64-linux-0.17.0-dev.2508+zigpp.091bb7527.tar.xz
aa511126f817cab0de28e9138a2e1e099f8b1b43f0390724cef5efa5736dceb2  zig-x86_64-windows-0.17.0-dev.2508+zigpp.091bb7527.zip

0.17.0-dev.2506+zigpp.e75228e99

2026-09-26 21:18 UTC · zigpp-0.17.0-dev.2506

SHA256SUMS:

51459fd3ccd426d0463d8fa54c5481d04f6a091c7a0564af85d531c10d4d9c51  zig-aarch64-linux-0.17.0-dev.2506+zigpp.e75228e99.tar.xz
549136e9bb47172ef03fff0e85e3649ba1709aa2a395c84a0ade2941009283d4  zig-aarch64-macos-0.17.0-dev.2506+zigpp.e75228e99.tar.xz
848c3836716135246d5990be0e9aaa620d030972f58b348c26f5492793793204  zig-x86_64-linux-0.17.0-dev.2506+zigpp.e75228e99.tar.xz
c3b31abf8760471b7c7a337c79925f68ee2d9a80152dba9699cea1fb3bcced02  zig-x86_64-windows-0.17.0-dev.2506+zigpp.e75228e99.zip

0.17.0-dev.2491+zigpp.e03a07b67

2026-09-26 17:45 UTC · zigpp-0.17.0-dev.2491

SHA256SUMS:

d403d43a210e9b34ebcf4ee36d7eb3478493dc61fbe3afd18a5590ed168f3b8b  zig-aarch64-linux-0.17.0-dev.2491+zigpp.e03a07b67.tar.xz
f6043e99d24e94170baa5b2e487e3ab7c61bec1ede15c4441cc031c4816c015b  zig-aarch64-macos-0.17.0-dev.2491+zigpp.e03a07b67.tar.xz
d11e037b2f204fc9e0b637e06cdb2c6d2fb79015becb8a978448e78c80882256  zig-x86_64-linux-0.17.0-dev.2491+zigpp.e03a07b67.tar.xz
dbde986ca8a07417a3c1c3b91d0aae70b13a168eea5260830eab07900b6edb76  zig-x86_64-windows-0.17.0-dev.2491+zigpp.e03a07b67.zip

0.17.0-dev.2489+zigpp.255932694

2026-09-26 17:21 UTC · zigpp-0.17.0-dev.2489

SHA256SUMS:

156cf2d9bf7ca2e58074df97805ffad059848760f53d762acef2d6ea9bd899fc  zig-aarch64-linux-0.17.0-dev.2489+zigpp.255932694.tar.xz
1ab23d136e18bfecd88f03be41031a63d256f81392625c3fa4c1fe47c88a4655  zig-aarch64-macos-0.17.0-dev.2489+zigpp.255932694.tar.xz
1a48b18a032cfacb91a4b737ba9ee4d16b5862a5a3c04e046bbf5065303645bf  zig-x86_64-linux-0.17.0-dev.2489+zigpp.255932694.tar.xz
56d017354a36e2b0c44c3321421b8adc63d7cfb63bb93cb7a6587d86469dbd74  zig-x86_64-windows-0.17.0-dev.2489+zigpp.255932694.zip

0.17.0-dev.2487+zigpp.cd699eced

2026-09-26 17:15 UTC · zigpp-0.17.0-dev.2487

SHA256SUMS:

59b465536056118b20fef7b8bba5ce92a5b37b8852f5f35104754142d3b93cc7  zig-aarch64-linux-0.17.0-dev.2487+zigpp.cd699eced.tar.xz
02bbead55739159af50522b2176ceb407321411559e6526b86806a7b1c3dbc50  zig-aarch64-macos-0.17.0-dev.2487+zigpp.cd699eced.tar.xz
46713161033959e52911589d6bd6269b86c7ff99f5d18560119e3734a238b30f  zig-x86_64-linux-0.17.0-dev.2487+zigpp.cd699eced.tar.xz
d67b6c6d56769fac22f5f339108e08780cdaf33f2b2ae3f06c950129af2b3891  zig-x86_64-windows-0.17.0-dev.2487+zigpp.cd699eced.zip

0.17.0-dev.2485+zigpp.753de4bb5

2026-09-26 16:44 UTC · zigpp-0.17.0-dev.2485

SHA256SUMS:

e22f6e4f47aafc16591de7f4b89c8855acf36225485f8a5f5409fe30bfd10a6b  zig-aarch64-linux-0.17.0-dev.2485+zigpp.753de4bb5.tar.xz
6bcbb5fe1682cdbf8fea092baeb08fad1de7bb6448b09d0cc4f0ab7cb7b18449  zig-aarch64-macos-0.17.0-dev.2485+zigpp.753de4bb5.tar.xz
89d8cd11d5233e2c993c4cef9f23e8469070954028cd707b119e2ea5494dc081  zig-x86_64-linux-0.17.0-dev.2485+zigpp.753de4bb5.tar.xz
6f8b609a2df584257f5f1bd9c572a3866a57def4617a74e4dcc4c2c40014c60f  zig-x86_64-windows-0.17.0-dev.2485+zigpp.753de4bb5.zip

0.17.0-dev.2483+zigpp.0099b5739

2026-09-26 16:23 UTC · zigpp-0.17.0-dev.2483

SHA256SUMS:

2ff51a34e2013c609b9364378e29ea2cf6bab2e6cd8e3f6388b8559c137e143c  zig-aarch64-linux-0.17.0-dev.2483+zigpp.0099b5739.tar.xz
27e0cc33a716d3644275d48687003618b331915ef28769989ad45e9cf643e789  zig-aarch64-macos-0.17.0-dev.2483+zigpp.0099b5739.tar.xz
fe6b4859a1987b6a4db157d9eb9ffe4a4318c0f62d99dc4e772b5cf3b1eef0f5  zig-x86_64-linux-0.17.0-dev.2483+zigpp.0099b5739.tar.xz
daca824c0a6190ad33ad0287a74896a7c1c229ad681192a360d624c612d058dc  zig-x86_64-windows-0.17.0-dev.2483+zigpp.0099b5739.zip

0.17.0-dev.2480+zigpp.e95c60250

2026-09-26 15:12 UTC · zigpp-0.17.0-dev.2480

SHA256SUMS:

2573bbd092f2e18364d55f76f06d82722efbb2869a859c0c74029a8782486e5e  zig-aarch64-linux-0.17.0-dev.2480+zigpp.e95c60250.tar.xz
4bb98576693c4aa6e540e28834d3bdb3ada59137574e329565b61ade17d7f9e2  zig-aarch64-macos-0.17.0-dev.2480+zigpp.e95c60250.tar.xz
f1f6704830aa22fc7b6b89b34b7d2e500a2843bcebe39dd4f2718401d730171a  zig-x86_64-linux-0.17.0-dev.2480+zigpp.e95c60250.tar.xz
1ac2608316fa95182cebab36ac00420beeca6fc633a6f857ee91894557602084  zig-x86_64-windows-0.17.0-dev.2480+zigpp.e95c60250.zip

0.17.0-dev.2478+zigpp.f682d9860

2026-09-26 14:38 UTC · zigpp-0.17.0-dev.2478

SHA256SUMS:

3d846e8074190e94e2021c68087c859be37b9fd99fc8e5567d515aa2bc885910  zig-aarch64-linux-0.17.0-dev.2478+zigpp.f682d9860.tar.xz
ab2fca50c3ae3796756eab311cb5622d3457e552ef5cc5fa39e77653f58366c8  zig-aarch64-macos-0.17.0-dev.2478+zigpp.f682d9860.tar.xz
49a27d4aa67e3d57e224cd1668d86115c02fad70ae7d4c3d058cc1cdd7d52d52  zig-x86_64-linux-0.17.0-dev.2478+zigpp.f682d9860.tar.xz
75460135aed989ed11ceeecad0c0a2f7e64765e6656779a8d4e56d4e5b33fccc  zig-x86_64-windows-0.17.0-dev.2478+zigpp.f682d9860.zip

0.17.0-dev.2476+zigpp.70dc1eaec

2026-09-26 14:30 UTC · zigpp-0.17.0-dev.2476

SHA256SUMS:

9f834359b8a1f2c875e9e4afead153b76950c2ea8455782cecdb1fa0a13f306c  zig-aarch64-linux-0.17.0-dev.2476+zigpp.70dc1eaec.tar.xz
1a5520bf6d7e003f1628903cff0f50cb9438822b92603dde5b397ce644fcf92e  zig-aarch64-macos-0.17.0-dev.2476+zigpp.70dc1eaec.tar.xz
c53769d84f336d4c7c1c6fa6f5513b27639a2cae899ac7e68cd8ea7e256d6bff  zig-x86_64-linux-0.17.0-dev.2476+zigpp.70dc1eaec.tar.xz
2a76f8f57ec8ec71a0093b37a8de71f62edd68d16669027fb47ed7802436abfa  zig-x86_64-windows-0.17.0-dev.2476+zigpp.70dc1eaec.zip

0.17.0-dev.2469+zigpp.04926fc36

2026-09-26 11:42 UTC · zigpp-0.17.0-dev.2469

SHA256SUMS:

89618cdb462023e104aab3b865060befcad1a3b245e679007376fda41b0d1a90  zig-aarch64-linux-0.17.0-dev.2469+zigpp.04926fc36.tar.xz
c16f854925e182185b525af7697fa4c58332faa267e734422e7dd6e59d1d3e8b  zig-aarch64-macos-0.17.0-dev.2469+zigpp.04926fc36.tar.xz
e38e965cadc80e2f6f59ac389da37f954e1a1cb028951c0991911482062d7358  zig-x86_64-linux-0.17.0-dev.2469+zigpp.04926fc36.tar.xz
97000323c96f6d42cea4cab121f21d7d503bb2efb5c4a3d6f4cd1c7f00d64a39  zig-x86_64-windows-0.17.0-dev.2469+zigpp.04926fc36.zip

0.17.0-dev.2453+zigpp.35608841b

2026-09-26 09:55 UTC · zigpp-0.17.0-dev.2453

SHA256SUMS:

1803b48015b5e2a3b0e8ef7198ad1bae17dd65228795a16e9d1c4626a29dfc68  zig-aarch64-linux-0.17.0-dev.2453+zigpp.35608841b.tar.xz
f135169c6c414c7f5cf6c37a16e345bc12db1232797b086d8cd8350c11c28bc3  zig-aarch64-macos-0.17.0-dev.2453+zigpp.35608841b.tar.xz
e978b8678e249781116962fd062d264ffbf59bd5158bd34569c21ca86d781ab4  zig-x86_64-linux-0.17.0-dev.2453+zigpp.35608841b.tar.xz
4f4344a29cf0df4fe6f5b7201e15f2c886f5f00c7551ac0bf40e1a667d08d78f  zig-x86_64-windows-0.17.0-dev.2453+zigpp.35608841b.zip

0.17.0-dev.2451+zigpp.628313d7c

2026-09-26 02:09 UTC · zigpp-0.17.0-dev.2451

SHA256SUMS:

5a2c2862904c64516cf69f835d9219edeeafc3eda0bf98377341f70714098046  zig-aarch64-linux-0.17.0-dev.2451+zigpp.628313d7c.tar.xz
090525146e849e4ff997b791bdf6a60c055d4e6b83a32957626927a38ea8da37  zig-aarch64-macos-0.17.0-dev.2451+zigpp.628313d7c.tar.xz
ac5baed5472c592ba7f2e15ef79be7603c4921eeb9b569c1132716b32fdc4526  zig-x86_64-linux-0.17.0-dev.2451+zigpp.628313d7c.tar.xz
e1c245f1c16d4b57fc6e47c372e6275569e7d66f5dd6d26421b0729c2800badc  zig-x86_64-windows-0.17.0-dev.2451+zigpp.628313d7c.zip

0.17.0-dev.2445+zigpp.ee991009e

2026-09-25 23:05 UTC · zigpp-0.17.0-dev.2445

SHA256SUMS:

2a5a322700be01a4104a83bb90a3e63804ba30eda14aeec4419b771b18041b34  zig-aarch64-linux-0.17.0-dev.2445+zigpp.ee991009e.tar.xz
6ee9d5d07d17e2dd5733723aa565a781b0b37213a5bc011cfb2538d90e59a4ed  zig-aarch64-macos-0.17.0-dev.2445+zigpp.ee991009e.tar.xz
ce55a1e171411749d57434a336149075424619c8b97b95c55a137c3223950f72  zig-x86_64-linux-0.17.0-dev.2445+zigpp.ee991009e.tar.xz
c057333fd59aa154b3772ee879c6bb32edc647e02f1780d75cfdc3b3f420f06b  zig-x86_64-windows-0.17.0-dev.2445+zigpp.ee991009e.zip

0.17.0-dev.2439+zigpp.5da715b14

2026-09-25 18:52 UTC · zigpp-0.17.0-dev.2439

SHA256SUMS:

b26a0050741a1ae1122c97945aae4b24f80d4b4e7941504a46b7cd477dd4f22c  zig-aarch64-linux-0.17.0-dev.2439+zigpp.5da715b14.tar.xz
88d9274f0041e1a341a619639d572b705ec6bdabf7c8ddcabf65273cbf76031e  zig-aarch64-macos-0.17.0-dev.2439+zigpp.5da715b14.tar.xz
25a1d27f7f6b2213a1f626551814eb41245b87c69f7f28553aa2298fed5dcdcb  zig-x86_64-linux-0.17.0-dev.2439+zigpp.5da715b14.tar.xz
6984ed83606532fd049504e83332ef8deb728697b40c61e18ba49dd36cc9e9d7  zig-x86_64-windows-0.17.0-dev.2439+zigpp.5da715b14.zip

0.17.0-dev.2435+zigpp.711f09ad4

2026-09-25 16:23 UTC · zigpp-0.17.0-dev.2435

SHA256SUMS:

4617ac19114b7166992aa8474e2b3129dfbc259950767bed840a0d28ba0faec3  zig-aarch64-linux-0.17.0-dev.2435+zigpp.711f09ad4.tar.xz
70e8306fda45578d643488b51aa354adcecaf77bf1f6bbe4b4f0171ded1f2348  zig-aarch64-macos-0.17.0-dev.2435+zigpp.711f09ad4.tar.xz
e7833d618ff84edb621a7969a3efc9d17eec91a2b7e3371ffa494c49d5381906  zig-x86_64-linux-0.17.0-dev.2435+zigpp.711f09ad4.tar.xz
0c5547ed494461ed19de46fed6e8e126597bb485d4e82386f86fa0242f760fcb  zig-x86_64-windows-0.17.0-dev.2435+zigpp.711f09ad4.zip

0.17.0-dev.2418+zigpp.887649bfd

2026-09-25 07:13 UTC · zigpp-0.17.0-dev.2418

SHA256SUMS:

1017bef07b89149286470c5590867220ee3aa8d1a5ba323a2e75b611619a36d3  zig-aarch64-linux-0.17.0-dev.2418+zigpp.887649bfd.tar.xz
8a997f00399220bad77f3412478c4251cabda851549ec7eff98f87b329bf59d8  zig-aarch64-macos-0.17.0-dev.2418+zigpp.887649bfd.tar.xz
40da06b12dfff1b8184efcf44d6bfb89eeffdf113a7267f566122090da28d547  zig-x86_64-linux-0.17.0-dev.2418+zigpp.887649bfd.tar.xz
b4a5b55f059865895aa18b390416bae7000135f94b19bed2864f307f884f60e5  zig-x86_64-windows-0.17.0-dev.2418+zigpp.887649bfd.zip

0.17.0-dev.2408+zigpp.ed422e86b

2026-09-25 02:28 UTC · zigpp-0.17.0-dev.2408

SHA256SUMS:

31e5f5efafad3b952fe28f190543c815f47edac3f933895c5fd4b8021cc4e1e5  zig-aarch64-linux-0.17.0-dev.2408+zigpp.ed422e86b.tar.xz
a4bc644ed6e02cedc106b48917e5d458fa3b969a2a855764d5342e839baf2a9d  zig-aarch64-macos-0.17.0-dev.2408+zigpp.ed422e86b.tar.xz
714f9c798fb61e59cfc8cac3982e3981e40d8b513a6ca9625d997ad1fe7dd9ea  zig-x86_64-linux-0.17.0-dev.2408+zigpp.ed422e86b.tar.xz
71470c1ca94de31101aafd07a1f76da9c31dd54f96f9118285e3481966c905f8  zig-x86_64-windows-0.17.0-dev.2408+zigpp.ed422e86b.zip

0.17.0-dev.2391+zigpp.b80ea5342

2026-09-25 00:50 UTC · zigpp-0.17.0-dev.2391

SHA256SUMS:

38f00821e59c6f4cb8f7a0ced9c9826513363fff8d3f6dbcddb32a10e437b233  zig-aarch64-linux-0.17.0-dev.2391+zigpp.b80ea5342.tar.xz
a255226202e6d48641ac82390bac4902975d3f008b17c363df72f02f809235cd  zig-aarch64-macos-0.17.0-dev.2391+zigpp.b80ea5342.tar.xz
a91b0371aad0a21fe0ecba03d2d6a7984632dccc335d9661bfbed0d6903f7913  zig-x86_64-linux-0.17.0-dev.2391+zigpp.b80ea5342.tar.xz
acf38738088be9f63ad12d0d8180ce1c46245f090b92560f782e95fc83c76b9d  zig-x86_64-windows-0.17.0-dev.2391+zigpp.b80ea5342.zip

0.17.0-dev.2383+zigpp.bceb3a816

2026-09-24 22:08 UTC · zigpp-0.17.0-dev.2383

SHA256SUMS:

88c6dd087f6040129a19782875dd62ce6d9a3ee1d0c837a41520e9d2b0c270b6  zig-aarch64-linux-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz
3c95e4b6a7796b1f9572256a9843299197e3565c3c59bdab267c9dbf653969a3  zig-aarch64-macos-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz
85b697808c93d9a6a8163b3027b9f3d1ce6af26391822da6e30d0dd64554209a  zig-x86_64-linux-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz
e41d124c48248ed78fbbac1cab4250f29524502a23b6d9c20a30f87140b1573d  zig-x86_64-windows-0.17.0-dev.2383+zigpp.bceb3a816.zip

0.17.0-dev.2376+zigpp.b926cb810

2026-09-24 21:30 UTC · zigpp-0.17.0-dev.2376

SHA256SUMS:

4232d06b57059da725db85e44c7269941c58fded3cca93cf5198e0c044402a8c  zig-aarch64-linux-0.17.0-dev.2376+zigpp.b926cb810.tar.xz
762a64b811656274d3da9d22b5ecd5c7971b4cedec140e46f26741173d4b28ee  zig-aarch64-macos-0.17.0-dev.2376+zigpp.b926cb810.tar.xz
ca7bd086ed96b6cc5ebfee57251fdb736a5a467a557ea913040b7ed9e8fca5f9  zig-x86_64-linux-0.17.0-dev.2376+zigpp.b926cb810.tar.xz
a9e95c3f34f296add9662096ffa09e5b43dd511b58d372bc4b99ec55471584aa  zig-x86_64-windows-0.17.0-dev.2376+zigpp.b926cb810.zip

0.17.0-dev.2373+zigpp.add158e97

2026-09-24 20:40 UTC · zigpp-0.17.0-dev.2373

SHA256SUMS:

723baa7b6ac79443b960b600a61f21c9f3881cee83f567e0a5bdbf4d797f7f34  zig-aarch64-linux-0.17.0-dev.2373+zigpp.add158e97.tar.xz
7148a807800498e2a4a4748a1ad802799b589fb79cf9c106d18c87130f8509d2  zig-aarch64-macos-0.17.0-dev.2373+zigpp.add158e97.tar.xz
fc062a67e5ecab871f10d00af08d53bfe6249e837f1fbefda58f0e15ddd0a6cc  zig-x86_64-linux-0.17.0-dev.2373+zigpp.add158e97.tar.xz
8f78651bfc9435b77edf3376e9315740b84156543e991746ba0092732741ad1d  zig-x86_64-windows-0.17.0-dev.2373+zigpp.add158e97.zip

Verifying a download

sha256sum -c SHA256SUMS

The archives and SHA256SUMS of a release are its assets, so download them into one directory first. The archives are a Zig++ compiler and its lib/: unpack one anywhere and run zig from it, with no installation step (see the Installing chapter).

The machine-readable index of every release is at download/index.json: master is the newest release, and every release has a key of its own.

Building from Source

Build Zig++ from source when you need a target that has no release, when you are developing the compiler, or when you want to build it against your own LLVM 23 libraries. There are four routes, from the least to the most preparation:

  1. A Zig++ bootstrap devkit, which brings LLVM, Clang, and LLD with it. This is what Zig++’s own CI and releases use.
  2. The CMake build, against LLVM, Clang, and LLD 23.x you installed yourself.
  3. An existing Zig++ compiler, which is the quickest route if you already have one.
  4. bootstrap.c, which needs nothing but a C compiler, and produces a compiler that cannot use the LLVM backend.

Requirements

Whichever route you take, the CMake build needs:

  • CMake 3.15 or later;
  • a system C/C++ toolchain;
  • the LLVM, Clang, and LLD development libraries, version 23.x, built with the same system C/C++ toolchain.

Zig++ accepts LLVM 23.x: the CMake module that finds LLVM rejects anything older than 23 and anything newer than 24, and it checks that the libraries it found can target AMDGPU and NVPTX. CMAKE_PREFIX_PATH points the build at a non-system LLVM:

mkdir build
cd build
cmake .. -DCMAKE_PREFIX_PATH="$HOME/local/llvm23"
make install

This produces stage3/bin/zig, relative to the build directory: the Zig compiler built by itself.

By default the build prefers the shared LLVM libraries. -DZIG_STATIC=ON links them statically, which is what releases do; it is not compatible with glibc. -DZIG_SHARED_LLVM=ON and -DZIG_STATIC_LLVM=ON choose explicitly, and asking for both at once is an error.

Building against an LLVM you built with Zig

If you have LLVM, Clang, and LLD libraries that were built by Zig, an existing Zig++ compiler can build Zig++ against them:

"$ZIG_PREFIX/zig" build \
  -p stage3 \
  --search-prefix "$LLVM_PREFIX" \
  --zig-lib-dir "lib" \
  -Dstatic-llvm

$LLVM_PREFIX is the directory that holds include/llvm/Pass.h and lib/libLLVMCore.a. The result is stage3/bin/zig.

The compiler that runs the build must be Zig++, not upstream Zig: Zig++ changed std.lang.Type, so an upstream Zig binary cannot compile against it. Upstream’s zig-bootstrap is still a good way to get the LLVM, Clang, and LLD libraries, as long as you use a Zig++ compiler to build with them.

Devkits

zigpp-bootstrap is zig-bootstrap for Zig++, and it makes the devkits that Zig++’s CI and releases build with. A devkit is a prebuilt tree of static LLVM, Clang, and LLD libraries, zlib and zstd, and a Zig++ compiler, with the headers to build against.

It builds LLVM, Clang, and LLD 23.1.2 from the llvmorg-23.1.2 release source, checked against its SHA-256 and patched with the patches that zig-bootstrap uses for LLVM 23, plus zlib 1.3.1 and zstd 1.5.2.

Host requirements:

  • a C++ compiler that can build LLVM, Clang, and LLD: GCC 5.1 or later, or Clang;
  • CMake 3.20 or later, and Ninja or another build system that CMake supports;
  • curl, tar with xz support, patch, and sha256sum or shasum;
  • a POSIX system (sh, mkdir, cd) and Python 3.
git clone --recursive https://github.com/mattneel/zigpp-bootstrap
cd zigpp-bootstrap
./build x86_64-linux-musl baseline

<arch>-<os>-<abi> is a Zig target and <mcpu> is a -mcpu value of Zig: baseline for a generic CPU of the architecture, or native. The Zig++ distribution for the target appears in out/zig-x86_64-linux-musl-baseline/.

The first run builds LLVM, Clang, and LLD twice: once for the host, to build Zig++ and the LLVM tools the cross build needs, and once for the target, with Zig++ as the cross compiler. Later runs for other targets reuse the host build. CMAKE_GENERATOR=Ninja and CMAKE_BUILD_PARALLEL_LEVEL tune it, ZIG_SRC builds another Zig++ checkout instead of the submodule, and ZIG_VERSION sets the version string it is built as.

To get just the libraries and compiler for a target, as one archive:

./devkit x86_64-linux-musl baseline
out/devkit/zig+llvm+lld+clang-x86_64-linux-musl-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz

That archive holds the target’s libraries and headers, bin/zig, and lib/zig/ for a computer of that target. On Windows, ./devkit x86_64-windows-gnu baseline packages the same thing as a .zip. Unpack it anywhere and build with it:

zig build -Dtarget=x86_64-linux-musl -Dstatic-llvm --search-prefix /path/to/devkit

./publish uploads every devkit of the version in zig-version to the devkit-<version> release of zigpp-bootstrap, with a SHA256SUMS file, and Zig++’s .github/scripts/devkit.sh names the version that CI downloads. So you do not have to build a devkit to use one: download it, unpack it, and use it as the --search-prefix above.

An upstream Zig devkit is not a Zig++ compiler, and it cannot build one. Use a Zig++ devkit, whose LLVM is 23.1.2.

How CI builds Zig++

Zig++’s CI does not install LLVM. .github/scripts/bootstrap.sh downloads the devkit of the machine, x86_64-linux-musl, x86_64-windows-gnu (in Git Bash), or aarch64-macos-none, into ~/deps, where it stays for the next run, and unpacks the newest release’s compiler there too, in a directory named for the release’s version, so a run whose newest release has not moved downloads nothing. It then builds the compiler with the release’s compiler, against this checkout’s lib/; on the Linux machine, that is:

ZIG_LIB_DIR="$PWD/lib" "$RELEASE/zig" build \
  --prefix build-bootstrap/stage3 \
  --search-prefix "$PREFIX" \
  -Dtarget=x86_64-linux-musl -Dcpu=baseline \
  -Denable-llvm -Dstatic-llvm -Duse-zig-libcxx \
  -Doptimize=ReleaseFast -Dstrip -Dno-lib

When there is no release, when stage1/zig1.wasm changed since the release’s commit, or when the release cannot build the checkout, it bootstraps from source instead, on Linux and macOS: the CMake build, with the devkit’s Zig++ as the C and C++ compiler and the devkit’s libraries as the search prefix:

cmake .. \
  -DCMAKE_INSTALL_PREFIX=stage3 \
  -DCMAKE_PREFIX_PATH="$PREFIX" \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_C_COMPILER="$PREFIX/bin/zig;cc;-target;x86_64-linux-musl;-mcpu=baseline" \
  -DCMAKE_CXX_COMPILER="$PREFIX/bin/zig;c++;-target;x86_64-linux-musl;-mcpu=baseline" \
  -DZIG_TARGET_TRIPLE=x86_64-linux-musl \
  -DZIG_TARGET_MCPU=baseline \
  -DZIG_STATIC=ON \
  -DZIG_NO_LIB=ON \
  -GNinja
ninja install

Either way, lib/ stays in the checkout, so the scripts point ZIG_LIB_DIR at this repository’s lib/. The compiler that comes out is build-bootstrap/stage3/bin/zig, and the release workflow cross-compiles every target’s archive with it, builds the language reference once with it and hands that to the per-target package jobs, which build with -Dno-langref.

A CI job points ZIG_LOCAL_CACHE_DIR and ZIG_GLOBAL_CACHE_DIR at directories under the runner’s tool cache, which actions/checkout leaves alone, so a run starts with what the run before it built. The compiler is one build step, its C++ glue inside it, so a run that changes the Zig source rebuilds that step: on the WSL2 machine, 326 s against 415 s with no cache, and 0.04 s for a commit that has already been built once. A run of the suites leaves about 6 GB in the local cache and the compiler’s share of the global one under 1 GB, so each directory is wiped whole when it passes 32 GiB, several runs of history: a Zig cache entry may not be deleted on its own, because a manifest may outlive the output it names, and one rebuild is cheaper than a machine whose disk has quietly filled.

Building without LLVM

The only system dependency of this route is a C compiler:

cc -o bootstrap bootstrap.c
./bootstrap

This produces zig2 in the current working directory: a stage2 build of the compiler, reported as 0.17.0-dev.bootstrap, without LLVM extensions, and therefore lacking:

  • release-mode optimizations;
  • some ELF linking features, some COFF/PE linking features, and some WebAssembly linking features;
  • the ability to create static archives from object files;
  • the ability to compile assembly files;
  • the ability to compile C, C++, Objective-C, and Objective-C++ files.

It still provides an LLVM backend that produces bitcode files, which a separately installed Clang can optimize and compile, and a C backend that produces C source. From there, the build system installs a compiler as usual:

./zig2 build

Zig++ is not going upstream’s way on LLVM: LLVM is a permanent, first-class dependency of Zig++, and the LLVM-less zig2 above exists only to bootstrap it. Upstream’s project to completely eliminate the dependency on LLVM library API calls will not be resolved here, and LLVM is forever explains why.

AI Policy and Governance

AI policy

Upstream Zig bans LLMs from issues, patches, and bug tracker comments. Zig++ welcomes them. The first Zig++ language feature, private fields, was implemented, tested, and documented by Claude, and the commit is signed that way.

Zig++ has AI on both sides of compilation: as a runtime facility in the programs it builds, and as a build-system facility in the build itself. Neither is in std yet: both are packages today, and both are headed into the standard library under the names std.ai and std.Build.ai.

std.ai will be ai.zig, the AI toolkit for Zig: an implementation of the Vercel AI SDK v7 core built on std.Io, with text generation and streaming, multi-step tool loops, structured output, embeddings, reranking, reusable agents, and an MCP client.

std.Build.ai will be Zigger, AI code generation as a Zig build step. Today it is a package that adds a zig build gen step: it reads SPEC.md, runs the Claude CLI to implement it, runs zig build test, feeds any failures back, and repeats until the tests pass (up to 10 times by default). With -Dtdd=true it writes failing tests first. The plan is to move this pipeline into std.Build.ai.

The goal: by September 2027, all new Zig++ code is written through the Zigger pipeline.

Governance

Upstream Zig is BDFN (Benevolent Dictator For Now). Zig++ is BDFL: Matthew Neel is the Benevolent Dictator For Life and has final say on the design and implementation of everything.

Zig++ has no Code of Conduct. It has one rule: talk about code. Issues, pull requests, reviews, and comments are for the compiler, the language, the standard library, and the tools. Everything else, politics included, is off topic and will be closed.

Language proposals are welcome. Zig++ is made of them.

Contributing

The source, the issues, and the pull requests are at github.com/mattneel/zigpp. CI runs on the project’s own machines: x86_64 Linux under WSL2 and x86_64 Windows, on a laptop with an RTX 5090 Laptop GPU, and an M4 Mac. On each it builds Zig++ against the caches the run before it left, and runs all four suites at the same time, a quarter of the machine’s cores each through -j, printing their logs when they have all finished:

# The standard library and behavior tests, with both back ends.
zig build test-fmt
zig test lib/std/std.zig -lc
zig test test/behavior.zig
zig test lib/std/std.zig -lc -fllvm
zig test test/behavior.zig -fllvm
# Zig++ against C that the Clang in Zig++ compiled.
zig build test-c-abi -Dskip-non-native -Dskip-release
zig build test-cases -Dskip-non-native -Denable-llvm
zig build test-standalone -Dskip-non-native -Dskip-release

The standalone tests include the GPU suite: its kernels compile for NVIDIA, AMD, and Apple GPUs, and it runs them wherever a GPU driver is present. The Linux machine runs the CUDA kernels on the RTX 5090 Laptop GPU, and the Mac runs the Metal kernels on the M4; the suite skips what a machine has no driver for.

A pull request that changes only documentation, ppup, or the other workflows does not run CI, and neither does one from a fork: the machines run only branches of the repository itself. Building from Source has the same bootstrap the CI job uses, so a change can be tested against the compiler CI builds.