Skip to editor content
learningzig.orglesson 25 of 25

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 .err log call inside the catch block using logger.log(.err, ...) instead of logger.warn
  • Add a new field to AppConfig and update default and validate accordingly

Key Takeaways

  • Every .zig file is implicitly a module (a struct). @import returns that struct as a type value.
  • @import accepts 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 pub declarations are accessible to importers.
  • Structs can have pub methods alongside private helpers — privacy applies at method level too.
  • Named modules are registered in build.zig using b.addModule() and attached with addImport().
  • External dependencies are declared in build.zig.zon with a URL and content hash, and fetched with zig 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) and build.zig.zon (what to fetch).

Pro Tip: Keep your library's public surface small and deliberate. Start every declaration as private and only add pub when 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 ten pub declarations 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.