The Many Ways of Doing C Interop With Zig

12/18/2025
3 min read

Treat this post more as note-taking of all the different ways I have done C interop with Zig, this is by no means a complete guide.

I'm going to cover 4 different methods, starting with the method I use the least, and finishing with my most preferred method.


The Dumb Way

I call it the dumb way because it is by far the least scalable and "correct" way of doing it. This method essentially consists of rolling your own extern function declarations in Zig, and then linking it against the C library you want to interop with. I'm sure there are usecases where this is the best/only approach, I just have not encountered one yet. Below is a minimal example.

my_lib.c
int add(int a, int b) {
  return a + b;
}
build.zig
const lib = b.addLibrary(.{
  .name = "my_lib",
  .root_module = b.createModule(.{
    .target = target,
    .optimize = optimize,
  }),
});
lib.root_module.addCSourceFile(.{
    .file = b.path("lib/my_lib.c"),
});

const mod = b.addModule("interop", .{
  .target = target,
  .optimize = optimize,
  .root_source_file = b.path("src/root.zig"),
});
mod.linkLibrary(lib);
root.zig
const std = @import("std");

extern fn add(a: c_int, b: c_int) c_int;

test "add" {
  try std.testing.expectEqual(3, add(1, 2));
}

Using the translate-c CLI

This method consists of running the translate-c CLI tool on your C source files and generating a Zig file that contains the bindings for the C functions and types. Unfortunately, due to lack of good documentation and needing to memorise a CLI API, it tends to be more time consuming than the upcoming methods. However, one strong advantage of this method is that it allows you to directly modify the generated Zig source code, which can be useful for debugging and cases where translate-c outputs incorrect code.

$ zig translate-c lib/src/my_lib.c -I lib/include > src/c.zig

We can actually inspect the generated Zig code and find our add function we created. Although not all C code can be translated directly to Zig, translate-c does a good job of handling most common cases.

src/c.zig
pub export fn add(arg_a: c_int, arg_b: c_int) c_int {
  var a = arg_a;
  _ = &a;
  var b = arg_b;
  _ = &b;
  return a + b;
}

Lets say we have a libc dependency (e.g including stdio.h), we can link with libc using the -lc flag.

$ zig translate-c -lc lib/src/my_lib.c -I lib/include > src/c.zig

@cImport
and
@cInclude

This is by far the easiest way to interop with C code in Zig, the Zig documentation even includes a section on how to use these functions, see Import from C Header File. This is quite a convenient way of interoping with C as it uses translate-c to generate Zig bindings for C functions and types defined in the header files we include. We can port the previous example to use this method.
include/my_lib.h
int add(int a, int b);
lib.installHeadersDirectory(b.path("lib/include"), "", .{});
lib.root_module.addCSourceFile(.{
  .file = b.path("lib/src/my_lib.c"),
});
const std = @import("std");
const c = @cImport({
  @cInclude("my_lib.h");
});

test {
  try std.testing.expectEqual(3, c.add(1, 2));
}
There are however some drawbacks with this approach. For one, as far as I know, this method is likely going to be deprecated in the future due to it being superseded by the following method. Another drawback is that it can lead you to having build-time logic in your source files, such as defining macros or including headers conditionally, which I'm personally not a fan of. See below example from the Zig documentation.
const builtin = @import("builtin");

const c = @cImport({
  @cDefine("NDEBUG", builtin.mode == .ReleaseFast);
  if (something) {
    @cDefine("_GNU_SOURCE", {});
  }
  @cInclude("stdlib.h");
  if (something) {
    @cUndef("_GNU_SOURCE");
  }
  @cInclude("soundio.h");
});
Previously one could isolate all this in to a module and use the
usingnamespace
keyword to cleanly isolate this. However, with the removal of
usingnamespace
in Zig 0.15, this approach is no longer possible and will instead lead to you needing a top-level namespace. As we will see in the next method, there is an alternative approach which resolves the issues with this method.

b.addTranslateC
and C code as a module

This is my preferred approach, the underlying idea here is to translate our C code in our build.zig, create a module out of it, and import it in our source code. It can be done in the following way:
const my_lib = b.addTranslateC(.{
  .target = target,
  .optimize = optimize,
  .root_source_file = b.path("lib/include/my_lib.h"),
});

const my_lib_mod = my_lib.createModule();
my_lib_mod.addCSourceFile(.{
  .file = b.path("lib/src/my_lib.c"),
});

const mod = b.addModule("interop", .{
  .target = target,
  .optimize = optimize,
  .root_source_file = b.path("src/root.zig"),
});
mod.addImport("my_lib", my_lib_mod);
const std = @import("std");
const my_lib = @import("my_lib");

test {
  try std.testing.expectEqual(3, my_lib.add(1, 2));
}
Obviously this is a very simple example, but it can be extended to handle more complex scenarios. See this build.zig which creates a Zig module out of wgpu-native.