Extending C with Zig

Note

This guide was last updated in July of 2026 with an 0.17.0-dev build. Zig is a moving target, and stuff written about it may fall out of date. If you find something broken about this, feel free to let me know.

One of the most compelling wedges for Zig to gain a foothold is in development and maintenance of libraries that already have significant amounts of C code. Since Zig can export symbols understood by C linkers, a library written in C can be incrementally migrated to Zig without breaking the interface.

Math

For our example, we’ll start with a trivial addition library. Here’s our C library’s interface:

add.h
#ifndef ADD_H
#define ADD_H

#include <stdint.h>

// Add two numbers together and return the sum.
int32_t add(int32_t a, int32_t b);

#endif

And here’s the implementation.

add.c
#include "add.h"

int32_t add(int32_t a, int32_t b)
{
    return a + b;
}

Simple, right? Let’s also make a program that uses it and a Makefile to build the whole thing.

main.c
#include <stdio.h>;
#include <inttypes.h>;
#include "add.h"

int main(void)
{
    printf("7 + 3 = %"PRIi32"\n", add(7, 3));
    return 0;
}
Makefile
CC=gcc
CFLAGS=-Wall -Werror -Wextra -Os -fPIE
LD=ld
LDFLAGS=-melf_x86_64 -r
LPATH=-L.

.PHONY: clean

%.o: %.c
     $(CC) -o $@ -c $(CFLAGS) $^

libadd.a: add.o
     $(LD) $(LDFLAGS) -o $@ $^

main: main.o libadd.a
     $(CC) -o main $(CFLAGS) $(LPATH) $< -ladd

clean:
     rm -f *.o *.a main

A bit more complexity, but if you’ve ever built a library it should mostly look familiar.

Add Zig

Now it’s time to make the add library, but in Zig. Coincidentally, Zig’s default library initializer matches this one’s interface perfectly.

$ zig init
info: created build.zig
info: created build.zig.zon
info: created src/main.zig
info: created src/root.zig
info: see `zig build --help` for a menu of options

Ok, so zig init has gotten a bit fancier since the first edition of this tutorial. Let’s delete a bunch of stuff we’re not going to use first, so that we just have build.zig and src/root.zig.

$ rm src/main.zig build.zig.zon

Then edit src/root.zig so this is what’s left:

src/root.zig
//! By convention, root.zig is the root source file when making a package.
const std = @import("std");

pub fn add(a: i32, b: i32) i32 {
    return a + b;
}

test "basic add functionality" {
    try std.testing.expect(add(3, 7) == 10);
}

The default build.zig also has a lot of comments and extraneous targets. We’ll get to that later.

The one thing we need to do in order to make our add function callable by C is mark it with export. So src/root.zig becomes:

src/root.zig
const std = @import("std");
const testing = std.testing;

export fn add(a: i32, b: i32) i32 {
    return a + b;
}

test "basic add functionality" {
    try testing.expectEqual(10, add(3, 7));
}

This lets Zig know to make add use the C ABI and to make it visible in the generated object file. (docs)

Now all we need to do is hook up the build system.

Building a Library

The build.zig that’s generated by zig init has a lot of stuff we don’t need. For starters, the comments are good for explaining what’s going on but they take up a lot of space. We can also delete everything that has to do with the exe target. That means deleting the definitions of exe, run_step, run_cmd, exe_tests, run_exe_tests, and anything that depends on them. When we’re done, we’ll have a pretty tight build.zig file:

build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const mod = b.addModule("add", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
    });

    const mod_tests = b.addTest(.{
        .root_module = mod,
    });

    const run_mod_tests = b.addRunArtifact(mod_tests);

    const test_step = b.step("test", "Run tests");
    test_step.dependOn(&run_mod_tests.step);
}

Since we deleted the executable, we need to add a library target, and put the optimization options into the module.

build.zig
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const mod = b.addModule("add", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });

    const lib = b.addLibrary(.{
        .name = "add",
        .root_module = mod,
    });
    b.installArtifact(lib);

    const mod_tests = b.addTest(.{
        .root_module = mod,
    });

    const run_mod_tests = b.addRunArtifact(mod_tests);

    const test_step = b.step("test", "Run tests");
    test_step.dependOn(&run_mod_tests.step);
}

There’s just one more adjustment needed to make this work as a C library: Modern linkers expect (and modern C compilers emit) position-independent code. It’s easy to tell Zig’s build system to do the same, by adding .pic = true, to the createModule call. With that in place, our build.zig looks like this:

build.zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const mod = b.addModule("add", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
        .pic = true,
    });

    const lib = b.addLibrary(.{
        .name = "add",
        .root_module = mod,
    });
    b.installArtifact(lib);

    const mod_tests = b.addTest(.{
        .root_module = mod,
    });

    const run_mod_tests = b.addRunArtifact(mod_tests);

    const test_step = b.step("test", "Run tests");
    test_step.dependOn(&run_mod_tests.step);
}

Now all we need is to update the Makefile to know where Zig puts its build artifacts. We could have told build.zig to put libadd.a somewhere other than its default, but it’s a bit nicer to leave it in zig-out/lib, I think.

Makefile
CC=gcc
CFLAGS=-Wall -Werror -Wextra -Os -fPIE
ZFLAGS=-Doptimize=Debug
ZIGOUT=zig-out/lib
LPATH=-L. -L$(ZIGOUT)
ZIG_SRCS=$(wildcard src/*.zig)

.PHONY: clean libadd.a test default

default: main

%.o:: %.c
     $(CC) -o $@ -c $(CFLAGS) $^

$(ZIGOUT)/libadd.a: $(ZIG_SRCS)
     zig fmt build.zig src/*.zig
     zig build $(ZFLAGS)

main: main.o $(ZIGOUT)/libadd.a
     $(CC) -o main $(CFLAGS) $(LPATH) $< -ladd

test: $(ZIG_SRCS)
     zig build test

clean:
     rm -f *.o *.a main
     rm -rf zig-out .zig-cache

All set. Let’s run make main and see what happens.

$ make main
gcc -o main.o -c -Wall -Werror -Wextra -Os -fPIE main.c
zig fmt build.zig src/*.zig
zig build -Doptimize=Debug
gcc -o main -Wall -Werror -Wextra -Os -fPIE -L. -Lzig-out/lib main.o -ladd
$ ./main
7 + 3 = 10

It works! Earlier versions of Zig blew up here, but the world is a better place than it used to be in at least this one way.

Incremental Rewriting

Way back in the first paragraph, I said that incrementally replacing a C library with Zig was the use case. But what we’ve done so far is entirely replace a C library with Zig. If we want to replace part of a library, we’ll need a larger library to start with. Let’s take our libadd and add some extra math in a separate file, along with its interface.

mul.h
#ifndef MUL_H
#define MUL_H

#include <stdint.h>;

// Multiply two numbers together and return the product.
int32_t mul(int32_t a, int32_t b);

#endif
mul.c
#include "mul.h"

int32_t mul(int32_t a, int32_t b)
{
    return a * b;
}

That was easy. Now we just have to tweak our Makefile to link libadd.a and mul.o into a larger library. Let’s call it libi32math.

Makefile
CC=gcc
CFLAGS=-Wall -Werror -Wextra -Os -fPIE
ZFLAGS=-Doptimize=Debug
LD=ld
LDFLAGS=-melf_x86_64 -r --whole-archive
ZIGOUT=zig-out/lib
LPATH=-L.
ZIG_SRCS=$(wildcard src/*.zig)

.PHONY: clean libadd.a test default

default: main

%.o:: %.c
     $(CC) -o $@ -c $(CFLAGS) $^

$(ZIGOUT)/libadd.a: $(ZIG_SRCS)
     zig fmt build.zig src/*.zig
     zig build $(ZFLAGS)

libi32math.a: $(ZIGOUT)/libadd.a mul.o
     $(LD) $(LDFLAGS) $(LPATH) -o $@ $^

main: main.o libi32math.a
     $(CC) -o main $(CFLAGS) $(LPATH) $< -li32math

test: $(ZIG_SRCS)
     zig build test

clean:
     rm -f *.o *.a main
     rm -rf zig-out .zig-cache

And just like that, we have a small part of our i32math library written in Zig. Let’s try running a program that uses it.

$ make clean
rm -f *.o *.a main
rm -rf zig-out .zig-cache
$ make main
gcc -o main.o -c -Wall -Werror -Wextra -Os -fPIE main.c
zig fmt build.zig src/*.zig
zig build -Doptimize=Debug
gcc -o mul.o -c -Wall -Werror -Wextra -Os -fPIE mul.c
ld -melf_x86_64 -r --whole-archive -L. -o libi32math.a zig-out/lib/libadd.a mul.o
gcc -o main -Wall -Werror -Wextra -Os -fPIE -L. main.o -li32math
$ ./main
7 + 3 = 10
7 * 3 = 21

If we wanted to migrate the library’s build system over to Zig’s build system, we could. Zig can build C code and its build system can do the job that Make is doing for our little i32math library. That, however, is a project for another day.

Done

That’s all there is to it. The code for the completed i32math library can be found on sourcehut or github.