C Interop

Zig treats C as a first-class neighbor: import headers directly with @cImport, link C libraries from build.zig, and share structs that follow the C ABI. This lesson shows how to stand on the shoulders of the entire C ecosystem.

Importing C

The @cImport builtin compiles the named headers into a Zig container. Every C function, type, and constant becomes accessible through the resulting namespace — translated automatically, with types mapped to their Zig equivalents.

@cImport and @cInclude

const c = @cImport({
    @cInclude("math.h");   // pull in the C header
    @cInclude("stdlib.h");
});

const root = c.sqrt(16.0);  // call the C function
std.debug.print("{d}\n", .{root}); // 4.0

Namespaced Access

Everything lands under c., so a C symbol can never collide with a Zig name. Constants, enum values, and function pointers all travel through the same namespace — c.EXIT_SUCCESS works like c.errno.

Linking Libraries

Declaring headers is only half the story; the binary must also link the library. Zig keeps this in the build script where everyone can see it.

linkSystemLibrary

const exe = b.addExecutable(.{
    .name = "app",
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    }),
});
exe.root_module.addCMacro("USE_SSE", "1");   // define a macro
if (target.result.os.tag == .linux) {
    exe.root_module.linkSystemLibrary("m", .{}); // libm on Linux
}
b.installArtifact(exe);

Static Libraries

Static archives link the same way: linkSystemLibrary for standard names or addObjectFile/addIncludePath for vendored builds. Third-party C code ships with its own compile flags, and Zig honors the include and library paths you declare.

Sharing Types and the ABI

Passing data across the boundary requires agreeing on layout. Zig gives you precise tools for that agreement — the same ones from the structures lesson.

extern Structs and Opaque Types

// matches the C declaration: struct timespec { tv_sec; tv_nsec; }
const Time = extern struct {
    sec: i64,
    nsec: i64,
};

// opaque hides the layout of a foreign handle (e.g. FILE*)
const OpaqueFile = opaque {};
const file: *OpaqueFile = undefined;

C Strings

A C char* arrives as a sentinel pointer; convert to a Zig slice with std.mem.span (which uses the NUL terminator) and back with @ptrCast + @intFromPtr where the ABI demands it.

Automatic Translation

When a whole C file must become Zig, zig translate-c produces idiomatic (or at least faithful) Zig. It is the ideal starting point for migration and for vendoring a library without a hand-written wrapper.

zig translate-c

zig translate-c legacy.c > legacy.zig
# review the output, then @import it or fold it into your build

Translate or Hand-Write

Hand-writing small, stable interfaces keeps types precise and Zig-idiomatic. Translating whole libraries is faster and less error-prone for large surface areas. Prefer the smaller hand-crafted seam: less generated code to audit.

Thinking in Zig: interop is not a bridge to a foreign land — it is the same land. Zig was designed so C headers, C ABIs, and C libraries feel native, which is why the Zig toolchain also compiles C directly. You reuse the ecosystem instead of reimplementing it.

Common Pitfalls

C NULL Is an Optional

C functions that may return NULL map to optionals (?*T). Forgetting to unwrap is a compile error — which is precisely the protection you never had in C.

Memory Across the Boundary

Memory allocated by a C library must be freed by the library (or std.heap.c_allocator), never by a Zig arena. Keep ownership on the side that allocated — mixed free is a fast way to corrupt both heaps.

Next: Testing & Debugging — making the compiler prove your code.