Subcommands with flag.FlagSet

Updated

September 8, 2026

Subcommands with flag.FlagSet

Overview

Real tools grow verbs: git status, docker compose up, kubectl get pods. You can implement subcommands with only the standard library via flag.NewFlagSet—no Cobra required.

Dispatch pattern

os.Args[0]   program
os.Args[1]   subcommand  (get|set|list)
os.Args[2:]  flags + args for that subcommand
package main

import (
    "fmt"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        usage()
        os.Exit(2)
    }
    var err error
    switch os.Args[1] {
    case "get":
        err = cmdGet(os.Args[2:])
    case "set":
        err = cmdSet(os.Args[2:])
    case "list":
        err = cmdList(os.Args[2:])
    case "help", "-h", "--help":
        usage()
        return
    default:
        fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
        usage()
        os.Exit(2)
    }
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func usage() {
    fmt.Fprintf(os.Stderr, `Usage: kv <command> [flags]

Commands:
  get   get a key
  set   set a key
  list  list keys
`)
}

Per-command FlagSet

func cmdGet(args []string) error {
    fs := flag.NewFlagSet("get", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)
    raw := fs.Bool("raw", false, "print raw value only")
    if err := fs.Parse(args); err != nil {
        return err
    }
    if fs.NArg() != 1 {
        return fmt.Errorf("usage: kv get [-raw] <key>")
    }
    key := fs.Arg(0)
    val, ok := store[key]
    if !ok {
        return fmt.Errorf("key not found: %s", key)
    }
    if *raw {
        fmt.Print(val)
        return nil
    }
    fmt.Printf("%s=%s\n", key, val)
    return nil
}

func cmdSet(args []string) error {
    fs := flag.NewFlagSet("set", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)
    if err := fs.Parse(args); err != nil {
        return err
    }
    if fs.NArg() != 2 {
        return fmt.Errorf("usage: kv set <key> <value>")
    }
    store[fs.Arg(0)] = fs.Arg(1)
    return nil
}

Shared “global” flags

Parse globals before the subcommand, or require globals after the verb (simpler with FlagSet-per-command).

Globals before verb (manual)

// mytool -verbose get -id 1
// harder with stdlib flag alone — often document:
// mytool get -verbose -id 1

Globals on every FlagSet (duplicate)

func addGlobal(fs *flag.FlagSet, verbose *bool) {
    fs.BoolVar(verbose, "verbose", false, "verbose output")
    fs.BoolVar(verbose, "v", false, "verbose output (short)")
}

Cobra’s persistent flags solve this more cleanly (chapter 312). For stdlib, prefer flags after the subcommand.

Nested subcommands

// tool remote add
// tool remote list
func cmdRemote(args []string) error {
    if len(args) < 1 {
        return fmt.Errorf("usage: tool remote <add|list>")
    }
    switch args[0] {
    case "add":
        return cmdRemoteAdd(args[1:])
    case "list":
        return cmdRemoteList(args[1:])
    default:
        return fmt.Errorf("unknown remote command %q", args[0])
    }
}

Example: mini todo CLI

// commands: add, list, done
// storage: JSON file in OS user config dir

func cmdAdd(args []string) error {
    fs := flag.NewFlagSet("add", flag.ContinueOnError)
    pri := fs.Int("pri", 0, "priority")
    if err := fs.Parse(args); err != nil {
        return err
    }
    if fs.NArg() < 1 {
        return fmt.Errorf("usage: todo add [-pri N] <text>")
    }
    text := strings.Join(fs.Args(), " ")
    return saveNewTask(text, *pri)
}

Help that lists commands

var commands = map[string]string{
    "get":  "get a key",
    "set":  "set a key",
    "list": "list all keys",
}

func usage() {
    fmt.Fprintln(os.Stderr, "Usage: kv <command> [flags]")
    fmt.Fprintln(os.Stderr, "\nCommands:")
    keys := slices.Sorted(maps.Keys(commands))
    for _, k := range keys {
        fmt.Fprintf(os.Stderr, "  %-8s %s\n", k, commands[k])
    }
}

Alias map

aliases := map[string]string{"ls": "list", "rm": "delete"}
cmd := os.Args[1]
if a, ok := aliases[cmd]; ok {
    cmd = a
}

When FlagSet is enough

Enough Grow to Cobra
≤ ~10 commands, flat or shallow Deep trees, many shared flags
Internal tooling Public CLI with completion
You want zero dependencies Man pages, docs generators

Rules of thumb

Do Don’t
One FlagSet per command One global flag.Parse for all verbs
ContinueOnError in libraries/tests ExitOnError inside run used by tests
Document command list in usage Silent unknown commands

Try next

  1. Implement kv get|set|list with an in-memory map (then JSON file).
  2. Add kv help get that prints only the get FlagSet defaults.
  3. Add nested config get / config set.