Zig and the art of Language Interoperability: A guide on Static & Dynamic Linking
Table of Contents
The limits of my language mean the limits of my world. - L. Wittgenstein
Imprisoned by language, impossible to escape, we have to learn how to navigate within it. Programming languages are just a way to navigate this inescapable restriction, a dialect that has the power to reshape how we think but not how the computer works.
There exist many programming languages, and the language you utilize today might not apply to the use case of tomorrow. General-purpose languages such as C are surviving because of their broad application and low-level capabilities. Zig, a somewhat recent programming language, is aiming to be a general-purpose programming language and toolchain for maintaining robust, optimal, and reusable software.
Zig has high-performance capabilities, and you might’ve wondered how you can use Zig with, for example, your existing Python or Rust projects.
This comprehensive guide shows you exactly how to create cross-language libraries using Zig’s powerful build system and linking capabilities.
Full example can be found at github.
Why Zig for Cross-Language Libraries?
Zig excels at creating interoperable libraries because of its:
- Zero-cost C interoperability - No wrapper overhead
- Cross-compilation support - Build for any target
- Powerful build system - Automate complex builds
- Memory safety - No hidden allocations!
What You’ll Learn
- Create static and dynamic libraries in Zig
- Build Python bindings using dynamic linking
- Integrate Rust clients with static linking
- Learn about Zig’s build system for cross-compilation
- Understand linking strategies and their trade-offs
References & Further Reading
We’re standing on the shoulders of giants
- Tigerbeetle - Great project and development philosophy, great reference for anyone who’d like to learn in-depth about software and development, please have a look.
- Zig in depth - Great tutorial series , please go give “Dude the Builder” some support.
- Zig Documentation - Obviously.
Prerequisites & Environment Setup
zig version
Using a pinned version ensures all examples work correctly and builds remain reproducible.
Creating our Exportable Zig Function
The foundation of cross-language interoperability is C ABI compatibility. Here’s our simple add function that we’ll export to other languages:
// Tangled from content/org/blog.org
const std = @import("std"); // Importing standard lib
const builtin = @import("builtin"); // Builtins
comptime { // Run this at compile time.
if (!builtin.link_libc) { // Require libc
@compileError("Symbols cannot be exported without libc.");
}
// Creates a symbol in the output object file which refers to the target of add function. Essentially this is equivalent to the export keyword used on a function.
@export(&add, .{ .name = "zig_add", .linkage = .strong });
}
// Our add function with C calling conventions, ensuring our function can be called correctly from other languages.
pub fn add(a: i32, b: i32) callconv(.C) i32 {
return a + b;
}
Key Points:
callconv(.C)
- Ensures C calling conventions@export()
- Makes function available to other languageslink_libc
requirement - Needed for symbol export
Understanding Linking Strategies
Static Linking
Self-contained executables - No external dependencies Easy deployment - Single binary distribution Larger file sizes - Code included in each binary No shared updates - Must recompile to update library
Dynamic Linking
Smaller executables - Shared library loaded at runtime Code sharing - Multiple programs use same library Easy updates - Update library without recompiling apps Dependency management - Must ensure library availability
Build system
Zig’s build system is the key to seamless cross-language integration. Let’s break down the essential components in the following sub-chapters.
Platform and Architecture Configuration
Before diving into the build logic, we need to understand how Zig handles cross-compilation. One of Zig’s standout features is its ability to compile for virtually any target platform.
In this example we’ll use the following; it’s possible that you might have to change these in order to match your system.
Target specification;
"x86_64-linux-gnu.2.27"
Architecture and OS;
"x86_64-linux"
CPU optimizations;
"x86_64_v3+aes"
Imports
Essential imports:
const std = @import("std"); // Zig's standard library
const assert = std.debug.assert; // Assert!
const builtin = @import("builtin"); // Builtins
const log = std.log.scoped(.build_log); // Scoped log, not 100% necessary in this example but we believe that it's a good practice.
Zig Version Compatibility
comptime {
const zigv_comp = builtin.zig_version.major == 0 and
builtin.zig_version.minor == 14 and
builtin.zig_version.patch == 0;
if (!zigv_comp) {
@compileError(std.fmt.comptimePrint("Zig version unsupported found {} expected 0.14.0", .{ builtin.zig_version },));
}
}
Do a compatibility check at compile-time to avoid possibly confusing runtime errors and make it immediately clear what needs to be fixed.
Why version checking matters: Zig evolves rapidly - this ensures your build scripts work reliably and provide clear error messages.
(This could of course be done in different ways, we could also use std.SemanticVersion
but for the sake of the demo we’d like to keep it simple.)
Library Build Function
fn build_lib(
b: *std.Build,
steps: *std.Build.Step,
options: struct {
optimize: std.builtin.OptimizeMode,
},
) void{
const query = std.Target.Query.parse(.{
.arch_os_abi = <<arch-os-abi>>,
.cpu_features = <<cpu-features>>,
}) catch unreachable;
const resolved_target = b.resolveTargetQuery(query);
const shared = b.addSharedLibrary(.{
.name = "add",
.root_source_file = b.path("src/root.zig"),
.target = resolved_target,
.optimize = options.optimize,
});
shared.linkLibC();
log.info("Creating shared lib {s}", .{
b.pathJoin(&.{
"./src/clients/lib/",
<<arch-os-abi>>,
shared.out_filename,
})});
steps.dependOn(&b.addInstallFile(
shared.getEmittedBin(),
b.pathJoin(&.{
"../src/clients/lib/",
<<arch-os-abi>>,
shared.out_filename,
}),
).step);
const static = b.addStaticLibrary(.{
.name = "add",
.root_source_file = b.path("src/root.zig"),
.target = resolved_target,
.optimize = options.optimize,
});
static.pie = true;
static.bundle_compiler_rt = true;
static.linkLibC();
log.info("Creating static lib {s}", .{
b.pathJoin(&.{
"./src/clients/lib/",
<<arch-os-abi>>,
static.out_filename,
})});
steps.dependOn(&b.addInstallFile(
static.getEmittedBin(),
b.pathJoin(&.{
"../src/clients/lib/",
<<arch-os-abi>>,
static.out_filename,
}),
).step);
// In case we'd like to add a module to the lib, we could do for example;
//const util_mod = b.createModule(.{ .root_source_file = b.path("src/lib/util.zig") });
//lib.root_module.addImport("util", util_mod);
// Specify path to link libs..
//step.dependOn(&lib.step);
//return lib;
}
Understanding the Build Function: Here we’re creating a static library from root.zig
.
std.Build
- type contains the information used by the build runner.std.Build.Step
- a “step” of our build process with its own dependencies that need to finish before this step.options
- struct parameter pattern provides clean, typed configuration options. We’ll tend to follow this pattern throughout the article.std.Build.ResolvedTarget
- Resolved target to build, used for cross-compilation.std.builtin.OptimizeMode
- Optimization configurations.
Python Integration with Dynamic Linking
"./src/clients/python/src/bindings.py"
Python integration uses dynamic linking for flexibility and easy updates. We’ll auto-generate Python bindings at build time.
fn build_python_client(b: *std.Build, steps: *std.Build.Step) void {
const PythonBuildStep = struct {
source: std.Build.LazyPath,
step: std.Build.Step,
// Ofcourse this could become more generic..
fn make_python(step: *std.Build.Step, prog_node: std.Build.Step.MakeOptions) anyerror!void {
_ = prog_node; // Not needed in this example..
const _b = step.owner;
const py: *@This() = @fieldParentPtr("step", step);
const source_path = py.source.getPath2(_b, step);
const p = try std.fs.Dir.updateFile(
_b.build_root.handle,
source_path,
_b.build_root.handle,
"./src/clients/python/src/bindings.py",
.{},);
step.result_cached = p == .fresh;
}
pub fn init(_b: *std.Build) *@This() {
const build_step = _b.allocator.create(@This()) catch @panic("Out of memory!");
build_step.* = .{
.source = _b.addRunArtifact(_b.addExecutable(.{
.name = "python_bindings",
.root_source_file = _b.path("src/clients/python/bindings.zig"),
.target = _b.graph.host,
})).captureStdOut(),
.step = std.Build.Step.init(.{ // Initialize a build step.
.id = .custom,
.name = _b.fmt("generate {s}", .{std.fs.path.basename("./src/clients/python/src/bindings.py")}),
.owner = _b,
.makeFn = make_python, // This could ofcourse be more elegant, e.g. have a struct for all generated code with member functions...
}),
};
build_step.source.addStepDependencies(&build_step.step);
return build_step;
}
};
Creating our build query
const bindings = PythonBuildStep.init(
b,
);
steps.dependOn(&bindings.step);
}
Python bindings
Creating python bindings zig in a separate file, src/clients/python/bindings.zig
, props to tigerbeetle clients
const std = @import("std");
const root = @import("root");
const Buffer = struct {
inner: std.ArrayList(u8),
pub fn init(allocator: std.mem.Allocator) Buffer {
return .{
.inner = std.ArrayList(u8).init(allocator),
};
}
pub fn write(self: *Buffer, comptime format: []const u8, args: anytype) void {
self.inner.writer().print(format, args) catch unreachable;
}
};
pub fn main() !void {
@setEvalBranchQuota(100_000);
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const alloc = arena.allocator();
var buffer = Buffer.init(alloc);
buffer.write(
\\##########################################################
\\## !!! WARNING, DO NOT EDIT THIS FILE !!! ##
\\## Auto-generated file python bindings. ##
\\##########################################################
\\from .lib import lib # Amazing naming creativity..
\\zig_add = lib.zig_add
, .{});
try std.io.getStdOut().writeAll(buffer.inner.items);
}
The @setEvalBranchQuota
changes the maximum number of backwards branches that compile-time code execution can use before giving up and making a compile error.
It’s not 100% necessary in this example, however it becomes relevant in case we’re looking to expand our API.
Python Library Loader
from ctypes import CDLL from pathlib import Path def _load_lib(): """ Loads the library, assumes the host machine platform. """ path = Path(__file__).parent.parent.parent / "lib" / "x86_64-linux-gnu.2.27/libadd.so" return CDLL(str(path)) lib = _load_lib()
We can now load and expose our library to Python.
Python example usage
from lib import lib
a = 1
b = 2
for i in range(10):
print(f"Calling add ({a*i}+{b*i}) from python {lib.zig_add(a*i,b*i)}")
Build and run:
zig build clients:python
python3 src/clients/python/src/example.py
Rust Integration with Static Linking
"./src/clients/rust/src/lib.rs"
Rust integration uses static linking for self-contained, high-performance executables.
fn build_rust_client(b: *std.Build, steps: *std.Build.Step ) void {
Recreating the buildstep struct for Rust, this could be made generic as the content doesn’t change too much. However, I’m under the impression that it would introduce additional complexity and make it more difficult to understand. Thus, for the sake of this guide, we’ll keep them separated.
const RustBuildStep = struct {
source: std.Build.LazyPath,
step: std.Build.Step,
// Of course this could become more generic..
fn make_rust(step: *std.Build.Step, prog_node: std.Build.Step.MakeOptions) anyerror!void {
_ = prog_node; // Not needed in this example..
const _b = step.owner;
const py: *@This() = @fieldParentPtr("step", step);
const source_path = py.source.getPath2(_b, step);
const p = try std.fs.Dir.updateFile(
_b.build_root.handle,
source_path,
_b.build_root.handle,
"./src/clients/rust/src/lib.rs",
.{},);
step.result_cached = p == .fresh;
}
pub fn init(_b: *std.Build) *@This() {
const build_step = _b.allocator.create(@This()) catch @panic("Out of memory!");
build_step.* = .{
.source = _b.addRunArtifact(_b.addExecutable(.{
.name = "rust_bindings",
.root_source_file = _b.path("src/clients/rust/bindings.zig"),
.target = _b.graph.host,
})).captureStdOut(),
.step = std.Build.Step.init(.{ // Initialize a build step.
.id = .custom,
.name = _b.fmt("Generate rust : {s}", .{std.fs.path.basename("./src/clients/rust/src/lib.rs")}),
.owner = _b,
.makeFn = make_rust, // This could of course be more elegant, e.g. have a struct for all generated code with member functions...
}),
};
build_step.source.addStepDependencies(&build_step.step);
return build_step;
}
};
const bindings = RustBuildStep.init(
b,
);
steps.dependOn(&bindings.step);
}
Rust bindings generator
Creating rust bindings zig in a separate file, src/clients/rust/bindings.zig
, props to tigerbeetle clients
const std = @import("std");
const root = @import("root");
const Buffer = struct {
inner: std.ArrayList(u8),
pub fn init(allocator: std.mem.Allocator) Buffer {
return .{
.inner = std.ArrayList(u8).init(allocator),
};
}
pub fn write(self: *Buffer, comptime format: []const u8, args: anytype) void {
self.inner.writer().print(format, args) catch unreachable;
}
};
pub fn main() !void {
@setEvalBranchQuota(100_000);
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const alloc = arena.allocator();
var buffer = Buffer.init(alloc);
buffer.write(
\\// !!! WARNING, DO NOT EDIT THIS FILE !!!
\\// Auto-generated file python bindings.
\\pub mod zig {{
\\ extern "C" {{
\\ pub fn zig_add(a: i32, b: i32) -> i32;
\\ }}
\\}}
, .{});
try std.io.getStdOut().writeAll(buffer.inner.items);
}
Rust example usage
use example::zig::zig_add as add;
fn main() {
println!("Example!");
for n in 1..101 {
unsafe {
println!("Using zig add: {:?}", add(1*n, 2*n));
}
}
}
Cargo
Let’s add a cargo file for easier testing.
[package]
name = "example"
version = "0.1.0"
edition = "2021"
[dependencies]
zig build clients:rust
cd src/clients/rust
RUSTFLAGS="-L ../lib/x86_64-linux-gnu.2.27 -l static=add" cargo run
Build function
pub fn build(b: *std.Build) !void {
The main build function orchestrates the entire compilation process, managing dependencies and configuring different build targets:
Steps
This is not too exciting, provide clear entry points for different build targets.
- Build library
- Build Rust client
- Build Python client
This organization makes it easy for developers to build only what they need and understand what each build target produces.
const steps = .{
.build_lib = b.step("lib", "Builds the library."),
.rust_client = b.step("clients:rust", "Builds Rust Client with lib"),
.python_client = b.step("clients:python", "Builds Python Client with lib"),
};
Optimizations
Even though we don’t necessarily need this in the example, let’s optimize for release.
const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSafe });
The ReleaseSafe
optimization mode provides performance optimizations while retaining safety checks.
Building our library and clients
build_lib(b, steps.build_lib, .{
.optimize = optimize,
});
This demonstrates how our modular build functions integrate with the main build process and is accessible with our build:lib
option. The unused variable assignment shows how you might reference the library for additional processing.
zig build lib
Now to build a specific language client with the library
// E.g. python client
build_python_client(b, steps.python_client);
And of course the same goes for building the Rust client
// E.g. rust client
build_rust_client(b, steps.rust_client);
Close build function
}
Usage
Usage: zig build [steps] [options]
Steps:
install (default) Copy build artifacts to prefix path
uninstall Remove build artifacts from prefix path
lib Builds the library.
clients:rust Builds Rust Client with lib
clients:python Builds Python Client with lib
Conclusion
Zig’s excellent C interoperability and cross-compilation capabilities make it an outstanding choice for creating cross-language libraries. Even though it’s not always applicable and might introduce complexity to your software.
The combination of:
- Zero-overhead C ABI compatibility
- Powerful build system automation
- Memory safety without runtime costs
- Static and dynamic linking flexibility
Makes Zig an attractive alternative for building libraries that need to work across multiple programming languages.