Debug vs Release: What a ‘Hello, World’ Taught Me About Zig, Roc, and the Cost of Debug Symbols

*What started as a simple "hello world" exercise turned into a deep dive into how Zig and Roc actually build binaries — from optimization modes and DWARF debug symbols eating 94% of a binary's size, to a surprising 14x size gap between a tiny Zig executable and its Roc equivalent. Here's everything I learned, one command at a time.*

Detailed Summary / Reference

1. The starting point: zig run vs zig build-exe

  • zig run main.zig — Compiles and runs without creating a named executable in the project folder. Great for quick iteration.
  • zig build-exe main.zig -O ReleaseSmall — compiles only, leaving a persistent binary you can ship, inspect, or run repeatedly.

2. Zig 0.16.0 breaking change: std.io → std.Io

My original main.zig used:

const stdout = std.io.getStdOut().writer();
try stdout.print("Zig Day 01 — hello\n", .{});

This no longer compiles on Zig 0.16.0. The std.io namespace was replaced by a much larger std.Io interface (capital I) as part of a new explicit async I/O model — writing to real stdout now requires setting up an Io context object (e.g. std.Io.Threaded) and passing it around explicitly.

Fix used (Option A — simplest):

const std = @import("std");

pub fn main() void {
    std.debug.print("Zig Day 01 — hello\n", .{});
}

std.debug.print sidesteps the new Io interface entirely — no setup required. Trade-off: it writes to stderr, not stdout (invisible difference in a terminal, but worth knowing). The proper std.Io-based approach is a meatier topic for a future lesson.

3. Optimization modes and binary size

Zig -O modeDescription
Debug (default)No optimization, safety checks on, full debug info
ReleaseSafeOptimized, safety checks retained
ReleaseFastOptimized for speed, safety checks stripped
ReleaseSmallOptimized for smallest binary size, safety checks stripped

4. The real discovery: optimization ≠ stripping debug symbols

Measured across identical source code:

BuildFlagsSize
Debug(default)9.8M
ReleaseFast, symbols kept-O ReleaseFast -fno-strip3.6M
ReleaseFast, default-O ReleaseFast (no strip flag)3.6M
ReleaseFast, stripped-O ReleaseFast -fstrip210K

Key finding: -O ReleaseFast does not strip debug symbols by default — confirmed by main-fast-default and main-fast-debug landing at the exact same size (3.6M). Debug info had to be removed explicitly via -fstrip. That flag alone accounted for ~3.39M, roughly 94% of the binary’s size in that build.

What DWARF debug info actually contains:

  • Line number tables (mapping machine code → source lines)
  • Type information (struct/enum/variable shapes)
  • Name tables (symbol names for debuggers)
  • Call frame info (enables stack traces)

None of it affects runtime behavior — it’s metadata for tools like gdb/lldb, but it can dwarf a tiny program’s actual code size.

5. Zig vs Roc: a cross-language binary size comparison

Zig (ReleaseSmall)Roc (--opt=size)
Commandzig build-exe main.zig -O ReleaseSmallroc build main.roc --opt=size
Binary size7.2K104K

Roc’s binary is larger mainly because it statically links its platform and runtime (effects system, host binary, memory management) into every executable — a deliberate architectural trade-off for a higher-level, effect-based language, versus Zig’s near-zero-runtime systems approach.

Roc/Zig command parallels:

ZigRocPurpose
zig run main.zigroc main.roccompile + run, throwaway
zig build-exe ... -O ReleaseSmallroc build main.roc --opt=sizecompile only, persistent binary

6. Exercise completed

Final main.zig:

const std = @import("std");

pub fn main() void {
    std.debug.print("Zig Day 01 — Give me one step at a time\n", .{});
}

Ran successfully via zig run main.zig — Zig Day 01 exercise fully complete (all 4 steps).

Lessons for Next Time

A short “on deck” list to carry into future posts, so future-you has a trailhead:

  • Finish Roc Day 01 — change the greeting string in roc/day01/main.roc and re-run with roc main.roc (attempted once already; a nano save slipped through, so double-check with Ctrl+O → Enter → Ctrl+X next time).
  • Explore std.Io properly — today we sidestepped Zig’s new explicit I/O interface using std.debug.print. A future post could walk through setting up std.Io.Threaded manually and writing to real stdout, which is the forward-looking pattern in Zig 0.16+.
  • Close the ReleaseSmall vs ReleaseFast gap — we measured ReleaseFast -fstrip at 210K, far above the very first ReleaseSmall build’s 7.2K — but those used two different versions of main.zig, so it’s not a clean comparison. Worth re-running ReleaseSmall -fstrip on the current source to isolate whether the mode or the code path explains the gap.
  • Inspect the Roc binary’s sections — we never ran readelf -S (or Roc’s equivalent) against the 104K Roc binary to see what’s actually inside it (platform code? debug info? both?). Doing so would ground the “Roc statically links its runtime” claim in real data, the same way we did for Zig’s DWARF sections.
  • Start notes/compare.md — the blank template sitting in ~/learn-zig-roc/notes/compare.md is meant to capture personal impressions (“what felt fast,” “what felt explicit,” “try vs Result,” “when I’d pick each”) — a good five-minute wrap-up habit after each paired Zig/Roc day.