Skip to lesson

learningzig.org / intermediate / 25-packaging-and-modules · lesson 25 of 25

TL;DR

Structure Zig projects with modules — use @import for file-based modules, control visibility with pub, and manage packages with build.zig.zon. Free tutorial with examples.

Key concepts

  • zig modules
  • zig packages
  • zig @import
  • build.zig.zon
  • zig project structure
  • zig package manager
  • zig dependency management

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

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.

Course complete!

You've reached the end of the curriculum. Keep practicing to sharpen what you've learned.

Keep practicing