Zig works unusually well with C because it treats C as a first-class part of systems programming.
Zig works unusually well with C because it treats C as a first-class part of systems programming.
Many languages can call C, but they usually need a foreign function interface, a binding generator, a wrapper layer, or a separate build system. Zig tries to remove most of that friction. It can import C headers, call C functions, link C libraries, compile C source files, and even act as a C compiler.
This matters because C is everywhere. Operating systems expose C APIs. Many database engines are written in C. Many graphics, audio, compression, crypto, networking, and embedded libraries are written in C. If a language cannot work well with C, it cuts itself off from a large part of existing systems software.
Zig does not try to pretend that C does not exist. It gives you a practical bridge.
C Is the Common Language of Systems Software
C is old, but it is still one of the main languages used at the operating system boundary.
For example, when you call into Linux, macOS, Windows, SQLite, OpenSSL, zlib, libcurl, SDL, Raylib, Lua, or many embedded SDKs, you are often dealing with a C API.
A C API usually contains things like this:
int add(int a, int b);A Zig program can call that function directly once the declaration is available.
That is the basic idea of C interop: Zig code and C code agree on function names, parameter types, return types, memory layout, and calling convention.
Zig Can Import C Headers
C libraries usually describe their public API in header files.
A header file might look like this:
// mathlib.h
int add(int a, int b);
int sub(int a, int b);In many languages, you would need to rewrite these declarations in another format. Zig can import the C header directly:
const c = @cImport({
@cInclude("mathlib.h");
});After that, you can call the C functions through c:
const result = c.add(10, 20);This is one of Zig’s strongest C interop features. You do not have to manually duplicate every C declaration in Zig.
Zig Understands C Types
C has types such as:
int
char
float
double
size_t
void *
const char *
struct
enumZig can represent these types when importing C code.
For example, a C function like this:
size_t strlen(const char *s);can be used from Zig after importing the right header:
const c = @cImport({
@cInclude("string.h");
});
pub fn main() void {
const len = c.strlen("hello");
_ = len;
}The imported C function keeps its C shape. Zig does not hide the fact that this is a C function. That is useful because C APIs often have C-style rules: null-terminated strings, raw pointers, manual memory ownership, and integer status codes.
Zig lets you call them, but it still makes the boundary visible.
Zig Can Compile C Code
Zig is not only a Zig compiler. It can also compile C code.
For example:
zig cc main.c -o mainHere, zig cc behaves like a C compiler command.
This is useful because Zig ships with its own toolchain behavior. You can often use Zig to compile C programs without separately setting up a complicated cross-compilation environment.
For example, compiling C for another target can look like this:
zig cc main.c -target x86_64-linux-gnu -o mainThis is one reason Zig is popular as a portable C compiler driver. Even if a project is mostly written in C, Zig can still be useful as the build tool.
Zig Can Link C Libraries
Calling a C function is only one part of the job. Your final executable also needs to link against the C library that contains the function’s implementation.
Suppose you have this C file:
// mathlib.c
int add(int a, int b) {
return a + b;
}and this header:
// mathlib.h
int add(int a, int b);Your Zig code can import the header:
const c = @cImport({
@cInclude("mathlib.h");
});
pub fn main() void {
const x = c.add(10, 20);
_ = x;
}But the header only describes the function. The actual function body is in mathlib.c. The build system must include that C file or link a library that contains it.
In build.zig, this usually means adding C source files or linking external libraries. The important idea is simple:
The header tells Zig what exists.
The object file or library gives the linker the actual code.
Both are needed.
Zig Uses C ABI Compatibility
ABI means Application Binary Interface.
An ABI defines how compiled code talks to other compiled code. It covers details such as:
| ABI concern | Meaning |
|---|---|
| Function calling convention | How parameters are passed |
| Return values | How results come back |
| Struct layout | How fields are arranged in memory |
| Alignment | Where values must be placed in memory |
| Symbol names | How functions are named in object files |
C interoperability depends on ABI compatibility.
If Zig and C disagree about how a function is called or how a struct is laid out, the program can break even if it compiles.
Zig provides tools for this. For example, you can define an extern struct:
const Point = extern struct {
x: c_int,
y: c_int,
};The word extern tells Zig to use a layout compatible with C.
Without extern, Zig is free to use its own layout rules. That may be better for Zig code, but it is not something you should pass directly to C.
Zig Makes C Boundaries Explicit
A good Zig program does not smear C-style code everywhere.
Instead, you usually place C interaction at the edge of your program. Then you wrap it in safer Zig functions.
For example, a C function may return an integer status code:
int do_work(void);Maybe 0 means success and nonzero means failure.
In Zig, you might wrap it like this:
const c = @cImport({
@cInclude("work.h");
});
const WorkError = error{
Failed,
};
fn doWork() WorkError!void {
const rc = c.do_work();
if (rc != 0) {
return error.Failed;
}
}Now the rest of your Zig program can use normal Zig error handling:
try doWork();This is the usual pattern:
Call C at the boundary.
Translate C errors into Zig errors.
Translate raw pointers into safer Zig types when possible.
Keep unsafe assumptions small and local.
C Strings Need Special Attention
C strings are usually null-terminated.
That means the string continues until the program finds a zero byte:
const char *name = "zig";In Zig, normal string data is usually represented as a slice:
[]const u8A slice has a pointer and a length. It does not need a zero byte at the end.
This difference matters.
A C function like this:
void puts(const char *s);expects a null-terminated string.
A Zig string literal can often be passed because Zig string literals have a sentinel zero byte. But not every Zig slice is safe to pass as a C string.
This is safe:
const c = @cImport({
@cInclude("stdio.h");
});
pub fn main() void {
_ = c.puts("hello from C");
}But if you have a runtime slice:
const msg: []const u8 = "hello";you should not blindly pass msg.ptr to a C function that expects a null-terminated string unless you know there is a zero byte after the data.
This is one of the first C interop rules to learn:
Zig slices know their length.
C strings usually do not.
Memory Ownership Must Be Clear
C libraries often allocate memory and ask you to free it later.
For example, a C library may have an API like this:
char *make_message(void);
void free_message(char *message);If Zig calls make_message, Zig must also call the matching C cleanup function.
In Zig, this should be made explicit:
const c = @cImport({
@cInclude("message.h");
});
fn useMessage() void {
const ptr = c.make_message();
defer c.free_message(ptr);
// use ptr here
}The defer line says: when this function exits, call free_message(ptr).
This makes cleanup visible and hard to forget.
Do not assume you can free C memory with a Zig allocator. The allocator that created the memory should usually be the one that frees it.
If C allocated it, use the C library’s free function.
If Zig allocated it, free it with the Zig allocator that created it.
Zig Can Replace C Gradually
One practical use of Zig is incremental replacement.
You may have an existing C project. You do not need to rewrite everything. You can start by adding one Zig file, calling existing C code, and exporting Zig functions back to C where needed.
For example, Zig can export a function with a C-compatible ABI:
export fn add(a: c_int, b: c_int) c_int {
return a + b;
}Now C code can call this function as if it were a C function, assuming the symbol is linked correctly.
This makes Zig useful in real projects. Migration does not have to be all-or-nothing.
Zig Also Improves C Builds
Zig’s C support is not only for calling C from Zig. It can also simplify C build workflows.
Common C build problems include:
Different compilers on different machines.
Difficult cross-compilation.
Missing libc or system headers.
Complex linker flags.
Platform-specific build scripts.
Zig helps by giving you a single command-line interface that can target many platforms. This does not remove every build problem, but it reduces a lot of toolchain friction.
For example:
zig cc hello.c -target aarch64-linux-gnu -o helloThat command expresses the target directly. You do not need to manually find a separate cross C compiler named something like aarch64-linux-gnu-gcc.
Zig and C Are Still Different Languages
C interop is powerful, but it does not mean C code becomes safe automatically.
When you call C, you may still be dealing with:
Raw pointers.
Null pointers.
Manual memory cleanup.
Integer status codes.
Global state.
Thread safety rules.
Buffer sizes.
Null-terminated strings.
Undefined behavior inside the C library.
Zig can make the interface cleaner, but it cannot magically fix the C code underneath.
That is why good Zig code usually wraps C libraries instead of exposing them everywhere.
A thin wrapper might translate this C-style API:
int read_data(char *buffer, size_t buffer_len);into a Zig-style API:
fn readData(buffer: []u8) !usize {
const rc = c.read_data(buffer.ptr, buffer.len);
if (rc < 0) {
return error.ReadFailed;
}
return @intCast(rc);
}Now callers use a slice, not a raw pointer and length pair. They also get Zig error handling instead of manually checking a negative integer.
The Core Idea
Zig works well with C because it respects how C actually works.
It can import C headers.
It can call C functions.
It can compile C code.
It can link C libraries.
It can use C ABI-compatible types.
It can export functions back to C.
Most importantly, Zig makes the boundary clear. You can use existing C code, but you can also wrap it in safer Zig APIs.
That is the right mental model for Zig and C:
Use C directly where needed.
Contain C complexity at the edges.
Expose clean Zig interfaces to the rest of your program.