migrating to zig 0.15: the roadblocks nobody warned you about
24 Oct 2025i built a command-line security tool to analyze shell scripts before executing them (preventing those dangerous curl | bash situations). starting with zig 0.15 meant hitting every breaking change head-on. hereâs what actually broke and how to fix it.
the project: safe-curl
the tool analyzes shell scripts for malicious patterns:
- recursive file deletion (
rm -rf /) - code obfuscation (base64 decoding, eval)
- privilege escalation (sudo)
- remote code execution
zig seemed perfect for a simple CLI tool with minimal dependencies. then i hit the 0.15 changes.
source: safe-curl on github
roadblock 1: arraylist requires allocator everywhere
the change: zig 0.15 replaced std.ArrayList with std.array_list.Managed as the default. the âmanagedâ variant now requires passing an allocator to every method call.
official reasoning: zig 0.15 release notes explain: âHaving an extra field is more complicated than not having an extra field.â the unmanaged variant is now the primary implementation, with the managed version as a wrapper.
what broke
my initial attempt looked like this:
const Finding = struct {
severity: Severity,
message: []const u8,
line_num: usize,
};
const AnalysisResult = struct {
findings: std.ArrayList(Finding),
fn init(allocator: std.mem.Allocator) AnalysisResult {
return .{
.findings = std.ArrayList(Finding).init(allocator),
};
}
fn addFinding(self: *AnalysisResult, finding: Finding) !void {
try self.findings.append(finding); // Error: missing allocator
}
};
error message:
error: expected 2 arguments, found 1
the fix
you have two options in 0.15:
option 1: store the allocator and pass it to methods
const AnalysisResult = struct {
findings: std.ArrayList(Finding),
allocator: std.mem.Allocator, // Store allocator
fn init(allocator: std.mem.Allocator) AnalysisResult {
return .{
.findings = std.ArrayList(Finding).init(allocator),
.allocator = allocator,
};
}
fn addFinding(self: *AnalysisResult, finding: Finding) !void {
try self.findings.append(self.allocator, finding); // Pass allocator
}
fn deinit(self: *AnalysisResult) void {
self.findings.deinit(self.allocator); // Pass here too
}
};
option 2: use the unmanaged variant
const AnalysisResult = struct {
findings: std.ArrayListUnmanaged(Finding),
fn init() AnalysisResult {
return .{
.findings = .{}, // Empty initialization
};
}
fn addFinding(self: *AnalysisResult, allocator: std.mem.Allocator, finding: Finding) !void {
try self.findings.append(allocator, finding);
}
fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void {
self.findings.deinit(allocator);
}
};
i went with option 1 for familiarity, but option 2 is more idiomatic in 0.15.
why this change?
the zig team explains that storing the allocator in the struct adds complexity. with the unmanaged variant as default, you get:
- simpler method signatures
- static initialization support (
.{}) - explicit allocator lifetime management
trade-off: you pass the allocator everywhere, but your data structures are cleaner.
roadblock 2: empty struct initialization .{}
the pattern: zig 0.15 introduced a shorthand for empty struct initialization.
what this enables
before, initializing an empty arraylist required:
var findings = std.ArrayList(Finding).init(allocator);
now you can use struct field inference:
const AnalysisResult = struct {
findings: std.ArrayList(Finding),
fn init(allocator: std.mem.Allocator) AnalysisResult {
return .{
.findings = .{}, // Compiler infers std.ArrayList(Finding).init(allocator)
.allocator = allocator,
};
}
};
this syntax confused me initially because .{} looks like an empty struct literal, but it actually calls the appropriate init function based on the field type.
when it works: field type is clear from context when it breaks: compiler canât infer the type
var list: std.ArrayList(Item) = .{}; // Works
var list = .{}; // Error: cannot infer type
roadblock 3: process api breaking changes
the change: std.process.Child.run() return type changed significantly.
what broke
fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 {
const result = try std.process.Child.run(.{
.allocator = allocator,
.argv = &[_][]const u8{ "curl", "-fsSL", url },
});
defer allocator.free(result.stderr);
// This line broke
if (result.term.Exited != 0) {
allocator.free(result.stdout);
return error.HttpRequestFailed;
}
return result.stdout;
}
error: no field named 'Exited' in union 'std.process.Child.Term'
the fix
the term field changed from having an Exited field to being a tagged union:
fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 {
const result = try std.process.Child.run(.{
.allocator = allocator,
.argv = &[_][]const u8{ "curl", "-fsSL", url },
});
defer allocator.free(result.stderr);
// Check the union variant properly
switch (result.term) {
.Exited => |code| {
if (code != 0) {
allocator.free(result.stdout);
return error.HttpRequestFailed;
}
},
else => {
allocator.free(result.stdout);
return error.ProcessFailed;
},
}
return result.stdout;
}
this is more explicit about handling different termination types (signal, unknown, etc.).
roadblock 4: http client instability
the problem: zigâs std.http.Client is still evolving rapidly between versions.
what i tried
fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 {
var client = std.http.Client{ .allocator = allocator };
defer client.deinit();
const uri = try std.Uri.parse(url);
var server_header_buffer: [16384]u8 = undefined;
var req = try client.open(.GET, uri, .{
.server_header_buffer = &server_header_buffer,
});
defer req.deinit();
try req.send();
try req.wait();
// ... read response
}
errors:
- API mismatches between documentation and actual implementation
- buffer size requirements unclear
- response reading patterns changed between minor versions
the workaround
the zig 0.15 release notes acknowledge: âHTTP client/server completely reworked to depend only on I/O streams, not networking directly.â
this instability meant falling back to shelling out:
fn fetchFromUrl(allocator: std.mem.Allocator, url: []const u8) ![]const u8 {
// Use curl as a fallback since the Zig HTTP client API is too unstable
const result = try std.process.Child.run(.{
.allocator = allocator,
.argv = &[_][]const u8{ "curl", "-fsSL", url },
});
defer allocator.free(result.stderr);
switch (result.term) {
.Exited => |code| {
if (code != 0) {
allocator.free(result.stdout);
return error.HttpRequestFailed;
}
},
else => {
allocator.free(result.stdout);
return error.ProcessFailed;
},
}
return result.stdout;
}
not ideal for a âzero dependencyâ tool, but pragmatic given the api churn.
roadblock 5: reader/writer overhaul (âwritergateâ)
the change: zig 0.15 completely redesigned std.io.Reader and std.io.Writer interfaces.
from the release notes: âA complete overhaul of the standard library Reader and Writer interfaces⌠designed to usher in a new era of performance and drastically reduce unnecessary copies.â
what changed
before (0.14):
const stdout = std.io.getStdOut().writer();
try stdout.print("Hello {s}\n", .{"world"});
after (0.15):
const stdout = std.fs.File.stdout();
try stdout.writeAll("Hello world\n");
// For formatted output, you need a buffer
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer = stdout.writer(&stdout_buffer);
try stdout_writer.print("Hello {s}\n", .{"world"});
why this matters
the old api wrapped streams in multiple layers of abstraction. the new api:
- builds buffering directly into reader/writer
- supports zero-copy operations (file-to-file transfers)
- provides precise error sets
- enables vector i/o and advanced operations
but it requires more explicit buffer management.
my approach
i created a helper function to hide the complexity:
fn printf(allocator: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void {
const stdout = std.fs.File.stdout();
const msg = try std.fmt.allocPrint(allocator, fmt, args);
defer allocator.free(msg);
try stdout.writeAll(msg);
}
this allocates for the formatted string, but keeps the call sites clean:
try printf(allocator, "{s}[{s}]{s} {s}\n", .{
color_code,
severity_name,
Color.NC,
finding.message
});
roadblock 6: undefined behavior rules tightened
the change: zig 0.15 standardizes when undefined is allowed.
from the release notes: âOnly operators which can never trigger Illegal Behavior permit undefined as an operand.â
what this means
// This now errors at compile time
const x: i32 = undefined;
const y = x + 1; // Error: undefined used in arithmetic
// Safe uses of undefined
var buffer: [256]u8 = undefined; // OK: just reserves space
const ptr: *u8 = undefined; // OK: pointers can be undefined
this catches bugs earlier but requires more explicit initialization.
the practical impact
in my code, i couldnât do:
var line_num: usize = undefined;
while (condition) : (line_num += 1) { // Error
// ...
}
had to initialize explicitly:
var line_num: usize = 1;
while (condition) : (line_num += 1) {
// ...
}
the verdict: worth it?
whatâs better in 0.15:
- 5Ă faster debug compilation with x86 backend
- clearer allocator lifetime management
- more explicit, less magic
- better performance fundamentals
what hurts:
- breaking changes everywhere
- documentation lags behind implementation
- http client still unstable
- community examples are all outdated
resources
- zig 0.15.1 release notes - official breaking changes
- std.ArrayList documentation - new allocator patterns
- safe-curl source code - working 0.15 example