Right, so you’ve built a simple CLI tool with a single purpose. It’s a beautiful, focused little thing. But then ambition strikes. You need your tool to create, list, and destroy things. You’ve outgrown a single set of flags. You need subcommands. And with subcommands, you need a way to have flags that are specific to just that subcommand. This is where flag.FlagSet struts onto the stage, looking a bit like a classic action hero who gets the job done with minimal fuss.

Think of a flag.FlagSet as an isolated, self-contained command-line-argument-processing unit. Each subcommand gets its own. It’s like giving each of your kids their own room—their flags can’t mess with each other, and you know exactly who made the mess (-h) when you need to.

The Nuts and Bolts of a FlagSet

You create a FlagSet for a subcommand by giving it a name and, importantly, setting the error handling behavior. The two most common settings are flag.ExitOnError (which calls os.Exit(2), the classic move for CLI tools) and flag.ContinueOnError (which gives you control to handle the error yourself, useful if you’re building a larger application that embeds this).

Here’s how you’d set up a tool with create and list subcommands:

package main

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

func main() {
    // The 'create' subcommand
    createCmd := flag.NewFlagSet("create", flag.ExitOnError)
    createName := createCmd.String("name", "", "Name of the resource to create (required)")
    createForce := createCmd.Bool("force", false, "Overwrite if it already exists")

    // The 'list' subcommand
    listCmd := flag.NewFlagSet("list", flag.ExitOnError)
    listAll := listCmd.Bool("all", false, "List all resources, including hidden ones")

    if len(os.Args) < 2 {
        fmt.Println("expected 'create' or 'list' subcommands")
        os.Exit(1)
    }

    switch os.Args[1] {
    case "create":
        createCmd.Parse(os.Args[2:]) // Parse the args *after* the subcommand
        fmt.Printf("Creating '%s' (force=%t)\n", *createName, *createForce)
        // Your create logic here...

    case "list":
        listCmd.Parse(os.Args[2:])
        fmt.Printf("Listing all=%t\n", *listAll)
        // Your list logic here...

    default:
        fmt.Printf("Unknown subcommand '%s'\n", os.Args[1])
        os.Exit(1)
    }
}

Run it with go run main.go create -name "myapp" -force, and watch the magic happen. The -name and -force flags are only valid after the create subcommand. Try go run main.go list -force and you’ll get a beautifully clear error: flag provided but not defined: -force. This isolation is exactly what we want.

The Subtle Art of Parsing and Validation

Notice the crucial line: createCmd.Parse(os.Args[2:]). We’re passing everything after the subcommand name to the FlagSet for parsing. This is why it works. The FlagSet only sees its own arguments.

Now, a common pitfall: forgetting to check for required flags. The FlagSet will happily parse an empty -name flag because it has a default value (the empty string). The FlagSet handles syntax, not your business logic. You have to do that yourself.

case "create":
    createCmd.Parse(os.Args[2:])
    if *createName == "" {
        fmt.Println("Error: the -name flag is required for the 'create' command")
        createCmd.Usage() // This prints out the help for just the 'create' subcommand!
        os.Exit(1)
    }
    // Now proceed, safe in the knowledge you have a name.

This is the “rough edge” I promised to be honest about. flag doesn’t have a built-in required attribute like some higher-level libraries. You have to roll up your sleeves and check it yourself. It’s a bit more code, but it’s also explicit and gives you total control over the error message, which is often worth it.

Best Practices and the Cobra Question

This pattern is solid, but you’ll quickly notice you’re writing a lot of boilerplate: the switch statement, the parsing, the validation. This is why fantastic libraries like Cobra exist. Cobra is essentially a framework that supercharges this FlagSet concept, wrapping it in a structure that handles commands, subcommands, help text, and validation in a more elegant and powerful way.

So, should you use flag.FlagSet or just jump to Cobra? My rule of thumb: if your tool has more than two subcommands or needs any advanced features like automatic help generation, shell completion, or multi-level nested commands, stop what you’re doing and use Cobra. It will save you time and headaches. For a simple tool with one or two subcommands, the raw FlagSet is a lightweight and perfectly respectable choice. It keeps your dependencies minimal and your code transparent. You’re not wrong for choosing it; you’re just choosing a different set of trade-offs.