Strings, Formatting & Files
[]const u8, and functions that take strings can also take byte buffers. Formatting is a library feature, and reading a file is an allocation decision — all three facts follow from the no-hidden-machinery philosophy.
Strings Are Byte Slices
A string literal is a compile-time array of bytes with a sentinel null terminator; it coerces to []const u8 where needed. That coercion is free and invisible — but the type you see in signatures is unambiguously a slice.
[]const u8
const greeting: []const u8 = "hello"; // a slice over 5 bytes
const len = greeting.len; // 5 — the slice knows its length
const byte = greeting[0]; // h — plain byte indexing
Unicode and UTF-8
Zig assumes UTF-8 in practice but treats text as bytes. For decoding codepoints, std.unicode provides utf8Decode and friends; splitting and searching live in std.mem and std.mem.splitScalar.
const text: []const u8 = "snow-☃";
var it = std.mem.splitScalar(u8, text, -);
while (it.next()) |part| std.debug.print("{s}\n", .{part});
Formatting
Format strings are type-checked: {s} for slices, {d} for integers, {x} for hex, {f} for floats, {any} for anything printable. A mismatch is a compile error, not a runtime surprise.
std.debug.print
const name: []const u8 = "Zig";
const year: u32 = 2026;
std.debug.print("{s} in {d} — hex {x}\n", .{ name, year, 255 });
// Zig in 2026 — hex ff
allocPrint
When the formatted text must outlive the current scope, allocPrint builds it on the heap with your allocator — the allocation is explicit because the result is a real resource.
const title = try std.fmt.allocPrint(allocator, "Page {d} of {d}", .{ 2, 10 });
defer allocator.free(title);
std.debug.print("{s}\n", .{title});
Parsing and Converting
Text to numbers goes through std.fmt, which returns error unions: malformed input is an error you see, not garbage you debug later. Numbers to text go back through formatting.
parseInt and Float
const value = std.fmt.parseInt(u32, "2048", 10) catch 0; // 2048
const ratio = std.fmt.parseFloat(f64, "3.5") catch 0.0; // 3.5
// "abc" as u32 -> error.InvalidCharacter, caught by the fallback
Coercion Recap
Remember from the types lesson: numeric widening is implicit, narrowing is explicit through @intCast and visible in the source; the runtime double-checks it in safe builds.
File I/O
File access is the perfect passing-through of allocator thinking: reading a whole file asks which allocator will own the buffer. The OS file handle itself is managed with defer file.close().
Read a File
const contents = try std.fs.cwd().readFileAlloc(allocator, "notes.txt", 1 << 20);
defer allocator.free(contents); // the buffer is ours to release
std.debug.print("{s}\n", .{contents});
Write a File
const file = try std.fs.cwd().createFile("out.txt", .{});
defer file.close();
try file.writeAll("written by Zig\n");
Command-Line Arguments
std.process.argsAlloc collects the argument list into freshly allocated memory; the size depends on the runtime, so the allocator goes in — and comes back out with a free.
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
for (args, 0..) |arg, i| std.debug.print("{d}: {s}\n", .{ i, arg });
Figure 1 — strings and files are streams of bytes; Zig keeps every step of the pipeline explicit.
Common Pitfalls
Assuming ASCII
text.len counts bytes, not characters. Indexing UTF-8 text by byte can split a codepoint; use std.unicode when characters, not bytes, are the unit.
Forgetting to Free Buffers
readFileAlloc and argsAlloc both allocate. Pair each with its free (or defer) — the memory lesson habit, applied to text.
Next: Structs, Enums & Unions — modeling data with Zig.