JavaRush /Courses /Go SELF /flag.FlagSet: separate flags per command

flag.FlagSet: separate flags per command

Go SELF
Level 49, Lesson 0
Available

1. Why subcommands need separate flags

When you write your first CLI program, it’s really tempting to do it “like in the example”: declare flags via flag.String(...), call flag.Parse(), and you’re done. It really is convenient… right up until the moment you decide your program won’t be a single command, but multiple ones — in the form app <cmd> .... And that’s where a small tragicomedy begins.

Imagine we’re building a learning app called todo (no disk storage yet — just to get a feel for CLI). We have two commands: add and list. add has a -title flag, and list has a -done flag.

If you try to declare both flags “globally”, the code looks roughly like this:

package main

import (
	"flag"
	"fmt"
	"os"
)

func main() {
	title := flag.String("title", "", "task title")
	done := flag.Bool("done", false, "show done tasks only")
	flag.Parse()

	fmt.Println("cmd args:", flag.Args()) // cmd args: [...]
	fmt.Println("title:", *title)         // title: ...
	fmt.Println("done:", *done)           // done: ...
	_ = os.Args
}

The problem isn’t that this compiles “incorrectly”. The problem is that it models the meaning incorrectly.

The command todo add -title "Buy milk" doesn’t have to know that somewhere out there a -done flag exists. But the global parser knows about everything at once and starts behaving like an airport dispatcher who’s simultaneously trying to manage airplanes, trains, and pizza delivery.

And vice versa: the command todo list -done doesn’t have to know that somewhere out there -title exists. If flags are declared globally, you either tolerate the “mess”, or you write a pile of conditions like “if the command is X, ignore some flags”, and you end up making your own life harder.

To lock in the idea, here’s a small table:

Approach What happens Why it’s bad with subcommands
Global flags (flag.String, flag.Parse) One shared set of flags for the whole application Flags from different commands get mixed, it’s harder to provide help for a specific command, and harder to control errors
flag.FlagSet per command Each command has its own “mini-parser” Commands are isolated, flags don’t conflict, and it’s easier to build a clean UX

2. flag.FlagSet: how it works and how to configure it

What flag.FlagSet is

In plain language, flag.FlagSet is a separate set of flags plus the logic to parse them. It’s like having “your own small flag”, but not global for the whole app — local to a specific command.

Think of each command as a separate “settings tab”. The add tab needs some settings, the list tab needs different ones. flag.FlagSet lets you keep those tabs from being mixed together.

Creating a FlagSet: NewFlagSet(name, errorHandling)

You create it via flag.NewFlagSet(name, errorHandling):

  • name — the name of the flag set (usually the command name, for example "add").
  • errorHandling — the mode that defines what happens on parsing errors.

Minimal example:

package main

import (
	"flag"
	"fmt"
)

func main() {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fmt.Println(fs.Name()) // add
}

Declaring flags: fs.String, fs.Int, fs.Bool

When you switch to FlagSet, the most important rule is: inside a command you no longer use flag.String(...); instead, you use the methods of your specific FlagSet: fs.String(...), fs.Int(...), fs.Bool(...).

In other words, the “global flag” is like a shared kitchen. A FlagSet is your own small kitchen. And yes, you’re less likely to find someone else’s spoon in your pot.

Example for the add command, which has -title:

package main

import (
	"flag"
	"fmt"
)

func main() {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	title := fs.String("title", "", "task title")

	_ = fs.Parse([]string{"-title", "Buy milk"})
	fmt.Println(*title) // Buy milk
}

Note the type: fs.String(...) returns *string. This is the same approach as the global flag.

ContinueOnError: return an error instead of “exiting unexpectedly”

In a CLI you want predictable behavior. When the user makes a mistake in arguments, you typically want to:

  • print a short message explaining what’s wrong;
  • optionally show a usage hint for the command;
  • exit cleanly.

But if you create a FlagSet in flag.ExitOnError mode (or use the global flag, which by default is closer to “I’ll handle it myself”), the parser may print something on its own and terminate execution on its own. And you’re left with the feeling: “Am I even in charge of this program, or am I just standing nearby?”

For learning CLIs it’s very useful to pick flag.ContinueOnError: then Parse returns an error instead of exiting the program. This gives you control: you decide what to print, where to print it, and how to respond.

This pairs nicely with the Go approach “errors are values”: we don’t throw magical exceptions; we explicitly return an error and decide at the top level what to do next. When you add context to errors, you usually do it via fmt.Errorf, and to keep the original cause you use %w (this matters for proper diagnostics and checks via errors.Is/As).

For now, let’s do a simple example: the add command tries to parse flags and returns an error upward.

package main

import (
	"flag"
	"fmt"
)

func runAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	_ = fs.String("title", "", "task title")

	if err := fs.Parse(argv); err != nil {
		return fmt.Errorf("parse flags for add: %w", err)
	}
	return nil
}

Notice: we don’t print anything here. We just say: “inside the command, a parsing error occurred.” The decision “where to print” is the responsibility of the top level (usually main or a dispatcher). This makes the code much calmer: fewer duplicate messages, less chaos.

SetOutput: where flag prints messages

There’s a subtle point: even in ContinueOnError mode, the flag package can print diagnostic messages (for example, when it encounters an unknown flag). By default it may write to somewhere you don’t expect, and as a result you’ll mix “useful command output” with “parsing errors.”

That’s why a good habit is to explicitly set the FlagSet output to os.Stderr.

package main

import (
	"flag"
	"os"
)

func newAddFlagSet() *flag.FlagSet {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	return fs
}

The idea is simple: everything related to input errors and usage hints goes to stderr. Everything that is the command’s result (a task list, a created ID, etc.) goes to stdout.

Usage for a specific command

When there are multiple commands, “one help message for the whole app” often becomes too verbose. The user ran todo add, made a mistake, and you dumped help for list, rm, done, export, and also the secret command summon-the-dragon (which you added when you were in a particularly good mood).

FlagSet lets you assign your own Usage handler: fs.Usage = func() { ... }. This function prints help specifically for that command.

Example: create Usage for add and print flags via fs.PrintDefaults():

package main

import (
	"flag"
	"fmt"
	"os"
)

func newAddFlagSet() *flag.FlagSet {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	fs.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: todo add -title TEXT")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Flags:")
		fs.PrintDefaults()
	}
	return fs
}

It’s important to catch the style here: we print to os.Stderr, because Usage is not “the command’s output”; it’s help for the user.

3. The add command: flags → Parse → reading values

Now let’s assemble a small piece: the runAdd(argv []string) function creates a FlagSet, declares flags, parses arguments, and prints the result.

For now, we’ll keep the logic as simple as possible: if -title is empty, return an error (without diving into a unified CLI error classification — that’s a separate big topic). If everything is fine, print added: <title>.

package main

import (
	"flag"
	"fmt"
	"os"
)

func runAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	title := fs.String("title", "", "task title")
	if err := fs.Parse(argv); err != nil {
		return err
	}
	if *title == "" {
		return fmt.Errorf("missing -title")
	}

	fmt.Fprintln(os.Stdout, "added:", *title) // added: Buy milk
	return nil
}

Pay attention to the order of actions: first declare flags, then Parse, and only then read *title. This discipline saves your nerves.

4. The list command: a second FlagSet and isolation

To feel the difference, let’s add a second command — list — with the -done flag. And importantly: list doesn’t know that -title exists. It has its own separate FlagSet.

package main

import (
	"flag"
	"fmt"
	"os"
)

func runList(argv []string) error {
	fs := flag.NewFlagSet("list", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	done := fs.Bool("done", false, "show done tasks only")
	if err := fs.Parse(argv); err != nil {
		return err
	}

	fmt.Fprintln(os.Stdout, "list done =", *done) // list done = true
	return nil
}

Now the commands can be run independently:

  • todo add -title "Buy milk"
  • todo list -done

And the flags don’t conflict, because each command parses its own argv with a separate parser.

5. The dispatcher: it selects the command and passes argv

Here’s the key idea: the dispatcher (the code that selects the command) must not parse command flags. Its job is to determine which command (add, list, …) and pass the remaining arguments to the handler.

We won’t dig deeply into the dispatcher (that’s a separate topic for another day), but having the “world model” is useful. It’s convenient to imagine it like this:

flowchart TD
    A[os.Args] --> B[dispatcher: selects cmd]
    B -->|argv for command| C[runAdd: its own FlagSet]
    B -->|argv for command| D[runList: its own FlagSet]
    C --> E[stdout/stderr]
    D --> E[stdout/stderr]

The key meaning of the diagram: each command is a small autonomous module with its own flags.

A mini draft of a dispatcher, just to make it clear how commands receive argv:

package main

import (
	"fmt"
)

func dispatch(cmd string, argv []string) error {
	switch cmd {
	case "add":
		return runAdd(argv)
	case "list":
		return runList(argv)
	default:
		return fmt.Errorf("unknown command: %s", cmd)
	}
}

6. Common mistakes when working with flag.FlagSet

Mistake #1: inside a command, using flag.String(...) instead of fs.String(...).
This happens out of habit: your hand types flag.String because that’s how examples on the internet do it. As a result, some flags end up in the global parser and some in the local one, and you get strange behavior: flags “work sometimes and don’t work sometimes”, help is printed by “the wrong command”, and a mild panic moves into your head. The fix is simple: if you’re inside runXxx, then flags are declared via fs.<Type>(...), where fs is your FlagSet.

Mistake #2: creating a FlagSet in a mode that exits the program on its own (ExitOnError).
Sometimes it feels convenient to think “let the library handle everything”, but it quickly gets in the way when you want to manage messages, output, and errors consistently. In a mode where the parser exits by itself, you lose control. With ContinueOnError, you get an error and can organize application behavior properly, without turning your CLI into the “guess who decides what to print today” amusement ride.

Mistake #3: not calling fs.SetOutput(os.Stderr) and wondering “why help/errors got mixed with the command result”.
This is a subtle but very annoying problem. You want stdout to be redirectable to a file or a pipe, and then suddenly a line about an unknown flag appears there. The usual reason is that flag output was not configured explicitly. The habit of setting SetOutput immediately when creating a FlagSet makes behavior predictable.

Mistake #4: reading positional arguments from the original argv instead of from fs.Args() after Parse.
Even if you’re not using positional arguments today, you almost certainly will soon. And then the trap appears: after Parse, flags are “cut out” of the arguments, and the correct source of positional args becomes fs.Args(), fs.NArg(), fs.Arg(i). If you keep reading from the original argv, it’s easy to mix up indexes, especially when the user mixed flags and arguments. The right strategy is discipline: first Parse, then everything else.

Mistake #5: the command prints errors itself, and the top level prints them again.
When you’re just starting out, you really want to do fmt.Println("error:", err) everywhere. But as soon as the program becomes more complex, you get duplicates: the same message is printed twice, sometimes to different streams, sometimes with different prefixes. It’s much calmer when the command returns an error, and printing happens in one place (usually at the top). Then FlagSet with ContinueOnError becomes your ally: it also returns an error, without forcing you to print something “deep inside”.

1
Task
Go SELF, level 49, lesson 0
Locked
Greeting phrase
Greeting phrase
1
Task
Go SELF, level 49, lesson 0
Locked
Sum in the terminal
Sum in the terminal
1
Task
Go SELF, level 49, lesson 0
Locked
Repeat a phrase
Repeat a phrase
1
Task
Go SELF, level 49, lesson 0
Locked
Range filter
Range filter
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION