A mixed Zig and C project contains source files from both languages.
A mixed Zig and C project contains source files from both languages.
This is common when you are using an existing C library, replacing part of a C project with Zig, or writing a Zig program that depends on small C helper files.
A simple mixed project may look like this:
project/
build.zig
src/
main.zig
mathlib.c
mathlib.hThe Zig file contains the main program. The C header describes the C API. The C source file contains the C implementation.
The C Header
The header is the contract between Zig and C.
// src/mathlib.h
#ifndef MATHLIB_H
#define MATHLIB_H
int add(int a, int b);
int sub(int a, int b);
#endifThis file says two functions exist:
add
subIt does not contain the function bodies. It only describes their names, parameters, and return types.
The C Source File
The C source file implements the functions.
// src/mathlib.c
#include "mathlib.h"
int add(int a, int b) {
return a + b;
}
int sub(int a, int b) {
return a - b;
}This is the code the linker needs in the final executable.
The Zig File
The Zig file imports the C header with @cImport.
// src/main.zig
const std = @import("std");
const c = @cImport({
@cInclude("mathlib.h");
});
pub fn main() void {
const x = c.add(10, 20);
const y = c.sub(10, 3);
std.debug.print("x = {}, y = {}\n", .{ x, y });
}This program calls the C functions through the imported namespace c.
The name c is only a convention. It helps readers see that c.add and c.sub come from C.
The Build File
The build file connects everything.
// 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 = "mixed",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
exe.addIncludePath(b.path("src"));
exe.addCSourceFile(.{
.file = b.path("src/mathlib.c"),
.flags = &.{},
});
exe.linkLibC();
b.installArtifact(exe);
}There are three important lines:
exe.addIncludePath(b.path("src"));This lets @cInclude("mathlib.h") find the header.
exe.addCSourceFile(.{
.file = b.path("src/mathlib.c"),
.flags = &.{},
});This compiles the C source file and links it into the executable.
exe.linkLibC();This links the C standard library. Many C files need this, especially if they use libc functions.
Running the Project
Build the program:
zig buildRun the installed executable:
zig build runTo support zig build run, add a run step:
const run_cmd = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);A fuller build file becomes:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "mixed",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
exe.addIncludePath(b.path("src"));
exe.addCSourceFile(.{
.file = b.path("src/mathlib.c"),
.flags = &.{},
});
exe.linkLibC();
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
}Now this works:
zig build runExpected output:
x = 30, y = 7Keep the Boundary Simple
In a mixed project, the boundary between Zig and C should be small and clear.
This is good:
const result = c.add(10, 20);This is also fine:
fn add(a: i32, b: i32) i32 {
return @intCast(c.add(@intCast(a), @intCast(b)));
}The second version wraps the C function in a Zig function. That lets the rest of your program use normal Zig types and naming.
A wrapper becomes more valuable when the C API has raw pointers, status codes, or manual cleanup.
For example, C might expose this:
int read_file(const char *path, unsigned char *buffer, size_t len);A Zig wrapper could expose this:
const ReadError = error{
Failed,
};
fn readFile(path: [*:0]const u8, buffer: []u8) ReadError!void {
const rc = c.read_file(path, buffer.ptr, buffer.len);
if (rc != 0) {
return error.Failed;
}
}The rest of the Zig program calls:
try readFile("config.txt", buffer[0..]);That is cleaner than repeating C-style checks everywhere.
Passing Data Between Zig and C
Data crossing the Zig/C boundary must have a compatible representation.
Simple numbers are easy:
int add(int a, int b);Zig can call this with C-compatible integers:
const x: c_int = c.add(10, 20);Structs need C-compatible layout:
const Point = extern struct {
x: c_int,
y: c_int,
};Strings need special care. A Zig slice has a pointer and a length:
[]const u8A C string is usually a pointer to bytes ending in zero:
const char *So this is safe:
c.puts("hello");But this may be unsafe:
const msg: []const u8 = "hello";
_ = c.puts(msg.ptr);The pointer does not prove that the bytes are null-terminated.
Use a null-terminated pointer type when the C API expects a C string:
const msg: [*:0]const u8 = "hello";
_ = c.puts(msg);Building Several C Files
A real C library may have several source files.
src/
main.zig
c_lib/
parser.c
scanner.c
util.c
library.hYou can add them one by one:
exe.addCSourceFile(.{
.file = b.path("src/c_lib/parser.c"),
.flags = &.{},
});
exe.addCSourceFile(.{
.file = b.path("src/c_lib/scanner.c"),
.flags = &.{},
});
exe.addCSourceFile(.{
.file = b.path("src/c_lib/util.c"),
.flags = &.{},
});Or use a list:
const c_flags = &.{
"-std=c11",
};
const c_files = [_][]const u8{
"src/c_lib/parser.c",
"src/c_lib/scanner.c",
"src/c_lib/util.c",
};
for (c_files) |file| {
exe.addCSourceFile(.{
.file = b.path(file),
.flags = c_flags,
});
}This is easier to maintain when the list grows.
C Compile Flags
C files often need flags.
Examples:
const c_flags = &.{
"-std=c11",
"-Wall",
"-Wextra",
};Then pass them to each C source file:
exe.addCSourceFile(.{
.file = b.path("src/mathlib.c"),
.flags = c_flags,
});Flags can control the C standard, warnings, feature macros, optimization behavior, and platform-specific settings.
Some libraries need macro definitions:
const c_flags = &.{
"-DMY_LIBRARY_FEATURE=1",
};If a macro affects both the header and the source file, keep both sides consistent.
In Zig import:
const c = @cImport({
@cDefine("MY_LIBRARY_FEATURE", "1");
@cInclude("library.h");
});In build file:
const c_flags = &.{
"-DMY_LIBRARY_FEATURE=1",
};If the header sees one configuration and the C source is compiled with another, the declarations and implementations may not match.
Project Layout for Larger Mixed Projects
For larger projects, keep C code and Zig code organized.
One reasonable layout:
project/
build.zig
src/
main.zig
c.zig
wrapper.zig
c/
include/
library.h
src/
library.c
helper.cThen src/c.zig contains the import:
pub const c = @cImport({
@cInclude("library.h");
});Other Zig files use:
const c = @import("c.zig").c;The build file adds:
exe.addIncludePath(b.path("c/include"));and compiles the C files:
const c_files = [_][]const u8{
"c/src/library.c",
"c/src/helper.c",
};
for (c_files) |file| {
exe.addCSourceFile(.{
.file = b.path(file),
.flags = &.{"-std=c11"},
});
}This layout keeps the raw C import centralized. It also gives you a natural place for Zig wrappers.
Wrapping a C Library
Suppose C exposes an opaque handle:
typedef struct Database Database;
Database *database_open(const char *path);
void database_close(Database *db);
int database_exec(Database *db, const char *sql);The raw Zig calls might look like this:
const db = c.database_open("app.db") orelse return error.OpenFailed;
defer c.database_close(db);
const rc = c.database_exec(db, "create table users(id integer)");
if (rc != 0) return error.ExecFailed;A Zig wrapper gives it a better interface:
const DatabaseError = error{
OpenFailed,
ExecFailed,
};
const Database = struct {
ptr: *c.Database,
pub fn open(path: [*:0]const u8) DatabaseError!Database {
const ptr = c.database_open(path) orelse return error.OpenFailed;
return .{ .ptr = ptr };
}
pub fn close(self: Database) void {
c.database_close(self.ptr);
}
pub fn exec(self: Database, sql: [*:0]const u8) DatabaseError!void {
const rc = c.database_exec(self.ptr, sql);
if (rc != 0) {
return error.ExecFailed;
}
}
};Now application code is clean:
const db = try Database.open("app.db");
defer db.close();
try db.exec("create table users(id integer)");The wrapper has three benefits.
It converts null pointers into Zig errors.
It converts status codes into Zig errors.
It hides the raw C handle from most of the program.
Exporting Zig Functions to C
A mixed project can also call Zig from C.
Zig can export a C-compatible function:
export fn zig_add(a: c_int, b: c_int) c_int {
return a + b;
}C can declare it:
int zig_add(int a, int b);Then C can call it:
int result = zig_add(10, 20);The export keyword makes the function visible as a symbol in the final object or library.
Use C-compatible types in exported functions.
Avoid exporting Zig-specific types like slices, error unions, or normal Zig structs directly to C. C does not understand those types.
Instead, expose a C-shaped API:
export fn fill_buffer(buffer: [*]u8, len: usize) c_int {
const slice = buffer[0..len];
for (slice, 0..) |*byte, i| {
byte.* = @intCast(i % 256);
}
return 0;
}C sees pointer plus length. Zig internally converts that into a slice.
Build Modes Still Matter
Mixed projects still use Zig build modes.
Common modes:
zig build -Doptimize=Debug
zig build -Doptimize=ReleaseSafe
zig build -Doptimize=ReleaseFast
zig build -Doptimize=ReleaseSmallThese modes affect Zig code and may also affect how C code is compiled through the build system.
During development, use debug or safe builds. They give better diagnostics and stronger safety checks.
For release builds, choose the mode that matches your goal.
ReleaseFast favors speed.
ReleaseSmall favors binary size.
ReleaseSafe keeps more safety checks.
Common Errors
A header error usually looks like this:
'library.h' file not foundThis means the include path is wrong or missing.
Fix it with:
exe.addIncludePath(b.path("c/include"));A linker error usually looks like this:
undefined symbol: library_functionThis means the function was declared but no compiled implementation was linked.
Fix it by adding the C source file or linking the library.
A type error usually means your Zig call does not match the imported C declaration.
For example, a C function expects a pointer:
void update(int *value);But Zig passes a value:
c.update(x);The correct call is:
c.update(&x);What to Remember
A mixed Zig and C project needs headers, implementations, and a build file that connects them.
Use @cImport for C declarations.
Use addIncludePath so headers can be found.
Use addCSourceFile or library linking so implementations are included.
Use linkLibC when the C code needs libc.
Keep C imports centralized.
Wrap raw C APIs in Zig functions.
Use C-compatible types at the boundary.
Expose simple C-shaped APIs when C needs to call Zig.
The best mixed projects keep the boundary narrow. C code stays at the edge. Zig code uses clearer wrappers, explicit errors, slices, and ownership rules.