In a Go CLI with per-subcommand flag sets, why is a -verbose flag silently false, and how do you catch it?
answer
- no error is raised for this at all
- two sets exist, only one was parsed
- the default looks like a decision
- walk every defined flag at startup
basics
~20 sBecause nothing parsed the set it was registered on. A flag defined on one flag set and never reached by that set's Parse keeps its default, with no error anywhere, and the default is indistinguishable from a deliberate choice.
solid answer
~50 sRegistering a flag and parsing it are separate acts, and only the second one fails loudly. If `-verbose` is defined on `flag.CommandLine` but the subcommand path only calls `fs.Parse(os.Args[2:])` on its own set, `flag.Parse` never runs, so the flag holds its default forever — and that default looks exactly like a user who chose false. The same silence covers three neighbours: defining a flag after Parse returned, defining one name on two sets and reading the one you did not parse, and a known flag placed after the first operand, where parsing had already stopped. To catch it, dump the set you actually parsed at startup: `fs.VisitAll` walks every defined flag with its value and default, `fs.Visit` walks only the ones explicitly set, and `fs.Parsed()` says whether that set was parsed at all. Then fix the structure so exactly one set is parsed per code path.
code
go · 7 linesfs.VisitAll(func(f *flag.Flag) {
log.Printf("defined %s = %q (default %q)", f.Name, f.Value.String(), f.DefValue)
})
fs.Visit(func(f *flag.Flag) {
log.Printf("explicitly set: %s", f.Name)
})
log.Printf("parsed: %v", fs.Parsed())go deeper
Take away one rule: a flag only holds a user value if the set it was defined on was actually parsed. Check that Parse ran on the same set you registered the flag on.
Explain the difference between defining and parsing, and show how two flag sets in one program let a flag be defined on one and parsed on neither.
Demonstrate the diagnosis: dump every defined flag with its value and default at startup, use Visit to see what the user actually set, and then restructure so exactly one set is parsed per path.
Argue for a shape where this cannot recur — one parse per path, a testable run(argv) entry point, and a documented home for shared flags — and treat 'the default silently wins' as a class of config failure worth a standing check.
## Two separate acts, one of them silent In Go's `flag` package, *defining* a flag and *parsing* it are unrelated operations on a `*flag.FlagSet`. Defining always succeeds. Parsing may never happen at all, and when it does not, nothing complains: the variable behind the flag simply keeps the default you gave it at definition time. That is fine when the default is a real fallback. It is a production bug when the default happens to be `false`, `0` or `""` and the caller believes they turned something on. ## The subcommand shape that produces it A git-style CLI parses more than one set: ```go verbose := flag.Bool("verbose", false, "log every step") // on flag.CommandLine fs := flag.NewFlagSet("sync", flag.ContinueOnError) dry := fs.Bool("dry-run", false, "do not write") fs.Parse(os.Args[2:]) // only this set is parsed ``` `-dry-run` works. `*verbose` is false no matter what the user typed, because `flag.Parse()` — the call that would populate `flag.CommandLine` — is never made on this path. Worse, if the user writes `tool sync -verbose`, the subcommand's set reports an unknown flag, while `tool -verbose sync` would have worked if only the top-level set had been parsed. The two spellings fail in different, equally confusing ways. Three relatives of the same bug: - **Defining after parsing.** `flag.Parse()` runs, then some later `init` or lazily-called constructor registers another flag. It is registered and never populated. - **Two sets, same name.** A shared helper registers `-verbose` on every subcommand set *and* on `flag.CommandLine`, and the code reads the package-level pointer while the subcommand set filled its own copy. - **A misplaced known flag.** `tool sync out.txt -verbose` parses fine and sets nothing, because parsing stopped at `out.txt`. None of these produce an error. That is what makes the class worth naming. ## Diagnosing it The cheapest instrument is to print, once at startup, the state of the set you actually parsed: ```go fs.VisitAll(func(f *flag.Flag) { log.Printf("flag %s = %q (default %q)", f.Name, f.Value.String(), f.DefValue) }) ``` `VisitAll` walks **every flag defined on that set**, in lexicographical order, whether or not the user mentioned it, and each `*flag.Flag` carries `Name`, `Usage`, `DefValue` (the default rendered as a string) and `Value` (the live value, which prints through its `String` method). Two things fall out immediately: a flag you expected to see and cannot find is on a different set, and a flag whose value equals its default did not come from the user. `Visit` is the complement — it walks only the flags that were explicitly set during parsing. If a name appears in `VisitAll` but never in `Visit`, the user did not type it (or you never parsed that set at all). `fs.Parsed()` answers the cruder question of whether `Parse` ran on this set, and `fs.Lookup(name)` fetches a single flag when you only want to check one. ## Fixing the structure Diagnosis is easy; the fix is structural. Rules that hold up: 1. **Exactly one parse per code path**, and every flag a path reads must live on the set that path parses. 2. **Register everything before parsing** — no lazy registration from constructors or `init` functions that run late. 3. **Choose one home for shared flags** and make it uniform: either global flags precede the verb and `flag.CommandLine` is always parsed first, or a helper registers them onto every subcommand set. Mixing the two is what creates the two-sets-one-name variant. 4. **Make the shape testable.** A `run(argv []string) error` function that builds and parses the sets can be called from a test with a hand-written argument slice, so "this flag reaches this field" becomes an assertion rather than a hope. `flag.ContinueOnError` is what makes that test possible, because a bad slice returns an error instead of ending the test process. The underlying lesson generalises past this package: a configuration input whose failure mode is "the default wins" needs an explicit check, because the absence of an error is not evidence that anything happened.
- What happens if you define a flag after that set's Parse has already returned?The flag is registered successfully and never populated: the parse that would have filled it has already happened, so it holds its default for the life of the process. Parse is not re-run automatically. Define every flag before parsing — lazy registration from an init function or a late constructor is the usual way this creeps in.
- How do you tell 'the user passed -verbose=false' from 'the user passed nothing'?The value alone cannot tell you, because both are false. `Visit` walks only the flags that were explicitly set during parsing, while `VisitAll` walks every flag defined on the set. If the name shows up in Visit, the user typed it; if it appears only in VisitAll, it is sitting on its default.
- Why does an unknown flag error while a misplaced known flag does not?An unknown flag is encountered *during* parsing, so the parser reports it. A known flag written after the first operand is never examined at all — parsing had already stopped at that operand — so the token lands in Args() as ordinary text. The first failure is loud, the second is silent, which is why misplacement is the harder bug.
saying these in an interview costs you the question
- Assumes an unparsed flag would raise an error
- Calls Parse twice hoping the second fills the set
- Registers flags lazily after parsing has run
- Believes flag.CommandLine sees the whole argument vector
- Reads a package-level flag that a subcommand set filled