Packaging and Modules
Zig has no module keyword, no package declaration at the top of a file, and no import paths derived from a directory convention. Instead, every .zig file is a module, and you import it explicitly by path. The result is a system that is simple to reason about: a file's public interface is exactly the set of declarations you mark pub, and the dependency graph of your project is fully described in two files — build.zig and build.zig.zon.
This lesson covers how Zig's module system works from the ground up: how @import resolves files, how to structure a multi-file project, how to expose and consume a public API, and how to declare and fetch external packages using the official package manager.
Files Are Modules
In Zig, there is no special syntax to declare a module. Every .zig file is implicitly a struct whose fields are the top-level declarations in that file. When you write @import("std"), you receive a value of type type — a struct containing everything the standard library exposes.
const std = @import("std");
// @import returns a type (a struct). You can inspect it at comptime.
pub fn main(init: std.process.Init) void {
const io = init.io;
// std is a struct — you access its namespaces like fields.
// std.Io.File.stdout() gives the stdout handle; wrap it in a buffered writer.
var buffer: [1024]u8 = undefined;
var file_writer = std.Io.File.stdout().writer(io, &buffer);
const stdout = &file_writer.interface;
// std.math, std.mem, std.fmt are all nested structs (modules)
const max_u32 = std.math.maxInt(u32);
const pi = std.math.pi;
stdout.print("max u32: {}\n", .{max_u32}) catch {};
stdout.print("pi: {d:.5}\n", .{pi}) catch {};
// You can alias namespaces to shorten repeated access
const mem = std.mem;
const haystack = "hello, zig modules";
const found = mem.indexOf(u8, haystack, "zig") != null;
stdout.print("found 'zig': {}\n", .{found}) catch {};
stdout.flush() catch {};
}
The @import builtin accepts either a string literal for a named package (like "std") or a relative file path (like "./geometry.zig"). Named packages are registered through build.zig; file paths are resolved relative to the importing file.
Controlling Visibility with pub
By default, every declaration in a Zig file is private to that file. A function, constant, struct, or type alias is only visible to the module that defines it unless you prefix it with pub. This is the entire visibility system — there is no protected, internal, or package-private.
const std = @import("std");
// Simulating a module's public API inline.
// In a real project, these would live in a separate file
// and be imported with @import("./math_utils.zig").
// Private helper — not accessible to importers
fn clampToRange(value: f64, min: f64, max: f64) f64 {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Public API — importers can call these
pub fn lerp(a: f64, b: f64, t: f64) f64 {
const clamped = clampToRange(t, 0.0, 1.0);
return a + (b - a) * clamped;
}
pub fn inverseLerp(a: f64, b: f64, value: f64) f64 {
if (b == a) return 0.0;
return clampToRange((value - a) / (b - a), 0.0, 1.0);
}
pub const Vec2 = struct {
x: f64,
y: f64,
pub fn length(self: Vec2) f64 {
return std.math.sqrt(self.x * self.x + self.y * self.y);
}
// Private method — only accessible within this struct's methods
fn lengthSquared(self: Vec2) f64 {
return self.x * self.x + self.y * self.y;
}
pub fn normalized(self: Vec2) Vec2 {
const len = self.length();
if (len == 0.0) return self;
return .{ .x = self.x / len, .y = self.y / len };
}
};
pub fn main() void {
const start = Vec2{ .x = 0.0, .y = 0.0 };
const end = Vec2{ .x = 3.0, .y = 4.0 };
std.debug.print("end length: {d:.1}\n", .{end.length()});
std.debug.print("lerp at 0.5: {d:.1}\n", .{lerp(0.0, 10.0, 0.5)});
std.debug.print("lerp clamped: {d:.1}\n", .{lerp(0.0, 10.0, 1.8)});
const mid = Vec2{
.x = lerp(start.x, end.x, 0.5),
.y = lerp(start.y, end.y, 0.5),
};
std.debug.print("midpoint: ({d:.1}, {d:.1})\n", .{ mid.x, mid.y });
_ = end.normalized();
}
A useful pattern is re-exporting declarations from within a module. If your top-level main.zig imports a submodule and you want callers to access it through a single import, assign the imported module to a pub const:
const std = @import("std");
// Imagine this is the root of a library.
// It re-exports selected pieces under clean names.
// In reality: pub const geometry = @import("./geometry.zig");
// Here we define a namespace inline to demonstrate the pattern.
pub const geometry = struct {
pub const Point = struct { x: f64, y: f64 };
pub fn distance(a: Point, b: Point) f64 {
const dx = b.x - a.x;
const dy = b.y - a.y;
return std.math.sqrt(dx * dx + dy * dy);
}
};
pub const strings = struct {
pub fn trim(s: []const u8) []const u8 {
return std.mem.trim(u8, s, " \t\n\r");
}
pub fn startsWith(s: []const u8, prefix: []const u8) bool {
return std.mem.startsWith(u8, s, prefix);
}
};
pub fn main() void {
// Callers use: const geo = mylib.geometry;
const a = geometry.Point{ .x = 1.0, .y = 2.0 };
const b = geometry.Point{ .x = 4.0, .y = 6.0 };
std.debug.print("distance: {d:.2}\n", .{geometry.distance(a, b)});
const raw = " hello, zig ";
std.debug.print("trimmed: '{s}'\n", .{strings.trim(raw)});
std.debug.print("starts with 'hello': {}\n", .{strings.startsWith(strings.trim(raw), "hello")});
}
Project Structure and build.zig
A typical multi-file Zig project looks like this:
myproject/
├── build.zig
├── build.zig.zon
└── src/
├── main.zig
├── http.zig
└── config.zig
In build.zig, you register named modules so they can be imported by name rather than by path. This is how "std" works — it is a named package registered by the build system. You can do the same for your own modules:
// build.zig (not runnable in playground — illustrative)
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Create a named module from a source file
const config_mod = b.addModule("config", .{
.root_source_file = b.path("src/config.zig"),
});
const exe = b.addExecutable(.{
.name = "myproject",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Make the module available to the executable
exe.root_module.addImport("config", config_mod);
b.installArtifact(exe);
}
Once registered, src/main.zig can write const config = @import("config"); and it resolves to src/config.zig. This decouples import paths from the filesystem layout.
The Package Manifest: build.zig.zon
Zig's package manager is built into the toolchain. Dependencies are declared in build.zig.zon — a Zig Object Notation file that describes your package's name, version, and external dependencies:
// build.zig.zon
.{
.name = "myproject",
.version = "0.1.0",
.dependencies = .{
.zap = .{
.url = "https://github.com/zigzap/zap/archive/refs/tags/v0.9.0.tar.gz",
.hash = "1220...",
},
},
.paths = .{""},
}
The .hash field is a content hash that zig fetch computes and verifies. Once a dependency is listed, you consume it in build.zig with b.dependency() and attach it to your build target. This two-file design keeps the dependency graph auditable — you can read exactly what is fetched and from where.
Try It Yourself
This exercise simulates a small library with a clean public API. Explore how the pub keyword controls what is accessible, and how namespacing avoids name collisions:
const std = @import("std");
// --- Simulated "config" module ---
const config = struct {
pub const AppConfig = struct {
host: []const u8,
port: u16,
max_connections: u32,
debug: bool,
};
pub const default: AppConfig = .{
.host = "127.0.0.1",
.port = 8080,
.max_connections = 100,
.debug = false,
};
pub fn validate(cfg: AppConfig) !void {
if (cfg.port == 0) return error.InvalidPort;
if (cfg.max_connections == 0) return error.InvalidConnections;
}
};
// --- Simulated "logger" module ---
const logger = struct {
const Level = enum { debug, info, warn, err };
// Private — callers cannot access this directly
fn levelLabel(level: Level) []const u8 {
return switch (level) {
.debug => "DEBUG",
.info => "INFO ",
.warn => "WARN ",
.err => "ERROR",
};
}
pub fn log(level: Level, comptime fmt: []const u8, args: anytype) void {
std.debug.print("[{s}] " ++ fmt ++ "\n", .{levelLabel(level)} ++ args);
}
pub fn info(comptime fmt: []const u8, args: anytype) void {
log(.info, fmt, args);
}
pub fn warn(comptime fmt: []const u8, args: anytype) void {
log(.warn, fmt, args);
}
};
pub fn main() !void {
// Use the config module
var cfg = config.default;
cfg.host = "0.0.0.0";
cfg.port = 9090;
cfg.debug = true;
try config.validate(cfg);
logger.info("Server starting on {s}:{}", .{ cfg.host, cfg.port });
logger.info("Max connections: {}", .{cfg.max_connections});
if (cfg.debug) {
logger.warn("Debug mode is enabled", .{});
}
// Demonstrate error from validation
var bad = config.default;
bad.port = 0;
config.validate(bad) catch |err| {
logger.warn("Config invalid: {}", .{err});
};
logger.info("Startup complete", .{});
}
Try modifying the exercise:
- Add a
pub const version: []const u8 = "1.0.0"to the config module and print it at startup - Add a
.errlog call inside the catch block usinglogger.log(.err, ...)instead oflogger.warn - Add a new field to
AppConfigand updatedefaultandvalidateaccordingly
Key Takeaways
- Every
.zigfile is implicitly a module (a struct).@importreturns that struct as atypevalue. @importaccepts either a string name for a registered package or a relative file path like"./utils.zig".- All top-level declarations are private by default. Only
pubdeclarations are accessible to importers. - Structs can have
pubmethods alongside private helpers — privacy applies at method level too. - Named modules are registered in
build.zigusingb.addModule()and attached withaddImport(). - External dependencies are declared in
build.zig.zonwith a URL and content hash, and fetched withzig fetch. - Re-exporting submodules as
pub const name = @import("./name.zig");gives callers a single entry point. - The entire dependency graph is auditable from two files:
build.zig(how to build) andbuild.zig.zon(what to fetch).
Pro Tip: Keep your library's public surface small and deliberate. Start every declaration as private and only add
pubwhen you have a clear reason for a caller to need it. Unexported internals can change freely without breaking consumers — public API is a contract you must maintain. A module with tenpubdeclarations and thirty private helpers is easier to evolve than one where everything is exposed.
Course Complete!
Congratulations — you've completed the Learning Zig curriculum! You now have a solid foundation in Zig's approach to systems programming.
What to do next:
- Build a project that combines what you've learned — a CLI tool or a small system utility is a great start
- Explore the Zig Playground to experiment further
- Check out the Zig Language Reference for deeper coverage
Course complete!
You've reached the end of the curriculum. Keep practicing to sharpen what you've learned.