Zig-pflag,a golang pflag porting package

Hi everyone,
This repository — zig-pflag ( GitHub - rebornwwp/zig-pflag · GitHub ) — was built with the help of AI, porting Go’s spf13/pflag to Zig.
My motivation is straightforward: I want to see more ready-to-use third-party libraries in the Zig ecosystem. pflag has been battle-tested in the Go community for over a decade — the POSIX/GNU parsing rules, edge cases, and user expectations it handles aren’t something you can cover well starting from scratch. Porting it over means it at least works out of the box, so people who need it don’t stumble on half-baked behavior. From there, we can iterate together and make it better over time.
So far, 16 out of 26 flag types from Go pflag v1.0.9 have been ported, with 110 tests, plus some examples covering different allocator combinations. That said, I’m aware that an API produced this way — porting from another language with AI assistance — likely isn’t idiomatic Zig yet. The code style, design patterns, and memory model almost certainly have room for improvement.
So I’d like to ask: what can be improved in this library? Which parts should be rewritten rather than translated directly from Go? I’m also genuinely curious — how does the community feel about this approach (AI-assisted + porting from another language)? Is this kind of thing welcome? Honest feedback very much appreciated — I won’t take it personally.
(The above was translated from Chinese by AI — but it reflects my genuine thoughts. Please forgive the indirectness.)

bellow is what i lean(AI teach me :rofl:

Zig Memory Ownership Patterns — Lessons from zig-pflag

zig-pflag is a port of Go’s spf13/pflag to Zig 0.16.

This document captures the memory ownership patterns that emerged during development.


1. []const u8: One Type, Three Lifetimes

This is the fundamental problem of string handling in Zig:


const a: []const u8 = "hello"; // ① static read-only (.rodata) — cannot free

const b: []const u8 = &[_]u8{1, 2, 3}; // ② stack — cannot free (invalid after scope)

const c: []const u8 = gpa.dupe(u8, "..."); // ③ heap — must free

a, b, and c have identical type signatures. The compiler cannot help you

distinguish them. This is not a compiler limitation — it’s a deliberate design

choice in Zig: express all strings as one type, rather than introducing a

multi-type system like Rust’s &str / String / Cow<str>.

The consequence: when you write a library that accepts strings, the

[]const u8 you receive might be a static literal, a stack temporary, or a

heap allocation. The library must track ownership at runtime to know which

ones it can safely free. Rust solves this in the type system (at compile time).

Zig leaves it to you (at runtime).

This is not a flaw. It is a tradeoff. But you must accept it before the rest of

the design makes sense.

A concrete example from zig-pflag


// User registers a slice flag with a default value

var tags: std.ArrayListUnmanaged([]const u8) = .empty;

tags.append(gpa, "default") catch {}; // "default" might be a literal!

var state = pflag.StringSliceState{ .value = &tags, .gpa = gpa };

try fs.stringSliceVarP(&state, "tag", "t", &.{}, "tags (repeatable)");

The library now holds *ArrayListUnmanaged([]const u8). The string

"default" sits inside it. Is that string on the heap? A literal? The library

has no way to know just by looking at the type.


2. changed / allocated: The Necessary Cost Forced to the Surface

“Why not just gpa.dupe all default values at registration and be done with it?”

Two practical obstacles block that path:

Obstacle A: Container sharing

The library only holds a pointer to the user’s container — the user still

owns the container variable. If the library created its own deep copy, the user

could no longer read parsed results through their variable.


var tags: std.ArrayListUnmanaged([]const u8) = .empty;

// Both the user and the library reference the same `tags` container.

// If the library makes its own copy → user reads `tags.items` → always

// sees defaults, never the parsed results.

Obstacle B: Literals cannot be freed

Even if container sharing were solved, the user’s default values might live in

.rodata. Calling gpa.free("default") is a segfault.


var map: std.StringHashMapUnmanaged(i32) = .empty;

map.put(gpa, "Content-Type", 0) catch {};

// Where does "Content-Type" live?

// If it's a literal → gpa.free() = UB / segfault

// If it's gpa.dupe'd → not freeing it = memory leak

The solution: runtime ownership flags

These two booleans are not design clumsiness. They are **ownership markers

forced from the type system into runtime** by Zig’s design choices:


pub const StringSliceState = struct {

value: *std.ArrayListUnmanaged([]const u8),

gpa: std.mem.Allocator,

changed: bool = false, // "Has set() been called at least once?"

};

pub const StringState = struct {

value: *[]const u8,

gpa: std.mem.Allocator,

allocated: bool = false, // "Was the current value dupe'd by the library?"

};

deinit logic for slice/map types — two-tier release:


fn deinit(state: *StringSliceState, gpa: std.mem.Allocator) void {

if (state.changed) {

for (state.value.items) |item| gpa.free(item); // only library-dupe'd items

}

state.value.deinit(gpa); // container backing array — always freed

}

| What is freed | Responsibility | Condition |

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

| Container (backing array / hash table) | Library, always | None |

| Data items (key / value strings) | Library | changed == true |

Why containers are always freed

ArrayListUnmanaged.append() and StringHashMapUnmanaged.put() always

heap-allocate the container’s backing storage. The container itself is always

on the heap regardless of where its data items point. So state.value.deinit(gpa)

is unconditional.

Why data items are conditionally freed

The user’s default values may be literals or stack data. Freeing them is UB.

The library only frees items when changed == true — meaning at least one

set() call has run, and every item in the container was dupe’d by the library.

The ordering of changed matters

Setting changed = true before the first successful put/append is a bug:


// ❌ Wrong — changed set too early

map.clearRetainingCapacity();

state.changed = true;

try map.put(gpa, key, val); // OOM here? changed is already true → deinit double-frees

// ✅ Correct — changed set after first successful put

try map.put(gpa, key, val);

if (!state.changed) state.changed = true;

If OOM strikes after clearing defaults but before the first successful insert,

changed being already true causes deinit to iterate and attempt to free

items that may be literals — double-free or segfault.

This pattern generalizes

This is not zig-pflag-specific. Any Zig library that:

  1. Accepts user-provided string data whose origin (literal/stack/heap) is unknown,

  2. Writes new string data into the same container, and

  3. Needs to clean up at deinit

…will eventually converge on a similar mechanism. The Unmanaged suffix on

std.ArrayListUnmanaged and std.StringHashMapUnmanaged already signals this —

“the allocator is not baked in; you manage ownership.” Push that statement one

step further, and you end up inventing changed.

Summary: three Zig design choices, one predictable outcome

| Zig design choice | Consequence | Pattern that emerges |

|—|—|—|

| []const u8 unifies all strings | Ownership invisible at type level | Runtime boolean flags |

| Explicit allocators, no RAII | No automatic destructor to distinguish ownership | Manual deinit + conditional free |

| Unmanaged containers strip allocators | Caller tracks “who allocated what” | Explicit gpa parameter flow |

These three choices combine to produce a single inevitable result: **every Zig

library that handles dynamic strings must invent its own ownership tracking

mechanism**. changed and allocated are not a sign that the design is

unpolished — they are the necessary cost pressed to the surface by Zig’s

deliberate simplicity. Internalize this, and designing string-heavy libraries

in Zig stops feeling awkward — it starts making sense.

1 Like