Back to Home

Zig vs C: CLI utility in 1000 lines without errors

Article breaks down migration of CLI log analyzer logz from C (3147 lines) to Zig (1089 lines). Code comparison for parsing arguments/logs, defer, benchmarks. Advantages for system utilities: speed, memory safety, compactness.

Zig replaces 3000 lines of C: real case logz
Advertisement 728x90

Zig for CLI Utilities: Migrating from 3000 Lines of C to 1000 Lines Without Segfaults

CLI tool developers often face C's pitfalls: manual memory management, segfaults, and bloated code. Rewriting the logz log analyzer in Zig reduced its size from 3147 lines to 1089, sped up execution by 33%, and eliminated crashes. A breakdown of the migration, code examples, and benchmarks.

The Real-World Problems with C

The logz log analyzer parses nginx/apache access.log and error.log files, filters by level (DEBUG, INFO, WARN, ERROR), date, and IP, builds top error lists, and renders bar charts in the terminal using unicode.

In C, the code ballooned to 3147 lines:

Google AdInline article slot
  • The process_file function — a 400-line monolith.
  • Segfaults from buffer overflows (256 bytes vs. 300-byte lines).
  • Memory leaks, debugged with Valgrind.
  • A date parser with off-by-one errors.

Error handling via -1/NULL/errno obscured context. The Makefile was often forgotten, and builds were slow without -MMD.

Parsing a simple line like 2026-01-17 ERROR [nginx] 192.168.1.100 500 required 50 lines: strtok, NULL checks, strncpy, freeing buffers.

Zig's Advantages Over C and Rust

Zig retains C's control but adds powerful tools:

Google AdInline article slot
  • defer file.close() — guaranteed cleanup without hidden control flow.
  • !T in signatures — error unions, the compiler enforces handling.
  • Cross-compilation: zig build -Dtarget=x86_64-windows without toolchains.
  • Fast builds for small projects.

Rust's borrow checker is overkill for a CLI tool that reads a file and outputs to stdout.

The logz architecture is a pipeline of 5 modules:

  • Argument parsing.
  • File reading.
  • Line parsing.
  • Filtering.
  • Statistics and output.

In C, everything was mixed in process_file. In Zig, data flows strictly downward.

Google AdInline article slot

Code Comparison: CLI Arguments

C version (~80 lines):

int parse_args(int argc, char **argv, Config *cfg) {
    for (int i = 1; i < argc; i++) {
        if (strcmp(argv[i], "--file") == 0) {
            if (i + 1 >= argc) {
                fprintf(stderr, "error: --file requires a value\n");
                return -1;
            }
            cfg->filename = argv[++i];
        } // similarly for --level, --top-ip
    }
    return 0;
}

Zig version (~40 lines):

const Config = struct {
    filename: []const u8 = "",
    level: ?LogLevel = null,
    top_ip: u32 = 10,
    from_date: ?i64 = null,
};

fn parseArgs(allocator: std.mem.Allocator) !Config {
    var args = try std.process.argsWithAllocator(allocator);
    defer args.deinit();

    var cfg = Config{};
    _ = args.next();

    while (args.next()) |arg| {
        if (std.mem.eql(u8, arg, "--file")) {
            cfg.filename = args.next() orelse return error.MissingValue;
        } else if (std.mem.eql(u8, arg, "--level")) {
            const lvl = args.next() orelse return error.MissingValue;
            cfg.level = try LogLevel.parse(lvl);
        } // similarly
    }
    return cfg;
}

orelse return error.MissingValue replaces 3 checks. The error is typed.

Parsing Logs: From malloc to Error Unions

C (~65 lines):

LogEntry *parse_line(char *line) {
    LogEntry *entry = malloc(sizeof(LogEntry));
    if (!entry) return NULL;

    char *saveptr;
    char *token = strtok_r(line, " ", &saveptr);
    if (!token) { free(entry); return NULL; }

    // parsing timestamp, level, 40 lines...
    return entry; // free is the caller's responsibility
}

Zig (~35 lines):

const LogEntry = struct {
    timestamp: i64,
    level: LogLevel,
    source: []const u8,
    ip: []const u8,
    status: u16,
};

fn parseLine(line: []const u8) !LogEntry {
    var iter = std.mem.splitScalar(u8, line, ' ');

    const ts_str  = iter.next() orelse return error.InvalidFormat;
    const timestamp = try parseTimestamp(ts_str);

    const level_str = iter.next() orelse return error.InvalidFormat;
    const level = try LogLevel.parse(level_str);

    const source = iter.next() orelse return error.InvalidFormat;
    const ip     = iter.next() orelse return error.InvalidFormat;

    _ = iter.next(); // HTTP method
    const status_str = iter.next() orelse return error.InvalidFormat;
    const status = try std.fmt.parseInt(u16, status_str, 10);

    return LogEntry{...};
}

No malloc/free/strtok. The !LogEntry signature guarantees error handling.

Working with Files: defer in Action

pub fn processFile(
    allocator: std.mem.Allocator,
    path: []const u8,
    cfg: Config,
) !Stats {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    var buffered = std.io.bufferedReader(file.reader());
    var reader = buffered.reader();

    var stats = Stats.init(allocator);
    defer stats.deinit();

    var line_buf: [8192]u8 = undefined;

    while (try reader.readUntilDelimiterOrEof(&line_buf, '\n')) |line| {
        const entry = parseLine(line) catch continue;
        if (cfg.matches(entry)) {
            try stats.add(entry);
        }
    }
    return stats;
}

defer closes resources on any exit. In C, fclose was required before every return.

Benchmarks and Metrics

Test: 5 million lines, 800 MB.

| Metric | C | Zig |

|------------------|--------|--------|

| Time (sec) | 4.2 | 2.8 |

| Binary (KB) | 47 | 32 |

| Memory (MB) | 124 | 89 |

| Build (sec) | 3.1 | 1.4 |

| Lines of Code | 3147 | 1089 |

Speedup due to bufferedReader vs fgets. Code is 3 times more compact.

Common Migration Pitfalls

  • Allocators: Using page_allocator everywhere → ArenaAllocator for CLI (deinit at the end).
  • Slices: []const u8 without \0, for C-API — .ptr + null-termination.
  • Compiler: Long error messages, read the first line.
  • Volatility: Zig 0.13, examples from 2022–2023 may not compile — check docs.ziglang.org.

Key Takeaways

  • Zig reduces CLI code by 3x thanks to error unions and defer.
  • No segfaults: memory safety and no hidden allocations.
  • 30–40% faster than C with proper buffering.
  • Ideal for utilities, embedded systems, cross-compilation.
  • For large projects with an ecosystem — Rust is preferable.

— Editorial Team

Advertisement 728x90

Read Next