Zig Structs
A struct groups related data into a single named type. Instead of tracking a person's name, age, and email as three separate variables, you define a Person struct that holds all three together. Structs let you model real-world entities clearly and pass them around your program as a single unit.
Defining a Struct
const Point = struct {
x: f32,
y: f32,
};
Point struct layout in memory:
+-------+-------+
| x | y |
| (f32) | (f32) |
+-------+-------+
|-- 4B --|-- 4B--|
Total: 8 bytes
Creating Struct Instances
const origin = Point{ .x = 0.0, .y = 0.0 };
const peak = Point{ .x = 3.0, .y = 4.0 };
Each field name is prefixed with a dot when initializing. Zig requires you to provide every field — leaving one out causes a compile error unless you define default values.
Default Field Values
const Config = struct {
width: u32 = 800,
height: u32 = 600,
fullscreen: bool = false,
};
const default_cfg = Config{}; // uses all defaults
const custom_cfg = Config{ .width = 1920, .height = 1080 }; // fullscreen stays false
Accessing Fields
const player = Point{ .x = 5.0, .y = 12.0 };
std.debug.print("x={d}, y={d}\n", .{player.x, player.y});
Use dot notation to access any field. Mutating a field requires the struct to be a var:
var score_board = struct { hits: u32, misses: u32 }{ .hits = 0, .misses = 0 };
score_board.hits += 1;
Struct Methods
Zig structs can have functions defined inside them. These functions act as methods when called with the dot notation on an instance:
const Rectangle = struct {
width: f32,
height: f32,
fn area(self: Rectangle) f32 {
return self.width * self.height;
}
fn perimeter(self: Rectangle) f32 {
return 2 * (self.width + self.height);
}
};
const room = Rectangle{ .width = 5.0, .height = 3.0 };
std.debug.print("Area: {d}\n", .{room.area()}); // 15
std.debug.print("Perimeter: {d}\n", .{room.perimeter()}); // 16
Rectangle{ width=5, height=3 }
|
.area()
|
5 × 3 = 15
The first parameter self receives the struct instance the method is called on. You can name it anything, but self is the convention.
Mutating Methods
A method that changes fields requires a pointer to the struct as its receiver:
const Counter = struct {
value: u32 = 0,
fn increment(self: *Counter) void {
self.value += 1;
}
fn reset(self: *Counter) void {
self.value = 0;
}
};
var c = Counter{};
c.increment();
c.increment();
c.increment();
std.debug.print("Count: {d}\n", .{c.value}); // 3
c.reset();
std.debug.print("Count: {d}\n", .{c.value}); // 0
c.value = 0 c.increment() → c.value = 1 c.increment() → c.value = 2 c.increment() → c.value = 3 c.reset() → c.value = 0
The *Counter parameter is a pointer, meaning the method modifies the actual struct, not a copy. Without the pointer, any changes inside the method would vanish when the method returns.
Nested Structs
const Address = struct {
city: []const u8,
pincode: u32,
};
const Employee = struct {
name: []const u8,
age: u8,
address: Address,
};
const emp = Employee{
.name = "Meera",
.age = 30,
.address = Address{ .city = "Pune", .pincode = 411001 },
};
std.debug.print("{s} lives in {s}\n", .{emp.name, emp.address.city});
Employee
├── name: "Meera"
├── age: 30
└── address
├── city: "Pune"
└── pincode: 411001
Packed Structs
By default Zig may add padding bytes between fields to align them for the CPU. A packed struct removes all padding, placing fields side by side with no gaps:
const Flags = packed struct {
is_active: bool, // 1 bit
is_admin: bool, // 1 bit
is_premium: bool, // 1 bit
_padding: u5, // 5 bits to fill the byte
};
// Total: exactly 1 byte
Normal struct bool: 1 byte per bool (compiler pads for alignment)
Packed struct bool: 1 bit per bool (no padding between fields)
Flags (packed):
Bit: [ 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 ]
[ padding ] [prem][admn][actv]
Packed structs are essential for network protocols, hardware registers, and file formats where every bit position is specified precisely.
Extern Structs
When interfacing with C code, use extern struct to guarantee the same memory layout that C would produce:
const CPoint = extern struct {
x: i32,
y: i32,
};
Practical Example: Bank Account
const std = @import("std");
const Account = struct {
owner: []const u8,
balance: f64,
fn deposit(self: *Account, amount: f64) void {
self.balance += amount;
std.debug.print("Deposited {d:.2}. Balance: {d:.2}\n",
.{amount, self.balance});
}
fn withdraw(self: *Account, amount: f64) bool {
if (amount > self.balance) {
std.debug.print("Insufficient funds.\n", .{});
return false;
}
self.balance -= amount;
std.debug.print("Withdrawn {d:.2}. Balance: {d:.2}\n",
.{amount, self.balance});
return true;
}
};
pub fn main() void {
var acc = Account{ .owner = "Deepak", .balance = 1000.0 };
acc.deposit(500.0);
_ = acc.withdraw(200.0);
_ = acc.withdraw(2000.0);
}
Output:
Deposited 500.00. Balance: 1500.00 Withdrawn 200.00. Balance: 1300.00 Insufficient funds.
