⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2122+3e15e99e6What's newOn an older Zig?

Imports

const std = @import("std");
const expect = std.testing.expect;

// A relative path imports another source file. The result is a struct type
// whose declarations are that file's `pub` declarations.
const shapes = @import("_shapes.zig");

test "use an imported declaration" {
    try expect(shapes.double(21) == 42);
}

test "imported types work like any other" {
    const c = shapes.Circle{ .radius = 2 };
    try expect(c.area() > 12.5 and c.area() < 12.6);
}

test "a file is a struct" {
    // `@This()` inside a file refers to that file's implicit struct type,
    // which is why `@import` can return something you access with `.`.
    try expect(@TypeOf(shapes) == type);
    try expect(shapes.pi > 3.14);
}

test "std is just another import" {
    // `@import("std")` is the same mechanism, resolved by the compiler
    // rather than by path.
    try expect(std.mem.eql(u8, "a", "a"));
}

@import takes a path and returns that file as a struct type:

const shapes = @import("_shapes.zig");
shapes.Circle{ .radius = 2 };

There is no separate module system to learn. A file is a struct, @import evaluates it, and you access its declarations with . like any other struct.

Because the result is a value, the name you bind it to is yours. const s = @import("std") works exactly as well as const std = @import("std"). Importing the same file twice gives you the same type both times, so there is no duplicate-definition problem and no include guard.

pub is the boundary

Only pub declarations are visible to importers. Everything else is private to the file:

pub const pi = 3.14159;   // visible
const secret = 42;        // not

This is the whole visibility system: no private, no header files, no export lists. The unit of privacy is the file. So splitting a type into its own file is also how you give it a private implementation, and merging two files is how you let them see each other’s internals.

A file can be the type

Since a file is a struct, a file that declares fields at the top level is that struct, and importing it gives you the type directly:

// Point.zig
x: f32,
y: f32,

pub fn magnitude(self: @This()) f32 { ... }
const Point = @import("Point.zig");
const p: Point = .{ .x = 3, .y = 4 };

This is the convention behind the capitalised filenames in the standard library. std.Io.Writer lives in Writer.zig, and the file has no wrapper declaration around it. A lowercase filename means a namespace full of declarations; a capitalised one means the file is a single type. Following that convention is the difference between reading the standard library’s layout and being confused by it.

@import("std") is not special

The standard library is imported by the same mechanism; the name is resolved by the compiler rather than by path. Package dependencies declared in build.zig.zon work the same way, imported by their package name.

A name resolves as a module when the build declared one, and as a file path otherwise. So @import("foo") and @import("foo.zig") are different questions: the first asks the build system, the second asks the filesystem, relative to the importing file. Adding a dependency is a change to build.zig and build.zig.zon, never a change to an include path.

Two more names are always available. @import("root") is the file at the root of the compilation, which is how library code reaches a configuration constant the application declared. @import("builtin") is generated by the compiler and describes the build itself: the target, the optimisation mode, the Zig version. Neither is a file you wrote.

Import cycles

Two files may import each other. Zig resolves declarations lazily, so this works. What it will not accept is a real cycle in values: a const in A whose initialiser needs a const in B, whose initialiser needs the one in A.

Types referring to each other is fine, which is what most cycles actually are. A Node in one file can hold a ?*Tree from another while the Tree holds []Node, and it resolves without complaint. The size of a pointer does not depend on what it points at. The cycle is only a problem when a value cannot be computed without itself, and the error names the loop.

Bringing in data, not code

@embedFile("table.bin") reads a file at compile time and gives you a *const [N:0]u8: a pointer to the contents, with a zero byte after the last one. It is the right tool for a lookup table, a small template, or a test fixture. It also removes a whole class of “the data file was not next to the binary” failure. It is the wrong tool for anything large, since the bytes end up in the binary.