# 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: ```zig 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: ```zig 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](https://zig.guide/language-basics/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: ```zig 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 ``` this happened multiple times in logfire-zig when attributes were queued for batch export - by the time they were serialized, the original strings were gone. ## copy-safe pattern: internal storage if a struct must survive being copied and needs string data, store the data internally: ```zig 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_storage` is a fixed array, not a slice - data is copied with the struct - `_string_len` tracks how much of the buffer is used - `getString()` reconstructs the slice from internal storage - the slice returned by `getString()` points into `self`, so copies get slices into their own storage see: [logfire-zig/attribute.zig](https://tangled.sh/@zzstoatzz.io/logfire-zig/tree/main/src/attribute.zig) ## @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`: ```zig // 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. see: [zat/firehose.zig encodeCommitPayload](https://tangled.sh/@zzstoatzz.io/zat/tree/main/src/internal/firehose.zig) (zat is a library of atproto building blocks for zig) ## 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`: ```zig // 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). see: [zat/car.zig](https://tangled.sh/@zzstoatzz.io/zat/tree/main/src/internal/car.zig) ## 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: don't let it outlive the statement `&.{ a, b }` where `a`/`b` are comptime-known produces a pointer to static memory — safe anywhere. With **runtime** values it's a stack temporary, and the langref does not pin down how long it lives (result-location semantics; temporaries' lifetime is famously underspecified). Passing it straight into a call is fine — the callee consumes it before the statement ends. The trap is **storing** it into something longer-lived: ```zig var stmts: std.ArrayList(Statement) = .empty; if (stale_uri.len > 0) { // pointer to a temporary born inside this if-block… try stmts.append(alloc, .{ .sql = "...", .args = &.{stale_uri} }); } // …dereferenced here, after the block. does it still live? unspecified. try client.batch(stmts.items); ``` An empirical probe (block-scoped `&.{runtime}`, stack stomping, read after the block) happened to pass on 0.16 — which proves nothing; it's the kind of latent bug that appears when frame layout shifts. Defensive rule: when a `Statement`-like struct stores arg pointers for later use, materialize the args as **named locals (or fields of a caller-pinned struct) whose scope provably covers the consumption point**, and reserve any growable storage up front so appended pointers never move (learned on pub-search's `DocWriteBatch`, 0.16). ## 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