C headers are the bridge between Zig and C.
A header file describes functions, structs, constants, enums, macros, and type declarations. Zig reads those declarations through @cImport.
For example:
// mathlib.h
#ifndef MATHLIB_H
#define MATHLIB_H
int add(int a, int b);
#endifZig imports it like this:
const c = @cImport({
@cInclude("mathlib.h");
});Then Zig can call:
const result = c.add(10, 20);The header is not compiled into the final executable. It only describes the API.
What a Header Does
A header file answers questions such as:
What functions exist?
What types do they use?
What structs exist?
What constants are defined?
What does the compiler need to know?A header does not usually contain the actual function bodies.
This header:
int add(int a, int b);declares a function.
This source file:
int add(int a, int b) {
return a + b;
}implements the function.
The header is for compilation.
The source file or library is for linking.
Include Guards
Most C headers use include guards.
Example:
#ifndef MATHLIB_H
#define MATHLIB_H
int add(int a, int b);
#endifThe pattern prevents multiple inclusion.
Without guards, including the same header more than once may produce duplicate definition errors.
The basic idea:
If MATHLIB_H is not defined:
define it
process this headerModern C code may also use:
#pragma onceinstead of traditional guards.
Both approaches try to solve the same problem.
@cInclude
Inside Zig, headers are imported with @cInclude.
const c = @cImport({
@cInclude("stdio.h");
});This is conceptually similar to:
#include <stdio.h>in C.
You can include several headers:
const c = @cImport({
@cInclude("stdio.h");
@cInclude("stdlib.h");
@cInclude("string.h");
});All imported declarations become available through c.
_ = c.puts("hello");
const len = c.strlen("zig");Angle Brackets vs Quotes
C supports two common include styles:
#include <stdio.h>
#include "mathlib.h"Angle brackets usually mean:
Search system include pathsQuotes usually mean:
Search local project paths firstIn Zig, @cInclude simply takes the header name as a string:
@cInclude("mathlib.h");The actual search paths come from the build configuration.
Include Paths
If Zig cannot find a header, you may see an error like:
'mathlib.h' file not foundThis means the compiler does not know where the header lives.
Suppose the project layout is:
project/
build.zig
include/
mathlib.h
src/
main.zigThe build file must add the include directory:
exe.addIncludePath(b.path("include"));Now this works:
const c = @cImport({
@cInclude("mathlib.h");
});Without the include path, Zig cannot locate the header.
System Headers
System headers come from the operating system, libc, SDKs, or installed development packages.
Examples:
@cInclude("stdio.h");
@cInclude("stdlib.h");
@cInclude("string.h");
@cInclude("stdint.h");These headers are usually found automatically if libc and the target environment are configured correctly.
Example:
const c = @cImport({
@cInclude("stdio.h");
});
pub fn main() void {
_ = c.puts("hello");
}Local Headers
Project-specific headers usually live in your repository.
Example layout:
project/
include/
parser.h
lexer.hBuild file:
exe.addIncludePath(b.path("include"));Import:
const c = @cImport({
@cInclude("parser.h");
@cInclude("lexer.h");
});The include path tells Zig where those files exist.
Organizing Imports
Small projects may import headers directly inside main.zig.
const c = @cImport({
@cInclude("library.h");
});Larger projects benefit from a dedicated import module.
Example:
src/
c.zig
main.zig
wrapper.zigsrc/c.zig:
pub const c = @cImport({
@cInclude("library.h");
@cInclude("helper.h");
});Other Zig files:
const c = @import("c.zig").c;This centralizes the raw C boundary.
It also prevents repeated imports across many files.
@cDefine
Some headers change behavior based on preprocessor macros.
C example:
#define MY_FEATURE 1
#include "library.h"Zig equivalent:
const c = @cImport({
@cDefine("MY_FEATURE", "1");
@cInclude("library.h");
});The macro exists while importing the header.
This is common in libraries that expose optional features or configuration flags.
@cUndef
You can also undefine a macro:
const c = @cImport({
@cDefine("DEBUG", "1");
@cUndef("DEBUG");
@cInclude("library.h");
});This is less common, but sometimes useful when a header behaves differently depending on whether a macro exists.
Header Order Can Matter
Some headers depend on macros or earlier includes.
Example:
#define FEATURE_X 1
#include "config.h"
#include "library.h"The same logic may matter in Zig:
const c = @cImport({
@cDefine("FEATURE_X", "1");
@cInclude("config.h");
@cInclude("library.h");
});Headers are not always independent. Some rely on earlier declarations or feature macros.
If a library’s documentation specifies an include order, preserve it.
Function Declarations
A header often contains function declarations.
int add(int a, int b);Zig imports the declaration:
const result = c.add(10, 20);The declaration tells Zig:
Function name
Parameter types
Return type
Calling conventionThe actual implementation still must be linked separately.
Struct Declarations
Headers also declare structs.
typedef struct Point {
int x;
int y;
} Point;Imported Zig code can use:
var p = c.Point{
.x = 10,
.y = 20,
};The imported struct uses the C ABI layout.
Opaque structs also appear in headers:
typedef struct Database Database;This declares the type name without exposing fields.
C callers only hold pointers:
Database *database_open(const char *path);This pattern is common in stable library APIs.
Constants and Macros
Headers often define constants:
#define MAX_BUFFER 4096
#define MODE_READ 1Imported Zig code may use:
const max = c.MAX_BUFFER;
const mode = c.MODE_READ;Simple numeric macros usually import cleanly.
Complex macros may not.
Function-Like Macros
C macros are preprocessor substitutions, not real functions.
Example:
#define SQUARE(x) ((x) * (x))Some macros import poorly because they depend on textual substitution tricks.
In those cases, write a wrapper function in C:
// wrapper.h
int square_int(int x);// wrapper.c
#define SQUARE(x) ((x) * (x))
int square_int(int x) {
return SQUARE(x);
}Now Zig imports the wrapper:
const c = @cImport({
@cInclude("wrapper.h");
});
const x = c.square_int(9);This is usually easier than trying to expose complicated macros directly.
Conditional Compilation in Headers
Headers often use conditional compilation.
Example:
#ifdef _WIN32
void windows_only(void);
#else
void unix_only(void);
#endifDifferent targets may see different declarations.
That means Zig imports can vary depending on the build target.
Cross-platform projects should expect this.
You may need Zig-side platform checks too:
const builtin = @import("builtin");
if (builtin.os.tag == .windows) {
// windows behavior
} else {
// unix behavior
}Header and Library Must Match
A subtle but important rule:
The header and the compiled library must describe the same ABI.Bad combinations cause undefined behavior.
Examples:
Header from version 1
Library from version 2
Header compiled with one macro set
Library compiled with another macro set
Header expects packed layout
Library compiled differentlyThese mismatches may compile successfully and still fail at runtime.
Keep headers and libraries synchronized.
C++ Headers
Some headers are actually C++ headers.
Example:
class Widget {
public:
void run();
};Zig cannot directly import arbitrary C++ APIs the same way it imports C APIs.
For C++ libraries, expose a C wrapper layer.
Example:
extern "C" int widget_run(Widget *w);Then Zig imports the C wrapper header.
This keeps the ABI stable and predictable.
Generating Headers from Zig
If Zig exports functions for C, you should provide a matching C header.
Example Zig:
export fn mylib_add(a: c_int, b: c_int) c_int {
return a + b;
}Matching header:
#ifndef MYLIB_H
#define MYLIB_H
int mylib_add(int a, int b);
#endifThe header is the public contract for C users.
Keep it simple and stable.
Keep Headers Small
A good public header exposes only what users need.
Avoid exposing internal implementation details.
Good:
typedef struct Database Database;
Database *database_open(const char *path);
void database_close(Database *db);Less stable:
typedef struct Database {
int fd;
int cache_size;
int flags;
} Database;The second form freezes the struct layout into the public ABI.
The first form hides implementation details.
Common Header Problems
Missing include path:
'library.h' file not foundFix:
exe.addIncludePath(...);Header/library mismatch:
Compiles successfully
Crashes at runtimePossible cause:
Wrong library version
Wrong macros
Wrong struct layoutC++ header imported as C:
Unexpected parse errors
Unknown keywordsFix:
Use a C wrapper layerMacro conflicts:
Duplicate macro definitions
Unexpected macro expansionFix:
Control macros carefully with @cDefine and @cUndefWhat to Remember
Headers describe APIs.
Headers are for compilation, not linking.
Use @cInclude inside @cImport.
Use addIncludePath so Zig can find local headers.
Use dedicated import modules in larger projects.
Keep headers and compiled libraries synchronized.
Simple constants and declarations import well.
Complex macros and C++ APIs usually need wrappers.
Good headers expose stable contracts and hide implementation details.