When a simple flag package stops working
You need a command-line tool that accepts subcommands, parses flags, and prints a clean help message when users type --help. Writing a parser from scratch means handling edge cases, managing nested arguments, and formatting output manually. The standard library gives you flag, which works for simple scripts but collapses under a growing tree of commands. Cobra fills that gap by treating your CLI as a structured command tree.
Think of Cobra as a router for terminal input. Instead of one giant function that checks every possible flag combination, you build a tree. The root node is your executable name. Each branch is a subcommand. Each leaf holds the actual logic. When a user types myapp run --port 8080, Cobra walks the tree, matches run, extracts --port, and hands the parsed values to the function attached to that branch. The library handles the heavy lifting of argument splitting, flag validation, and help text generation. You just wire up the behavior.
Cobra is a tree. Route the input, attach the logic, let the library handle the rest.
Minimal skeleton
Start by generating the project structure. The cobra-cli tool scaffolds the directory layout and writes the boilerplate so you can focus on business logic.
go install github.com/spf13/cobra-cli@latest
cobra-cli init myapp
cd myapp
cobra-cli add run
The generator creates a cmd directory, a main.go entry point, and a run.go file for the subcommand. Open cmd/root.go. You will see a rootCmd variable and an init function that binds flags. The Execute function at the bottom ties everything together.
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "A brief description of your application",
// RunE returns an error instead of printing to stderr and exiting
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("myapp called")
return nil
},
}
// Execute adds all child commands to the root command and sets flags appropriately
func Execute() {
// cobra handles os.Args parsing and routing automatically
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
Build and run it. The help system works immediately because Cobra inspects the Use and Short fields to generate documentation.
go build -o myapp
./myapp --help
Goroutines are cheap. Channels are not magic. Command trees are just structs with pointers.
The execution lifecycle
When the binary starts, main.go calls Execute(). Cobra reads os.Args, strips the executable name, and begins matching tokens against the command tree. If it finds a match, it binds any attached flags. If a flag is missing and marked as required, Cobra aborts and prints a usage error. If everything validates, it calls the RunE function attached to that command.
The RunE signature is deliberate. It returns an error instead of accepting a log interface or printing directly. This keeps your command logic testable. You can unit test the function by passing mock dependencies, and Cobra catches the returned error to set the correct exit code. The convention in Go is to return errors up the call stack and let the top layer decide how to format them. Cobra follows that pattern.
Cobra also provides PreRunE and PostRunE hooks. PreRunE runs before the main logic. Use it for validation, configuration loading, or establishing database connections. PostRunE runs after. Use it for cleanup, closing connections, or flushing buffers. The lifecycle is linear and predictable.
// runCmd starts an HTTP server on a configurable port
var runCmd = &cobra.Command{
Use: "run",
Short: "Start the HTTP server",
// PreRunE validates flags before the main logic executes
PreRunE: func(cmd *cobra.Command, args []string) error {
port, _ := cmd.Flags().GetString("port")
if port == "" {
return fmt.Errorf("port flag cannot be empty")
}
return nil
},
// RunE keeps the function pure and testable by returning errors
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("server starting")
return nil
},
}
Notice the init function in the generated files. Go runs init before main, which means flag registration happens before argument parsing. Cobra relies on this ordering. The StringP method registers a long flag (--port) and a short alias (-p). The third argument is the default value. If the user omits the flag, Cobra supplies the default automatically.
Trust the flag binding phase. Register flags in init, read them in RunE, and let Cobra validate the types.
Realistic server command
A real CLI needs flags, validation, proper error handling, and graceful shutdown. Replace the placeholder run command with something that actually does work.
package cmd
import (
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
)
// runCmd starts an HTTP server on a configurable port
var runCmd = &cobra.Command{
Use: "run",
Short: "Start the HTTP server",
Long: "Launches a local HTTP server that serves static files and health checks.",
// RunE keeps the function pure and testable by returning errors
RunE: func(cmd *cobra.Command, args []string) error {
// Extract the flag value bound to this command
port, _ := cmd.Flags().GetString("port")
addr := fmt.Sprintf(":%s", port)
// Set up a simple handler for demonstration
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
// Create the server with a timeout to prevent goroutine leaks
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
// Start listening in a separate goroutine so we can handle shutdown
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(cmd.ErrOrStderr(), "server failed: %v\n", err)
}
}()
// Block until the process receives a signal
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
// Gracefully shut down the server
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(ctx)
},
}
func init() {
// Bind the port flag to this specific subcommand
// The default value lives here, not in the RunE logic
runCmd.Flags().StringP("port", "p", "8080", "port to listen on")
rootCmd.AddCommand(runCmd)
}
Error handling follows Go conventions. The if err != nil pattern is verbose by design. It forces you to acknowledge failure paths instead of swallowing them. When srv.ListenAndServe returns an error, we check for http.ErrServerClosed because that error is expected during graceful shutdown. Everything else gets logged to cmd.ErrOrStderr(), which respects the user's --stderr redirection if they configured it.
The context.WithTimeout call demonstrates a core Go convention. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. The server shutdown respects the five-second deadline before forcing a close.
Don't fight the type system. Wrap the value or change the design.
Pitfalls and compiler boundaries
CLI parsers fail in predictable ways. The most common mistake is accessing a flag before Cobra has parsed the arguments. If you call cmd.Flags().GetString("port") outside of RunE or PreRunE, you will get a zero value instead of the user input. Cobra does not parse os.Args until Execute() is called.
Another trap is mixing the standard flag package with Cobra. They both read os.Args and both try to claim the --help flag. If you import flag and call flag.Parse() manually, Cobra will panic with flag redefined: help. Stick to one parser. If you use Cobra, use cmd.Flags(). If you need global flags, use rootCmd.PersistentFlags().
The compiler will also catch structural mistakes. If you forget to add a subcommand to the root, you get unknown command "run" for "myapp" at runtime. If you return a string instead of an error from RunE, the compiler rejects it with cannot use "message" (untyped string constant) as error value in return argument. Go's type system prevents silent failures here.
Goroutine leaks are the silent killer in long-running CLIs. The example above blocks on a channel, but a real application should listen for os.Interrupt and call srv.Shutdown(ctx). If the server goroutine never exits, the process hangs on Ctrl+C. Always provide a cancellation path for background workers.
Register flags early. Parse late. Return errors, never panic.
Decision matrix
Use the standard flag package when your tool has fewer than five flags and no subcommands. Use Cobra when you need a command tree, auto-generated help, and flag grouping across subcommands. Use urfave/cli when you prefer a more declarative, struct-based configuration style. Use manual os.Args parsing when you are building a tiny wrapper script and want zero dependencies. Use a dedicated configuration library like Viper alongside Cobra when your CLI needs to read from environment variables, config files, and flags simultaneously.