structs #
written against zig 0.15.
designing structs that behave correctly when copied.
slices vs arrays in struct fields #
when you assign a struct, zig copies all fields by value. this has different implications depending on field type:
arrays are value types - the data lives inside the struct:
const Foo = struct {
buffer: [64]u8, // 64 bytes stored in the struct
};
var a = Foo{ .buffer = "hello".* ++ .{0} ** 59 };
var b = a; // copies all 64 bytes - b has its own data
slices are fat pointers - they store a pointer and length, not data:
const Bar = struct {
data: []const u8, // 16 bytes: pointer + length
};
var a = Bar{ .data = "hello" };
var b = a; // copies the pointer - b.data points to same memory as a.data
this is documented in zig.guide/slices: "slices can be thought of as many-item pointers with a length... the validity and lifetime of the backing memory is in the hands of the programmer."
the copy problem #
if you build a struct, store a slice pointing to temporary data, then copy that struct - the copy's slice becomes dangling:
fn makeAttribute(temp_string: []const u8) Attribute {
return .{ .value = temp_string }; // slice points to temp_string
}
// later, after temp_string goes out of scope:
const attr = makeAttribute(some_temp);
const copy = attr; // copy.value is now a dangling pointer
the typical trigger is attributes queued for batch export - by the time they are serialized, the original strings are gone.
copy-safe pattern: internal storage #
if a struct must survive being copied and needs string data, store the data internally:
pub const Attribute = struct {
key: []const u8,
value: Value,
_string_storage: [max_len]u8 = undefined,
_string_len: usize = 0,
pub const max_len = 512;
pub const Value = union(enum) {
string, // data in _string_storage[0.._string_len]
int: i64,
float: f64,
bool_val: bool,
};
pub fn getString(self: *const Attribute) ?[]const u8 {
return switch (self.value) {
.string => self._string_storage[0..self._string_len],
else => null,
};
}
fn setString(self: *Attribute, str: []const u8) void {
const len = @min(str.len, max_len);
@memcpy(self._string_storage[0..len], str[0..len]);
self._string_len = len;
self.value = .string;
}
};
key points:
_string_storageis a fixed array, not a slice - data is copied with the struct_string_lentracks how much of the buffer is usedgetString()reconstructs the slice from internal storage- the slice returned by
getString()points intoself, so copies get slices into their own storage
@constCast for inline struct literal slices #
when you need to pass an inline struct literal as a slice parameter, zig may need @constCast because the literal is const:
// this builds an array of MapEntry inline and passes it as a slice
try op_values.append(allocator, .{ .map = @constCast(&[_]cbor.Value.MapEntry{
.{ .key = "action", .value = .{ .text = action_str } },
.{ .key = "path", .value = .{ .text = path } },
}) });
without @constCast, you get a type mismatch — the literal produces *const [N]MapEntry but the field expects []const MapEntry through a mutable pointer. this is safe because the data is embedded in the struct value being appended.
std.math.cast for safe integer narrowing #
prefer std.math.cast over @intCast when narrowing integers from untrusted input. @intCast panics on overflow, std.math.cast returns null:
// dangerous — panics on 32-bit if header_len > maxInt(usize)
const len: usize = @intCast(varint_u64);
// safe — returns an error instead of panicking
const len = std.math.cast(usize, varint_u64) orelse return error.InvalidHeader;
use @intCast for values you've already bounds-checked or that come from trusted internal code. use std.math.cast at system boundaries (parsing wire formats, external input).
when to use this pattern #
use internal storage when:
- structs are queued, batched, or stored for later processing
- structs are copied into collections (ArrayList, hashmap values)
- ownership of string data is unclear or temporary
use slices when:
- struct lifetime is shorter than the data it references
- data is compile-time constant (string literals)
- you explicitly manage the backing memory
&.{...} with runtime values #
&.{ a, b } with comptime-known elements points to static memory and is safe
anywhere. with runtime elements it points to a temporary, and the langref does
not pin down how long a temporary lives. passing it straight into a call is
fine, since the callee is done before the statement ends. storing it is not:
var stmts: std.ArrayList(Statement) = .empty;
if (stale_uri.len > 0) {
// a pointer to a temporary created inside this block...
try stmts.append(alloc, .{ .sql = "...", .args = &.{stale_uri} });
}
// ...dereferenced after it
try client.batch(stmts.items);
a probe of exactly this shape (block-scoped &.{runtime}, stack stomping, a
read after the block) passed on 0.16, which proves nothing: frame layout can
shift under it. when a struct stores argument pointers for later, put the
arguments in named locals, or fields of a caller-pinned struct, whose scope
covers the use, and reserve growable storage up front so appended pointers
never move.
file-as-struct: a field and a top-level const share one namespace #
in a file that is itself a struct (const Client = @This(); with fields at
top level), const http = std.http; and a field http: *std.http.Client
are a "duplicate struct member name" error. import aliases and fields live in
the same scope. write the field's type out (http: *std.http.Client) or
name the alias differently.
sources #
- logfire-zig — src/attribute.zig, batch-export string lifetime bug
- zat — src/internal/firehose.zig, src/internal/car.zig
- pub-search — DocWriteBatch, 0.16
- tpuf — src/Client.zig, field/import name clash, 2026-09