Learn Zig Series (#27) - C Interop: Calling C from Zig
Learn Zig Series (#27) - C Interop: Calling C from Zig

What will I learn
- You will learn the
@cImportand@cIncludebuiltins for importing C headers; - You will learn how C types map to Zig types automatically;
- You will learn calling C standard library functions from Zig;
- You will learn passing Zig data to C functions: pointers, strings, structs;
- You will learn handling C's nullable pointers in Zig's type system;
- You will learn linking C libraries in
build.zig; - You will learn wrapping C APIs with safe Zig interfaces;
- You will learn a practical example: using libc math and string functions from Zig.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig (this post)
Learn Zig Series (#27) - C Interop: Calling C from Zig
Solutions to Episode 26 Exercises
Exercise 1 - BumpAllocator with resize for the most recent allocation:
const std = @import("std");
const BumpAllocator = struct {
buffer: []u8,
offset: usize,
allocations: usize,
last_alloc_start: usize,
last_alloc_len: usize,
pub fn init(buffer: []u8) BumpAllocator {
return .{
.buffer = buffer,
.offset = 0,
.allocations = 0,
.last_alloc_start = 0,
.last_alloc_len = 0,
};
}
pub fn reset(self: *BumpAllocator) void {
self.offset = 0;
self.allocations = 0;
self.last_alloc_start = 0;
self.last_alloc_len = 0;
}
pub fn allocator(self: *BumpAllocator) std.mem.Allocator {
return .{
.ptr = self,
.vtable = &.{
.alloc = alloc,
.resize = resize,
.free = free,
},
};
}
fn alloc(ctx: *anyopaque, len: usize, ptr_align: u8, _: usize) ?[*]u8 {
const self: *BumpAllocator = @ptrCast(@alignCast(ctx));
const alignment = @as(usize, 1) << @intCast(ptr_align);
const aligned_offset = std.mem.alignForward(usize, self.offset, alignment);
if (aligned_offset + len > self.buffer.len) {
return null;
}
const result = self.buffer.ptr + aligned_offset;
self.last_alloc_start = aligned_offset;
self.last_alloc_len = len;
self.offset = aligned_offset + len;
self.allocations += 1;
return result;
}
fn resize(ctx: *anyopaque, buf: [*]u8, old_len: usize, _: u8, new_len: usize, _: usize) bool {
const self: *BumpAllocator = @ptrCast(@alignCast(ctx));
const buf_addr = @intFromPtr(buf);
const base_addr = @intFromPtr(self.buffer.ptr);
// Only resize if this is the most recent allocation
if (buf_addr - base_addr != self.last_alloc_start) {
return false;
}
if (old_len != self.last_alloc_len) {
return false;
}
// Check if the new size fits
if (self.last_alloc_start + new_len > self.buffer.len) {
return false;
}
self.offset = self.last_alloc_start + new_len;
self.last_alloc_len = new_len;
return true;
}
fn free(_: *anyopaque, _: [*]u8, _: usize, _: u8) void {}
};
pub fn main() !void {
var buffer: [8192]u8 = undefined;
var bump = BumpAllocator.init(&buffer);
const alloc = bump.allocator();
var list = std.ArrayList(u32).init(alloc);
for (0..50) |i| {
try list.append(@intCast(i));
}
std.debug.print("Appended 50 items, offset: {d}\n", .{bump.offset});
std.debug.print("First 5: ", .{});
for (list.items[0..5]) |v| std.debug.print("{d} ", .{v});
std.debug.print("\n", .{});
}
The key insight: we track last_alloc_start and last_alloc_len so resize can verify the caller is asking about the most recent allocation. If it is, we just adjust the offset -- no copy needed. ArrayList's growth strategy triggers resize before falling back to alloc+copy, so this works perfectly for the single-active-allocation case.
Exercise 2 - CountingAllocator wrapper:
const std = @import("std");
const CountingAllocator = struct {
inner: std.mem.Allocator,
active_count: usize,
active_bytes: usize,
peak_bytes: usize,
total_allocs: usize,
pub fn init(inner: std.mem.Allocator) CountingAllocator {
return .{
.inner = inner,
.active_count = 0,
.active_bytes = 0,
.peak_bytes = 0,
.total_allocs = 0,
};
}
pub fn allocator(self: *CountingAllocator) std.mem.Allocator {
return .{
.ptr = self,
.vtable = &.{
.alloc = alloc,
.resize = resize,
.free = free,
},
};
}
fn alloc(ctx: *anyopaque, len: usize, ptr_align: u8, ret_addr: usize) ?[*]u8 {
const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
const result = self.inner.rawAlloc(len, ptr_align, ret_addr) orelse return null;
self.active_count += 1;
self.active_bytes += len;
self.total_allocs += 1;
if (self.active_bytes > self.peak_bytes) {
self.peak_bytes = self.active_bytes;
}
return result;
}
fn resize(ctx: *anyopaque, buf: [*]u8, old_len: usize, ptr_align: u8, new_len: usize, ret_addr: usize) bool {
const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
if (self.inner.rawResize(buf, old_len, ptr_align, new_len, ret_addr)) {
self.active_bytes = self.active_bytes - old_len + new_len;
if (self.active_bytes > self.peak_bytes) {
self.peak_bytes = self.active_bytes;
}
return true;
}
return false;
}
fn free(ctx: *anyopaque, buf: [*]u8, len: usize, ptr_align: u8) void {
const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
self.inner.rawFree(buf, len, ptr_align);
self.active_count -= 1;
self.active_bytes -= len;
}
pub fn printStats(self: *const CountingAllocator) void {
std.debug.print("\n--- Counting Allocator ---\n", .{});
std.debug.print("Active: {d} allocs, {d} bytes\n", .{ self.active_count, self.active_bytes });
std.debug.print("Peak: {d} bytes\n", .{self.peak_bytes});
std.debug.print("Total: {d} allocations made\n", .{self.total_allocs});
std.debug.print("--------------------------\n", .{});
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var counter = CountingAllocator.init(gpa.allocator());
const alloc = counter.allocator();
var list = std.ArrayList(u8).init(alloc);
defer list.deinit();
for ("Hello, counting allocator!") |ch| {
try list.append(ch);
}
const nums = try alloc.alloc(u32, 100);
counter.printStats();
alloc.free(nums);
counter.printStats();
}
The trick is forwarding everything to the inner allocator via rawAlloc, rawResize, rawFree while updating our counters before and after. This wrapper is completely transparent -- the inner allocator does the real work, we just observe.
Exercise 3 - StackAllocator with LIFO frees:
const std = @import("std");
const StackAllocator = struct {
buffer: []u8,
offset: usize,
stack: [64]StackEntry,
stack_top: usize,
const StackEntry = struct {
start: usize,
len: usize,
};
pub fn init(buffer: []u8) StackAllocator {
return .{
.buffer = buffer,
.offset = 0,
.stack = undefined,
.stack_top = 0,
};
}
pub fn allocator(self: *StackAllocator) std.mem.Allocator {
return .{
.ptr = self,
.vtable = &.{
.alloc = alloc,
.resize = resize,
.free = free,
},
};
}
fn alloc(ctx: *anyopaque, len: usize, ptr_align: u8, _: usize) ?[*]u8 {
const self: *StackAllocator = @ptrCast(@alignCast(ctx));
const alignment = @as(usize, 1) << @intCast(ptr_align);
const aligned = std.mem.alignForward(usize, self.offset, alignment);
if (aligned + len > self.buffer.len) return null;
if (self.stack_top >= self.stack.len) return null;
const result = self.buffer.ptr + aligned;
self.stack[self.stack_top] = .{ .start = aligned, .len = len };
self.stack_top += 1;
self.offset = aligned + len;
return result;
}
fn resize(_: *anyopaque, _: [*]u8, _: usize, _: u8, _: usize, _: usize) bool {
return false;
}
fn free(ctx: *anyopaque, buf: [*]u8, _: usize, _: u8) void {
const self: *StackAllocator = @ptrCast(@alignCast(ctx));
if (self.stack_top == 0) {
std.debug.print("[STACK] Warning: free on empty stack\n", .{});
return;
}
const addr = @intFromPtr(buf);
const base = @intFromPtr(self.buffer.ptr);
const top = self.stack[self.stack_top - 1];
if (addr - base == top.start) {
self.stack_top -= 1;
self.offset = top.start;
std.debug.print("[STACK] LIFO free OK, offset rewound to {d}\n", .{self.offset});
} else {
std.debug.print("[STACK] Warning: out-of-order free at offset {d}, " ++
"expected {d}\n", .{ addr - base, top.start });
}
}
};
pub fn main() !void {
var buffer: [4096]u8 = undefined;
var sa = StackAllocator.init(&buffer);
const alloc = sa.allocator();
const a = try alloc.alloc(u8, 100);
const b = try alloc.alloc(u8, 200);
const c = try alloc.alloc(u8, 300);
std.debug.print("Offset after 3 allocs: {d}\n", .{sa.offset});
// LIFO order: free c, b, a
alloc.free(c);
alloc.free(b);
alloc.free(a);
std.debug.print("Offset after LIFO free: {d}\n\n", .{sa.offset});
// Now demonstrate out-of-order
const x = try alloc.alloc(u8, 50);
const y = try alloc.alloc(u8, 60);
_ = x;
_ = y;
// Try freeing x before y -- out of order
alloc.free(x);
}
The key idea: maintain a stack of (start, len) entries. On free, check if the pointer matches the top of the stack. If yes, rewind. If no, warn and do nothing. This catches the common mistake of freeing in the wrong order -- which matters a lot for stack-based allocation strategies.
Here we go! Welcome back to the series. In episode 26 we built custom allocators from scratch -- bump allocators, debug wrappers, and learned when to specialize versus sticking with the general-purpose allocator. That was deep Zig territory. Today we're going somewhere arguably even more important for real-world Zig use: calling C code from Zig.
Here's the thing about systems programming in 2026. You can write the cleanest, most beautiful Zig code in the world, but at some point you're going to need a library that only exists in C. Maybe it's OpenSSL for TLS. Maybe it's SQLite for embedded databases. Maybe it's libcurl, zlib, libpng, or any of the thousands of mature, battle-tested C libraries that have been running production systems for decades. Re-implementing all of that in Zig would take years and introduce bugs that the C versions have already found and fixed.
Zig's answer to this is genuinely one of its strongest features: zero-cost C interop. Not "pretty good" interop. Not "write a bunch of glue code" interop. Actual zero-overhead, direct function calls with automatic type translation. You import a C header, and Zig gives you a Zig-native interface to it. No bindings generators, no FFI libraries, no runtime overhead.
If you've done C interop from Python (ctypes, cffi), from Rust (bindgen, unsafe blocks everywhere), or from Go (cgo with its goroutine overhead) -- forget all that. Zig's approach is fundamentally diferent ;-)
@cImport and @cInclude: importing C headers
The entry point for all C interop in Zig is @cImport. This is a builtin that takes a block of C preprocessor code and translates it into a Zig namespace. Inside that block, @cInclude is the equivalent of C's #include:
const std = @import("std");
const c = @cImport({
@cInclude("stdio.h");
@cInclude("stdlib.h");
@cInclude("string.h");
});
pub fn main() void {
// c.printf is now available as a normal Zig function
_ = c.printf("Hello from C's printf!\n");
// c.strlen works too
const msg = "Zig calling C";
const len = c.strlen(msg);
_ = c.printf("String '%s' has length %zu\n", msg, len);
}
What happens here is quite remarkable. The Zig compiler runs clang's C frontend on those headers, parses every typedef, struct, enum, function declaration, and macro it can understand, and generates Zig declarations for all of them. When you write c.printf(...), you're calling C's printf through a direct function call -- no wrapper, no marshalling, no overhead. The same calling convention, the same ABI, the same machine code as if you'd written it in C.
You can also use @cDefine to set preprocessor macros before including headers:
const c = @cImport({
@cDefine("_GNU_SOURCE", {});
@cDefine("NDEBUG", "1");
@cInclude("string.h");
});
This is equivalent to compiling C code with -D_GNU_SOURCE -DNDEBUG. You need this for platform-specific extensions -- _GNU_SOURCE on Linux unlocks functions like memmem and strchrnul that aren't in the POSIX standard.
One thing to keep in mind: @cImport is evaluated at compile time. The C headers must be available on the build machine. If you're targeting a platform where those headers don't exist, you'll get a compilation error. This is by design -- Zig doesn't pretend C interop works when it doesn't.
How C types map to Zig types
When Zig translates C headers, it maps C types to Zig types following predictable rules. Understanding this mapping is essential because you'll be working with both type systems simultaneously:
const std = @import("std");
const c = @cImport({
@cInclude("stdint.h");
@cInclude("stddef.h");
});
pub fn main() void {
// C fixed-width integers map directly
// c.int32_t -> i32
// c.uint64_t -> u64
// c.uint8_t -> u8
// C's basic types map to Zig equivalents
// int -> c_int (platform-dependent, typically i32)
// long -> c_long (i32 on 32-bit, i64 on 64-bit Linux, i32 on 64-bit Windows)
// size_t -> usize
// char -> u8 (on most platforms)
// Demonstrate the sizes
std.debug.print("c_int: {d} bytes\n", .{@sizeOf(c_int)});
std.debug.print("c_long: {d} bytes\n", .{@sizeOf(c_long)});
std.debug.print("c_ulong: {d} bytes\n", .{@sizeOf(c_ulong)});
std.debug.print("usize: {d} bytes\n", .{@sizeOf(usize)});
// C pointers map to Zig optional pointers
// int* -> ?*c_int (nullable single-item pointer)
// const int* -> ?*const c_int (nullable const pointer)
// void* -> ?*anyopaque (nullable opaque pointer)
// char* -> [*c]u8 (C pointer to u8, null-terminated aware)
std.debug.print("All type mappings behave as expected\n", .{});
}
The important mapping to internalize is the pointer story. C pointers are nullable by default -- any int* in C might be NULL. Zig represents this honestly: a C int* becomes ?*c_int in Zig. If you want to use it, you have to handle the null case. No more "just trust me, it's not null" -- Zig forces you to check.
The c_int, c_long, c_ulong types exist because C's integer sizes are platform-dependent. int is "at least 16 bits" (practically always 32). long is 32 bits on Windows even on 64-bit, but 64 bits on 64-bit Linux. Using c_int and c_long in your Zig code ensures you match the C ABI on whatever platform you're compiling for. Don't substitute i32 for c_int unless you've verified they're the same size on all your target platforms.
Calling C functions from Zig
Once you've imported headers, calling C functions is straightforward. But there are nuances around how Zig handles C's looser type system:
const std = @import("std");
const c = @cImport({
@cInclude("math.h");
@cInclude("stdlib.h");
@cInclude("time.h");
});
pub fn main() void {
// C math functions work directly
const angle: f64 = 1.5707963; // roughly pi/2
const sine = c.sin(angle);
const cosine = c.cos(angle);
_ = c.printf("sin(%.4f) = %.6f\n", angle, sine);
_ = c.printf("cos(%.4f) = %.6f\n", angle, cosine);
// sqrt returns f64
const root = c.sqrt(144.0);
_ = c.printf("sqrt(144) = %.1f\n", root);
// abs for integer absolute value
const negative: c_int = -42;
const positive = c.abs(negative);
_ = c.printf("abs(%d) = %d\n", negative, positive);
// time functions
var now: c.time_t = undefined;
_ = c.time(&now);
const time_str = c.ctime(&now);
_ = c.printf("Current time: %s", time_str);
// Random numbers via C's rand/srand
c.srand(@intCast(@as(c_uint, @truncate(@as(u64, @bitCast(now))))));
_ = c.printf("Random numbers: ");
for (0..5) |_| {
_ = c.printf("%d ", c.rand() % 100);
}
_ = c.printf("\n");
}
Notice we're using c.printf instead of std.debug.print. Both work, but when you're doing heavy C interop, using C's I/O functions can be convenient because they expect C-style format strings and C types. Having said that, for normal Zig code, always prefer std.debug.print or std.io -- they're type-safe and don't have the format string vulnerability problems that plague C codebases.
The return value of c.printf is c_int (the number of characters printed). In Zig, we discard it with _ = because we don't care. In C, ignoring return values is silent. In Zig, you have to explicitly acknowledge it.
Passing Zig data to C functions: pointers, strings, structs
This is where it gets interesting. Zig and C have different memory models, different string representations, and different struct layouts. When you pass data across the boundary, you need to understand what conversions happen:
const std = @import("std");
const c = @cImport({
@cInclude("string.h");
@cInclude("stdio.h");
@cInclude("stdlib.h");
});
pub fn main() !void {
// -- Strings --
// Zig string literals are null-terminated, so they work with C directly
const zig_str = "Hello from Zig";
const len = c.strlen(zig_str);
_ = c.printf("C sees: '%s' (length %zu)\n", zig_str.ptr, len);
// But Zig slices are NOT null-terminated by default
// If you have a []u8, you need a sentinel-terminated version for C
var buf: [64]u8 = undefined;
const slice = "dynamic string";
@memcpy(buf[0..slice.len], slice);
buf[slice.len] = 0; // manually null-terminate
// Now we can pass it to C
const c_ptr: [*c]const u8 = &buf;
_ = c.printf("Dynamic: '%s'\n", c_ptr);
// -- Pointers --
// Pass a pointer to a Zig variable for C to modify
var value: c_int = 0;
// sscanf reads from a string into variables via pointers
_ = c.sscanf("42", "%d", &value);
_ = c.printf("sscanf parsed: %d\n", value);
// -- Memory from C --
// malloc returns ?*anyopaque (nullable void pointer)
const raw = c.malloc(256) orelse {
_ = c.printf("malloc failed!\n");
return;
};
defer c.free(raw);
// Cast to a usable pointer type
const c_buf: [*]u8 = @ptrCast(raw);
_ = c.sprintf(c_buf, "Written by C's sprintf into malloc'd memory");
_ = c.printf("From malloc buffer: %s\n", c_buf);
}
The string situation deserves special attention. We covered sentinel-terminated types in episode 16, and this is exactly where they matter. Zig string literals ("hello") are actually *const [5:0]u8 -- a pointer to a fixed-size array with a sentinel zero byte. C expects null-terminated char*. Because Zig literals include the sentinel, they coerce to [*c]const u8 automatically. But if you have a runtime []u8 slice (from an allocator, from file I/O, from network data), you need to add the null terminator yourself before passing it to C.
This is also a good place to mention the std.mem.span function, which converts a [*c]u8 (C-style null-terminated pointer) back into a Zig slice:
const std = @import("std");
const c = @cImport({
@cInclude("string.h");
});
pub fn main() void {
// C gives us a [*c]u8 pointer
const c_str: [*c]const u8 = "C string example";
// Convert to a Zig slice using std.mem.span
const zig_slice: []const u8 = std.mem.span(c_str);
std.debug.print("Zig slice: '{s}' (len {d})\n", .{ zig_slice, zig_slice.len });
// Now you can use all of Zig's string operations
if (std.mem.startsWith(u8, zig_slice, "C string")) {
std.debug.print("Starts with 'C string' -- confirmed\n", .{});
}
}
std.mem.span finds the null terminator and gives you a proper []const u8 with a known length. From there, you're back in safe Zig land with bounds-checked indexing and all the standard library string functions.
Handling C's nullable pointers
C functions that return pointers can always return NULL. Zig translates these to optional pointers, which you MUST handle. This is one of the biggest safety wins of doing C interop from Zig rather than from C itself -- Zig won't let you forget the null check:
const std = @import("std");
const c = @cImport({
@cInclude("stdlib.h");
@cInclude("string.h");
@cInclude("stdio.h");
});
pub fn main() void {
// getenv returns ?[*:0]u8 -- it might be null
if (c.getenv("HOME")) |home| {
const home_slice = std.mem.span(home);
std.debug.print("HOME = {s}\n", .{home_slice});
} else {
std.debug.print("HOME not set\n", .{});
}
// A variable that probably doesn't exist
if (c.getenv("DEFINITELY_NOT_SET_XYZ_123")) |val| {
_ = val;
std.debug.print("Somehow it exists\n", .{});
} else {
std.debug.print("DEFINITELY_NOT_SET_XYZ_123: not found (as expected)\n", .{});
}
// malloc can return null (out of memory)
const ptr = c.malloc(1024);
if (ptr) |valid_ptr| {
// Safe to use
const typed: [*]u8 = @ptrCast(valid_ptr);
@memset(typed[0..1024], 0);
std.debug.print("Allocated and zeroed 1024 bytes\n", .{});
c.free(valid_ptr);
} else {
std.debug.print("malloc returned NULL\n", .{});
}
// strtol returns a value and sets an error pointer
var end: [*c]u8 = undefined;
const result = c.strtol("12345abc", &end, 10);
const remainder = std.mem.span(end);
std.debug.print("strtol parsed: {d}, remainder: '{s}'\n", .{ result, remainder });
}
Every C function that might return NULL forces you into an if (ptr) |valid| check. You can't just use the pointer directly -- the compiler won't let you. In C, you can write char *home = getenv("HOME"); printf("%s\n", home); and it'll crash with a segfault if HOME isn't set. In Zig, that pattern is a compilation error. You handle the null, or you don't compile.
Linking C libraries in build.zig
So far we've been using libc functions that are available on every platform. But what about third-party C libraries? You need to tell Zig's build system to link them. This happens in build.zig:
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "c-interop-demo",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Link libc -- required for any @cImport of standard C headers
exe.linkLibC();
// Link a system library (e.g. libm for math functions)
// On Linux, math functions live in a separate libm
exe.linkSystemLibrary("m");
// Link a third-party library (must be installed on the system)
// exe.linkSystemLibrary("sqlite3");
// exe.linkSystemLibrary("z"); // zlib
// exe.linkSystemLibrary("ssl"); // openssl
// exe.linkSystemLibrary("crypto"); // openssl crypto
// Add include paths if headers aren't in the default location
// exe.addIncludePath(b.path("vendor/include"));
// exe.addLibraryPath(b.path("vendor/lib"));
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Run the program");
run_step.dependOn(&run_cmd.step);
}
The linkLibC() call is the most common one -- it tells Zig to link against the platform's C standard library. On Linux, this is glibc or musl. On macOS, it's the system libc. On Windows, it's MSVCRT. Zig handles the platform differences for you.
linkSystemLibrary("m") links libm, the math library. On Linux, math functions like sin, cos, sqrt live in a separate shared library. On macOS, they're part of the system libc, so linking m is technically unnecessary but harmless.
For third-party libraries, linkSystemLibrary("sqlite3") tells the linker to find libsqlite3.so (Linux), libsqlite3.dylib (macOS), or sqlite3.lib (Windows) in the system's library search paths. The library must be installed on the build machine -- apt install libsqlite3-dev on Ubuntu, brew install sqlite3 on macOS, etc.
You can also bundle C source files directly into your Zig build, which is Zig's killer feature for dependency management -- but that's a topic for another day ;-)
Wrapping C APIs with safe Zig interfaces
Raw C interop works, but it's not pleasant to use throughout your codebase. C functions return error codes as integers, use nullable pointers, expect null-terminated strings, and give you no safety guarantees. The Zig pattern is to write a thin wrapper that translates C's conventions into idiomatic Zig:
const std = @import("std");
const c = @cImport({
@cInclude("stdlib.h");
@cInclude("string.h");
@cInclude("errno.h");
});
// Wrapper around C's strtol that returns a proper Zig error union
const ParseError = error{
InvalidCharacter,
Overflow,
Empty,
};
fn parseInt(str: []const u8) ParseError!i64 {
if (str.len == 0) return ParseError.Empty;
// We need a null-terminated copy for C
var buf: [64]u8 = undefined;
if (str.len >= buf.len) return ParseError.Overflow;
@memcpy(buf[0..str.len], str);
buf[str.len] = 0;
var end: [*c]u8 = undefined;
// Reset errno before calling strtol
c.errno = 0;
const result = c.strtol(&buf, &end, 10);
// Check for overflow
if (c.errno != 0) return ParseError.Overflow;
// Check if any valid digits were parsed
const parsed_len = @intFromPtr(end) - @intFromPtr(&buf);
if (parsed_len == 0) return ParseError.InvalidCharacter;
// Check for trailing garbage
if (parsed_len != str.len) return ParseError.InvalidCharacter;
return result;
}
// Wrapper around C's getenv that returns a Zig optional slice
fn getEnv(name: [:0]const u8) ?[]const u8 {
const result = c.getenv(name.ptr);
if (result) |ptr| {
return std.mem.span(ptr);
}
return null;
}
pub fn main() !void {
// Now we have Zig-idiomatic interfaces
const val = try parseInt("12345");
std.debug.print("Parsed: {d}\n", .{val});
// Error handling works naturally
if (parseInt("not_a_number")) |_| {
std.debug.print("Should not reach here\n", .{});
} else |err| {
std.debug.print("Parse error: {}\n", .{err});
}
// Null-safe environment variable access
if (getEnv("HOME")) |home| {
std.debug.print("Home: {s}\n", .{home});
}
if (getEnv("NONEXISTENT_VAR")) |_| {
std.debug.print("Should not reach here\n", .{});
} else {
std.debug.print("NONEXISTENT_VAR not set\n", .{});
}
}
This is the pattern you should follow whenever you use C libraries in a Zig project. The raw @cImport namespace is your escape hatch to the C world. The wrapper layer translates C's error conventions (return codes, errno, null pointers) into Zig's error unions and optionals. The rest of your Zig code never touches C types directly -- it uses your clean Zig API.
The benefits are worth the wrapping effort: you get Zig's error handling (try, catch, error unions), proper optional types instead of nullable pointers, and slice-based strings instead of null-terminated char pointers. If the C library changes its API, you fix the wrapper in one place instead of hunting down every call site.
Practical example: wrapping C string utilities
Let's put everything together with a practical example. Instead of using a third-party library (which would require you to install it), we'll wrap some C standard library string functions into a Zig-friendly CStr utility module:
const std = @import("std");
const c = @cImport({
@cInclude("string.h");
@cInclude("ctype.h");
@cInclude("stdio.h");
});
const CStr = struct {
// Case-insensitive compare using C's strcasecmp (POSIX)
// Returns true if strings are equal, ignoring case
pub fn eqlIgnoreCase(a: [:0]const u8, b: [:0]const u8) bool {
return c.strcasecmp(a.ptr, b.ptr) == 0;
}
// Find a substring using C's strstr
// Returns the index of the first occurrence, or null
pub fn indexOf(haystack: [:0]const u8, needle: [:0]const u8) ?usize {
const result = c.strstr(haystack.ptr, needle.ptr);
if (result) |found| {
return @intFromPtr(found) - @intFromPtr(haystack.ptr);
}
return null;
}
// Convert a character to uppercase using C's toupper
pub fn toUpper(ch: u8) u8 {
return @intCast(c.toupper(@as(c_int, ch)));
}
// Count occurrences of a character using C's strchr
pub fn countChar(str: [:0]const u8, ch: u8) usize {
var count: usize = 0;
var ptr: [*c]const u8 = str.ptr;
while (true) {
ptr = c.strchr(ptr, @as(c_int, ch)) orelse break;
count += 1;
ptr += 1;
}
return count;
}
// Format into a stack buffer using C's snprintf
pub fn format(buf: []u8, comptime fmt: [:0]const u8, args: anytype) []const u8 {
const result = @call(.auto, c.snprintf, .{buf.ptr, buf.len} ++ args);
_ = fmt;
const len: usize = if (result < 0) 0 else @min(@as(usize, @intCast(result)), buf.len - 1);
return buf[0..len];
}
};
pub fn main() void {
// Case-insensitive comparison
std.debug.print("hello == HELLO? {}\n", .{CStr.eqlIgnoreCase("hello", "HELLO")});
std.debug.print("hello == world? {}\n", .{CStr.eqlIgnoreCase("hello", "world")});
// Substring search
if (CStr.indexOf("Hello, World!", "World")) |idx| {
std.debug.print("'World' found at index {d}\n", .{idx});
}
if (CStr.indexOf("Hello, World!", "xyz")) |_| {
std.debug.print("Should not reach\n", .{});
} else {
std.debug.print("'xyz' not found (correct)\n", .{});
}
// Character counting
const count = CStr.countChar("abracadabra", 'a');
std.debug.print("'a' appears {d} times in 'abracadabra'\n", .{count});
// Uppercase
std.debug.print("Upper: ", .{});
for ("hello") |ch| {
std.debug.print("{c}", .{CStr.toUpper(ch)});
}
std.debug.print("\n", .{});
}
This is realistic C interop in action. We're using C's string functions (which are optimized to the bone on every platform -- glibc's strlen uses SIMD vectorization that we covered in episode 19) through a clean Zig wrapper. The caller never touches raw C types. The wrapper handles null checking, pointer arithmetic, and type conversions.
In production code, you'd probably use Zig's own standard library string functions for most things (and they're good!). But there are C functions that have no Zig equivalent yet, and wrapping them like this is the idiomatic approach. As Zig's standard library matures, some of these wrappers become unnecessary -- but the pattern stays the same for any C library you need to integrate.
Wat we geleerd hebben
@cImportand@cIncludetranslate C headers into Zig declarations at compile time. No bindings generators, no glue code -- the Zig compiler runs clang's C parser directly and produces type-safe Zig interfaces.- C types map to Zig types following predictable rules:
intbecomesc_int,longbecomesc_long,size_tbecomesusize. Pointers become optional pointers (?*T) because C pointers can always beNULL. - Calling C functions from Zig is a direct function call with zero overhead. The same ABI, the same calling convention, the same machine code.
- Strings require attention at the boundary: Zig literals are null-terminated and coerce automatically, but runtime
[]u8slices need manual null termination before passing to C. Going the other direction,std.mem.spanconverts C strings back into Zig slices. - Nullable pointers from C become
?*Tin Zig, forcing you to handle the null case. This turns C's "maybe NULL, maybe not, good luck" into compile-time safety. build.zighandles linking:linkLibC()for libc,linkSystemLibrary("name")for any installed C library. Add include paths and library paths when headers aren't in default locations.- Wrapping C APIs is the production pattern: a thin layer that translates C error codes into Zig error unions, C nullable pointers into Zig optionals, and C strings into Zig slices. The rest of your code stays in safe, idiomatic Zig.
- Zero-cost means exactly that -- Zig's C interop has no runtime overhead. The generated machine code is identical to what a C compiler would produce for the same calls.
C interop is what makes Zig practical for real projects today, even though the ecosystem is still young. Every C library ever written is available to you, and using it costs nothing. We've covered calling into C from Zig -- but the story goes both ways. What if you want C code to call your Zig functions? That's a whole other set of considerations around export conventions and ABI compatibility that we'll get into soon.
Exercises
Write a program that uses
@cImportto import<time.h>and wrapslocaltimeandstrftimeinto a Zig-friendlyDateTimestruct. The struct should have anow()method that returns the current time, and aformat(buf: []u8, fmt: [:0]const u8)method that formats it into a buffer. Use your wrapper to print the current date and time in at least 3 different formats (e.g. "2026-04-23", "23/04/2026 14:30:05", "Wednesday April 23").Create a Zig wrapper around C's
qsortfunction that sorts an array off64values. Your wrapper should take a[]f64slice and sort it in place. The tricky part:qsortexpects a comparison function with the C signatureint (*)(const void*, const void*)-- you'll need to write a Zig function that matches this signature and export it with the right calling convention. Compare the result with Zig's ownstd.mem.sortto verify correctness.Build a mini key-value store that uses C's
bsearchfunction for fast lookups. Define a structEntrywithkey: [32]u8andvalue: [64]u8fields (fixed-size, C-compatible). Write alookupfunction that sorts an array of entries by key usingqsort, then usesbsearchto find a specific entry. Handle the null return (key not found) properly using Zig's optional type. Populate the store with at least 10 entries and demonstrate both successful and failed lookups.
Thanks for your time!