The C programming language has accumulated decades of battle-tested libraries for everything from embedded key-value stores (LMDB) to natural language processing (Snowball stemmers) to machine learning inference (ONNX Runtime). Rather than rewriting these libraries from scratch, modern systems languages provide Foreign Function Interface (FFI) mechanisms to call C code directly. This interop capability is what makes it practical to build new systems in Zig or Go while still leveraging the enormous C ecosystem.
Zig and Go take fundamentally different approaches to C interop. Zig treats C as a first-class import: @cImport parses C header files at compile time, automatically translating C types and function signatures into Zig types with no code generation step. Go uses cgo, a tool that generates C-to-Go bridge code from special comments in .go files. Both approaches have distinct trade-offs in performance, safety, and build complexity that directly impact how a typical system integrates native libraries.
In practice, C interop is pervasive in performance-sensitive systems: a Zig search engine might call LMDB for persistent storage and Snowball for stemming; a Go distributed database might use cgo to bind LMDB and other native dependencies; and an inference pipeline calls ONNX Runtime’s C API for model execution. Understanding FFI patterns—memory ownership across language boundaries, ABI compatibility, and build system integration—is essential for working on any part of this stack.
The Application Binary Interface (ABI) defines how functions pass arguments, return values, and manage the call stack at the machine code level. C uses a platform-specific calling convention (System V AMD64 on Linux/macOS x86-64, AAPCS on ARM64). For FFI to work, the calling language must match the C calling convention exactly—wrong register assignments or stack alignment cause crashes or silent data corruption. Both Zig and Go handle this automatically when calling through their respective FFI mechanisms.
The most dangerous aspect of FFI is memory ownership. When a C function returns a pointer, who is responsible for freeing it? When Go passes a slice to C, what happens if the garbage collector moves the backing array? These questions have no universal answer—each C library defines its own ownership rules, and the FFI caller must respect them precisely. Getting this wrong leads to use-after-free bugs, double frees, or memory leaks that are extremely difficult to diagnose.
C libraries typically use Makefiles, CMake, or autotools for building. Integrating these into a Zig or Go build pipeline requires either pre-building the C library and linking against it, or incorporating the C source files directly into the host language’s build system. Zig excels here because its build system can compile C code directly (it includes a full C compiler). Go’s cgo relies on the system C compiler (gcc or clang) and requires CGO_ENABLED=1, which complicates cross-compilation.
Zig’s @cImport parses C headers at compile time and translates them into Zig types automatically. @cInclude("lmdb.h") makes all LMDB functions callable as c.mdb_env_open(...) with full type safety. No binding generator, no wrapper code, no separate build step. The Zig compiler itself is also a C compiler, so it can compile C source files as part of the same build.
Go’s cgo uses special // #include and // #cgo comments before import "C" to declare C dependencies. The cgo tool generates bridge functions that convert between Go and C calling conventions. Each cgo call crosses a goroutine/thread boundary with overhead of ~100ns, making it unsuitable for fine-grained calls in hot loops.
Go’s garbage collector can move objects in memory. When passing Go memory to C, the runtime “pins” the memory to prevent relocation during the C call. Since Go 1.21, runtime.Pinner provides explicit pinning for memory that C code retains beyond the call boundary. Zig has no GC, so memory passed to C stays at the same address until explicitly freed.
LMDB (Lightning Memory-mapped Database) provides a C API for embedded key-value storage. In Zig, the entire API becomes available through a single @cImport:
const c = @cImport({
@cInclude("lmdb.h");
});
pub const LmdbEnv = struct {
env: ?*c.MDB_env,
pub fn open(path: [*:0]const u8, max_dbs: u32) !LmdbEnv {
var env: ?*c.MDB_env = null;
// mdb_env_create returns 0 on success, error code otherwise
var rc = c.mdb_env_create(&env);
if (rc != 0) return error.EnvCreateFailed;
rc = c.mdb_env_set_maxdbs(env, max_dbs);
if (rc != 0) {
c.mdb_env_close(env);
return error.SetMaxDbsFailed;
}
rc = c.mdb_env_open(env, path, c.MDB_NOSUBDIR | c.MDB_NOSYNC, 0o644);
if (rc != 0) {
c.mdb_env_close(env);
return error.EnvOpenFailed;
}
return LmdbEnv{ .env = env };
}
pub fn close(self: *LmdbEnv) void {
if (self.env) |env| {
c.mdb_env_close(env);
self.env = null;
}
}
};
The same LMDB library accessed from Go requires cgo comments and explicit type conversions between Go and C types:
package lmdb
/*
#cgo LDFLAGS: -llmdb
#include <lmdb.h>
#include <stdlib.h>
*/
import "C"
import (
"fmt"
"unsafe"
)
type Env struct {
env *C.MDB_env
}
func NewEnv(path string, maxDBs uint32) (*Env, error) {
var env *C.MDB_env
rc := C.mdb_env_create(&env)
if rc != 0 {
return nil, fmt.Errorf("mdb_env_create: %d", rc)
}
C.mdb_env_set_maxdbs(env, C.MDB_dbi(maxDBs))
// Convert Go string to C string (allocates, must free)
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
rc = C.mdb_env_open(env, cpath, C.MDB_NOSUBDIR|C.MDB_NOSYNC, 0644)
if rc != 0 {
C.mdb_env_close(env)
return nil, fmt.Errorf("mdb_env_open: %d", rc)
}
return &Env{env: env}, nil
}
func (e *Env) Close() {
if e.env != nil {
C.mdb_env_close(e.env)
e.env = nil
}
}
The Snowball stemmer library provides C functions for word stemming. Zig wraps these with safe Zig idioms while managing the C library’s allocation pattern:
const c = @cImport({
@cInclude("libstemmer.h");
});
pub const Stemmer = struct {
handle: *c.struct_sb_stemmer,
pub fn init(language: [*:0]const u8) !Stemmer {
const handle = c.sb_stemmer_new(language, null) orelse
return error.UnsupportedLanguage;
return Stemmer{ .handle = handle };
}
pub fn stem(self: *const Stemmer, word: []const u8) []const u8 {
// sb_stemmer_stem returns a pointer into internal buffer
// Valid only until next call to stem — do NOT free
const result = c.sb_stemmer_stem(
self.handle,
word.ptr,
@intCast(word.len),
);
const len: usize = @intCast(c.sb_stemmer_length(self.handle));
return result[0..len];
}
pub fn deinit(self: *Stemmer) void {
c.sb_stemmer_delete(self.handle);
}
};
C strings are null-terminated ([*:0]const u8 in Zig), while Zig and Go strings carry explicit length. Every FFI boundary must convert between these representations. In Zig, use [*:0]const u8 for C string parameters. In Go, use C.CString() which allocates a null-terminated copy that must be freed with C.free().
A Zig search engine can use LMDB as its primary storage backend for inverted indices, vector data, and metadata. The Zig-to-C boundary is crossed on every read and write operation. Because LMDB uses memory-mapped files, the pointers returned by mdb_get point directly into the memory-mapped region—no copy is needed, but the pointer is only valid within the transaction scope.
An inference pipeline calls ONNX Runtime’s C API to execute embedding models and rerankers. The FFI boundary must handle tensor memory allocation: input tensors are created by the caller and ownership transfers to the ONNX session, while output tensors are owned by the runtime and must be released through its API. Mixing up these ownership rules causes either leaks or crashes.
A Go distributed database might use cgo bindings for LMDB and other native libraries. To minimize cgo call overhead (which blocks a goroutine on an OS thread), the Go code batches operations: rather than calling LMDB once per key, it opens a transaction, performs multiple gets/puts through a single cgo boundary crossing, and commits. This amortizes the ~100ns per-call overhead across hundreds of operations.
Building for multiple platforms (Linux amd64, Linux arm64, macOS) with C dependencies requires either cross-compilers or CI matrix builds. Zig simplifies this because its toolchain includes cross-compilation support for C: zig build -Dtarget=aarch64-linux compiles both Zig and C code for ARM64 Linux from any host. Go’s cgo cross-compilation requires a separate C cross-compiler for each target.
Raw C APIs expose nullable pointers, integer error codes, and manual memory management. The standard pattern is to wrap each C API in a safe abstraction that translates C idioms into the host language’s error handling and resource management:
error valuesdeinit() / Close() methodsEach cgo call has fixed overhead from the goroutine-to-thread transition. For hot paths, batch multiple operations into a single C function that the Go side calls once:
/*
#include "batch_ops.h"
// C-side batch function: performs N gets in a single call
int batch_get(MDB_txn *txn, MDB_dbi dbi,
MDB_val *keys, MDB_val *vals, int count);
*/
import "C"
func (db *DB) BatchGet(keys [][]byte) ([][]byte, error) {
ckeys := make([]C.MDB_val, len(keys))
cvals := make([]C.MDB_val, len(keys))
for i, k := range keys {
ckeys[i] = C.MDB_val{
mv_size: C.size_t(len(k)),
mv_data: unsafe.Pointer(&k[0]),
}
}
// Single cgo call for N operations
rc := C.batch_get(db.txn, db.dbi,
&ckeys[0], &cvals[0], C.int(len(keys)))
if rc != 0 {
return nil, fmt.Errorf("batch_get: %d", rc)
}
results := make([][]byte, len(keys))
for i := range results {
results[i] = C.GoBytes(cvals[i].mv_data, C.int(cvals[i].mv_size))
}
return results, nil
}
Zig’s build.zig can compile C source files alongside Zig code, managing include paths, compiler flags, and linking in one unified system:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "myengine",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Compile LMDB C sources directly
exe.addCSourceFiles(.{
.files = &.{ "deps/lmdb/mdb.c", "deps/lmdb/midl.c" },
.flags = &.{ "-DMDB_USE_POSIX_MUTEX", "-O2" },
});
exe.addIncludePath(b.path("deps/lmdb"));
// Link Snowball as a pre-built static library
exe.addLibraryPath(b.path("deps/snowball/lib"));
exe.linkSystemLibrary("stemmer");
exe.addIncludePath(b.path("deps/snowball/include"));
b.installArtifact(exe);
}
The Zig toolchain includes a complete C/C++ compiler based on Clang. This means build.zig can compile C source files, apply C compiler flags, and link everything into a single binary—no separate Makefile or CMake needed. This also enables seamless cross-compilation of C dependencies to any target Zig supports.
C.CString() in Go allocates and copies the string data, then requires a C.free(). For frequently-called functions that take string parameters, consider caching the C string or using a buffer pool.C.CString() in Go allocates via malloc. Missing the defer C.free() leaks memory. This is the single most common cgo bug and is invisible to Go’s garbage collector since the allocation is on the C heap.mdb_get returns a pointer valid only within the current transaction. Storing this pointer and using it after the transaction closes reads garbage or crashes. Always copy data out before closing the transaction.?*T (optional pointers). Failing to check for null before dereferencing a C-returned pointer causes a safety-check panic in safe mode or undefined behavior in release mode.@cImport handles this correctly, but manually defining C struct layouts in Zig (using extern struct) requires matching the exact field order, sizes, and alignment of the C original.