The CLI Library Choice
You are building a command-line tool. It started as a script that takes a filename and prints a report. Then your team asks for a --verbose flag. Then a subcommand to export data to JSON. Then auto-completion for your shell. Suddenly, parsing arguments feels like a full-time job. You need a library to handle the structure, help text, and flags without writing a parser from scratch.
The Go ecosystem offers two heavyweights for this job: spf13/cobra and urfave/cli. They solve the same problem, but they approach it with different philosophies. Cobra treats your CLI as a hierarchical tree of commands. urfave/cli treats it as a lightweight collection of flags and actions. The choice depends on the shape of your tool and how much structure you want the library to enforce.
How they model commands
Cobra models your tool as a command tree. Every subcommand is a node. The library manages the hierarchy, routing the user's input to the right handler. It comes with batteries included: shell completion, configuration file support, and a strict structure. You define commands, add them to parents, and Cobra handles the rest.
urfave/cli takes a lighter approach. It focuses on flags and arguments with minimal ceremony. You define an app, add commands and flags, and run. It feels more like writing a function with extra steps than bootstrapping a framework. The API is flat and direct. There is less magic, which means less to learn, but also less automation for complex features.
Minimal Cobra example
Cobra centers on the Command struct. You create a root command, define its behavior, and execute it. The library parses os.Args and matches them to the command tree.
package main
import (
"fmt"
"github.com/spf13/cobra"
)
// Execute runs the root command and handles errors.
func Execute() error {
// Create the root command with metadata for help text.
rootCmd := &cobra.Command{
Use: "greet",
Short: "A simple greeting tool",
// RunE returns an error instead of panicking on failure.
// This aligns with Go's error handling convention.
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Hello from Cobra!")
return nil
},
}
// Execute parses flags and runs the matching command.
// It prints help text if the user passes --help.
return rootCmd.Execute()
}
func main() {
// Check the error to exit with the correct code.
if err := Execute(); err != nil {
fmt.Println(err)
}
}
Cobra encourages RunE over Run. The RunE function returns an error, which Cobra captures and prints. If you use Run, you have to handle errors manually or risk panicking. The community standard is RunE for any command that can fail.
Minimal urfave/cli example
urfave/cli centers on the App struct. You define the app, add an action, and run it. The library provides a Context object that holds parsed flags and arguments.
package main
import (
"fmt"
"os"
"github.com/urfave/cli/v2"
)
// main sets up the app and runs it.
func main() {
// Define the app with a single action.
app := &cli.App{
Name: "greet",
Usage: "A simple greeting tool",
Action: func(c *cli.Context) error {
// Action receives the context with parsed flags.
// The context is immutable, which makes testing easier.
fmt.Println("Hello from urfave/cli!")
return nil
},
}
// Run parses arguments and executes the action.
// The app handles the exit code automatically.
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
Note the import path github.com/urfave/cli/v2. The library has a major version split. Version 2 is the current standard. Version 1 is legacy. Importing the wrong version causes confusion because the types differ. Always use v2 for new projects.
What happens when you run the binary
When you run the binary, both libraries intercept os.Args. They scan the slice for flags and subcommands. Cobra builds a tree in memory. It walks the tree based on the arguments. If you type greet --help, Cobra intercepts that flag and prints the Short description. It generates the help text automatically based on the command structure. You don't write the help string; you write the code, and Cobra reflects it.
urfave/cli does similar parsing but with less overhead. It maps flags to variables and invokes the callback. The help text is also generated from the metadata. Both libraries handle common flags like --help and --version without extra code.
Cobra generates shell completion scripts. You can run greet completion bash to output a script that enables tab-completion in your terminal. urfave/cli does not generate completion scripts by default. You have to write the completion logic yourself or use a third-party tool.
Realistic scenario: managing resources
Real tools have subcommands. Let's build a tool that manages users. The command structure is app user create --name Alice.
Cobra implementation
Cobra uses init() functions to wire up subcommands. This keeps the registration logic separate from the command definition. It's a common pattern in Cobra projects.
package main
import (
"fmt"
"github.com/spf13/cobra"
)
// rootCmd is the root command for the application.
var rootCmd = &cobra.Command{
Use: "app",
Short: "A resource management tool",
}
// userCmd represents the user command.
var userCmd = &cobra.Command{
Use: "user [name]",
Short: "Manage users",
RunE: func(cmd *cobra.Command, args []string) error {
// Check if a name was provided.
if len(args) == 0 {
return fmt.Errorf("name is required")
}
fmt.Printf("Managing user: %s\n", args[0])
return nil
},
}
// init registers the subcommand.
func init() {
// Add the subcommand to the root.
// This runs before main, setting up the tree.
rootCmd.AddCommand(userCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
}
}
urfave/cli implementation
urfave/cli defines commands in a slice. You pass the slice to the app. The structure is flatter.
package main
import (
"fmt"
"os"
"github.com/urfave/cli/v2"
)
// userCmd defines the user subcommand.
var userCmd = &cli.Command{
Name: "user",
Usage: "Manage users",
Action: func(c *cli.Context) error {
// Context.Args().First() gets the first positional argument.
name := c.Args().First()
if name == "" {
return fmt.Errorf("name is required")
}
fmt.Printf("Managing user: %s\n", name)
return nil
},
}
func main() {
app := &cli.App{
Name: "app",
Usage: "A resource management tool",
// Commands is a slice of subcommands.
Commands: []*cli.Command{
userCmd,
},
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
Both approaches work. Cobra's init() pattern scales well for large projects with many subcommands. You can put each command in its own file and register it in init(). urfave/cli's slice pattern is cleaner for small projects. You see the whole structure in one place.
Flags and state management
Flags are where the libraries diverge in style. Cobra binds flags to variables. urfave/cli reads flags from the context.
Cobra flag binding
Cobra uses Flags().StringVarP to bind a flag to a variable. This mutates the variable when the flag is parsed.
var outputFormat string
func init() {
// Bind a flag to a variable.
// The flag modifies outputFormat when parsed.
rootCmd.Flags().StringVarP(&outputFormat, "format", "f", "text", "Output format: text or json")
}
This approach is concise. You access the variable directly in your command handler. The downside is that the variable is global state. If you run multiple commands in the same process, the variable persists. This is rarely an issue for CLI tools, which usually run one command and exit. But it can make testing harder because you have to reset the variable between tests.
urfave/cli flag reading
urfave/cli defines flags in a slice and reads them from the context. The context is immutable.
var flags = []*cli.Flag{
&cli.StringFlag{
Name: "format",
Aliases: []string{"f"},
Value: "text",
Usage: "Output format: text or json",
},
}
// In the action:
format := c.String("format")
This approach is safer. The context holds the parsed values. You can inject the context into functions for testing. There is no global state. The trade-off is more verbosity. You have to define the flag struct and read it from the context.
Pitfalls and compiler errors
Cobra: Run vs RunE
If you use Run instead of RunE, you lose automatic error handling. Cobra will not print the error. You have to print it yourself. If you panic, the program crashes with a stack trace. The community standard is RunE.
If you forget to add a subcommand, Cobra won't complain at compile time. It will just say Error: unknown command "foo" for "myapp" at runtime. The compiler cannot check the command tree structure. You have to test the CLI manually or write integration tests.
urfave/cli: Versioning
The biggest pitfall with urfave/cli is versioning. If you import github.com/urfave/cli without /v2, you get version 1. The types are different. The compiler rejects your code with undefined: cli.App if you use v2 syntax with v1 imports. Always check your import path.
Context handling
Both libraries provide a context object. Cobra's cmd.Context() returns a standard context.Context. urfave/cli's c.Context also returns a standard context. Always pass this context to downstream calls. This allows you to cancel long-running operations if the user presses Ctrl+C.
If you ignore the context, your tool might hang after cancellation. The worst goroutine bug is the one that never logs. Always respect context cancellation in your handlers.
Convention aside: gofmt
Run gofmt on your code. The community expects consistent formatting. Most editors integrate this automatically. Don't argue about indentation; let the tool decide. It saves time and keeps the codebase uniform.
Decision matrix
Use Cobra when you are building a complex tool with nested subcommands like git or kubectl. Use Cobra when you need automatic shell completion and configuration file binding out of the box. Use Cobra when your team prefers a structured framework with clear conventions.
Use urfave/cli when you are writing a simple utility with a flat structure and a handful of flags. Use urfave/cli when you want minimal dependencies and a lightweight API without framework overhead. Use urfave/cli when you prefer immutable context objects for easier testing.
Use the standard flag package when your tool has fewer than five flags and no subcommands. The standard library is always available and has zero dependencies. It's the right choice for tiny tools.
Cobra scales to complexity. urfave/cli keeps the focus on your code. Pick the tool that matches the shape of your CLI.