DEV Community

Kittipat.po
Kittipat.po

Posted on

Building Powerful CLI Tools in Go with Cobra 🐍

Cobra Image

Introduction

If you've ever used kubectl, hugo, gh (GitHub CLI), or helm, you've already used a CLI built with Cobra. It's the standard library for building command-line interfaces in Go — and for good reason.

In this blog, we'll walk through everything you need to know about Cobra 🐍 — from installing it and creating your first command, all the way to subcommands, flags, argument validation and lifecycle hooks. Plenty of examples along the way.

Installation & Project Setup 🛠️

Step 1: Install Cobra

# Add cobra to your Go module
go get github.com/spf13/cobra@latest
Enter fullscreen mode Exit fullscreen mode

Step 2: (Optional) Install cobra-cli generator
The cobra-cli tool generates command boilerplate so you don't have to write it from scratch every time

go install github.com/spf13/cobra-cli@latest
Enter fullscreen mode Exit fullscreen mode

Step 3: Recommended project structure
Keep your CLI commands organized — one file per command inside cmd/:

myapp/
├── main.go         ← tiny entry point
├── cmd/
│   ├── root.go     ← rootCmd lives here
│   ├── serve.go    ← "serve" subcommand
│   └── version.go  ← "version" subcommand
└── go.mod
Enter fullscreen mode Exit fullscreen mode

Your First Command 🚀

main.go — keep it as thin as possible
The entry point does one thing: call cmd.Execute().

package main

import "myapp/cmd"

func main() {
    cmd.Execute()
}
Enter fullscreen mode Exit fullscreen mode

cmd/root.go — define the root command

package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "A brief description of myapp",
    Long: `myapp is a demo CLI built with Cobra.
It shows how to structure a Go CLI application.`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("Welcome to myapp! Run --help for usage.")
    },
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's run it:

~ go run . --help
myapp is a demo CLI built with Cobra.
It shows how to structure a Go CLI application.
Usage:
  myapp [flags]
  myapp [command]
Available Commands:
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  user        A brief description of your command
Flags:
  -h, --help   help for myapp
Use "myapp [command] --help" for more information about a command.
Enter fullscreen mode Exit fullscreen mode

You already get a fully formatted --help page for free. ✅

Subcommands 🌲

Subcommands are just more cobra.Command values, attached to a parent with AddCommand(). This is how you build git-style CLIs like myapp server start.

Example: nested subcommands — myapp server start / stop

package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
)

var serverCmd = &cobra.Command{
    Use:   "server",
    Short: "Manage the server",
}

var serverStartCmd = &cobra.Command{
    Use:   "start",
    Short: "Start the server",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("🚀 Server starting...")
    },
}

var serverStopCmd = &cobra.Command{
    Use:   "stop",
    Short: "Stop the server",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("🛑 Server stopped.")
    },
}

func init() {
    serverCmd.AddCommand(serverStartCmd, serverStopCmd)
    rootCmd.AddCommand(serverCmd)
}
Enter fullscreen mode Exit fullscreen mode
~ go run . server
Manage the server

Usage:
  myapp server [command]

Available Commands:
  start       Start the server
  stop        Stop the server

Flags:
  -h, --help   help for server

Use "myapp server [command] --help" for more information about a command.

~ go run . server start
🚀 Server starting...

~ go run . server stop 
🛑 Server stopped.
Enter fullscreen mode Exit fullscreen mode

Flags 🚩

Cobra supports two kinds of flags: local (only on that command) and persistent (inherited by all subcommands).

But before we get there, you'll notice Cobra has four naming patterns for every flag type. People often get confused by this, so let's clear it up first.

The 4 naming patterns

Take String as an example — the same pattern applies to every type (Int, Bool, Duration, etc.):

Method Returns Shorthand Who holds the value
String(name, value, usage) *string Cobra owns the pointer
StringP(name, short, value, usage) *string Cobra owns the pointer
StringVar(p, name, value, usage) nothing You own the pointer
StringVarP(p, name, short, value, usage) nothing You own the pointer

The naming rules are simple:

  • P suffix → adds a single-character shorthand (e.g. -n alongside --name)
  • Var suffix → you pass in your own variable; Cobra writes into it

String and StringP — Cobra gives you the pointer

var serveCmd = &cobra.Command{
    Use: "serve",
    Run: func(cmd *cobra.Command, args []string) {
        // retrieve via .GetString()
        host, _ := cmd.Flags().GetString("host")
        fmt.Println("host:", host)
    },
}

func init() {
    // String — no shorthand
    serveCmd.Flags().String("host", "localhost", "Host to bind")

    // StringP — adds -H as shorthand
    serveCmd.Flags().StringP("host", "H", "localhost", "Host to bind")
}
Enter fullscreen mode Exit fullscreen mode
~ go run . serve
host: localhost

~ go run . serve --host 127.0.0.1
host: 127.0.0.1
Enter fullscreen mode Exit fullscreen mode

You don't have a variable to read directly — you have to call cmd.Flags().GetString("name") inside Run. Fine for simple scripts, but it gets noisy fast.

StringVar and StringVarP — you own the variable

var host string // declare at package level

var serveCmd = &cobra.Command{
    Use: "serve",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("host:", host) // just use it directly ✅
    },
}

func init() {
    // StringVar — no shorthand
    serveCmd.Flags().StringVar(&host, "host", "localhost", "Host to bind")

    // StringVarP — adds -H as shorthand
    serveCmd.Flags().StringVarP(&host, "host", "H", "localhost", "Host to bind")
}
Enter fullscreen mode Exit fullscreen mode

You pass a pointer to your own variable. Cobra fills it in before Run is called. No GetString needed.

💡 In practice, always use Var / VarP. Declaring your flags as package-level variables is cleaner, easier to test, and works well with Viper bindings.

When to add P (shorthand)

Add a shorthand when the flag is frequently typed. Common conventions:

cmd.Flags().StringVarP(&output, "output", "o", "", "Output file")
cmd.Flags().StringVarP(&config, "config", "c", "", "Config file")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
cmd.Flags().IntVarP(&port, "port", "p", 8080, "Port to listen on")
Enter fullscreen mode Exit fullscreen mode

Local vs Persistent flags

Local — only visible on the command they're defined on:

func init() {
    // --port is only available on serveCmd
    serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port to listen on")
}
Enter fullscreen mode Exit fullscreen mode

Persistent — inherited by the command and all its subcommands:

func init() {
    // --verbose works on rootCmd AND every subcommand
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
}
Enter fullscreen mode Exit fullscreen mode

A common pattern is to put global flags like --config, --verbose, and --env as persistent on rootCmd, and command-specific flags like --port or --output as local on their own command.

Check if a flag was explicitly set

Sometimes you need to know whether the user actually passed a flag, or if you're just seeing the default value:

RunE: func(cmd *cobra.Command, args []string) error {
    if cmd.Flags().Changed("port") {
        fmt.Println("user explicitly set --port to", port)
    } else {
        fmt.Println("using default port:", port)
    }
    return nil
},
Enter fullscreen mode Exit fullscreen mode

This is especially useful when you want environment variables or a config file to take precedence over the default, but not over an explicit flag.

Arguments & Validation 🔒

Set the Args field on a command to validate positional arguments before Run is called.

  • MinimumNArgs
var greetCmd = &cobra.Command{
    Use:   "greet [names...]",
    Short: "Greet one or more people",
    Args:  cobra.MinimumNArgs(1), // at least one name required
    Run: func(cmd *cobra.Command, args []string) {
        for _, name := range args {
            fmt.Printf("Hello, %s!\n", name)
        }
    },
}
Enter fullscreen mode Exit fullscreen mode
~ go run . greet Dev.to Reader
Hello, Dev.to!
Hello, Reader!
Enter fullscreen mode Exit fullscreen mode
  • OnlyValidArgs
var greetCmd = &cobra.Command{
    Use:       "greet [names...]",
    Short:     "Greet one or more people",
    ValidArgs: []string{"Dev.to"},
    Args:      cobra.OnlyValidArgs,
    Run: func(cmd *cobra.Command, args []string) {
        for _, name := range args {
            fmt.Printf("Hello, %s!\n", name)
        }
    },
}
Enter fullscreen mode Exit fullscreen mode
~ go run . greet Dev.to Reader
Error: invalid argument "Reader" for "myapp greet"
Usage:
  myapp greet [names...] [flags]

Flags:
  -h, --help   help for greet

invalid argument "Reader" for "myapp greet"
exit status 1
Enter fullscreen mode Exit fullscreen mode
Validator Meaning
ArbitraryArgs Any number of arguments (default)
MinimumNArgs(n) At least n arguments
MaximumNArgs(n) At most n arguments
ExactArgs(n) Exactly n arguments
RangeArgs(min, max) Between min and max
OnlyValidArgs Only values listed in ValidArgs

Lifecycle Hooks ⚙️

Cobra executes hooks in a well-defined order around your main Run function. This is useful for things like loading config, validating auth, or sending notifications.

var deployCmd = &cobra.Command{
    Use: "deploy",

    // 1. Runs before PreRun of this AND all children
    PersistentPreRun: func(cmd *cobra.Command, args []string) {
        fmt.Println("[1] PersistentPreRun — loading config...")
    },
    // 2. Runs just before Run (not inherited)
    PreRun: func(cmd *cobra.Command, args []string) {
        fmt.Println("[2] PreRun — validating credentials...")
    },
    // 3. The main logic
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("[3] Run — deploying...")
    },
    // 4. Runs after Run (not inherited)
    PostRun: func(cmd *cobra.Command, args []string) {
        fmt.Println("[4] PostRun — sending notification...")
    },
    // 5. Runs after PostRun for this AND all children
    PersistentPostRun: func(cmd *cobra.Command, args []string) {
        fmt.Println("[5] PersistentPostRun — cleanup done.")
    },
}
Enter fullscreen mode Exit fullscreen mode
~ go run . deploy
[1] PersistentPreRun — loading config...
[2] PreRun — validating credentials...
[3] Run — deploying...
[4] PostRun — sending notification...
[5] PersistentPostRun — cleanup done.
Enter fullscreen mode Exit fullscreen mode

💡 Tip
Use the E-suffix variants (PersistentPreRunE, RunE, etc.) when a hook can fail — they return an error and stop execution if it's non-nil.

Error Handling 🛡️

Use RunE to return errors

var fetchCmd = &cobra.Command{
    Use:  "fetch [url]",
    Args: cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        resp, err := http.Get(args[0])
        if err != nil {
            return fmt.Errorf("fetch failed: %w", err)
        }
        defer resp.Body.Close()
        fmt.Printf("Status: %s\n", resp.Status)
        return nil
    },
}
Enter fullscreen mode Exit fullscreen mode
~ go run . fetch a
Error: fetch failed: Get "a": unsupported protocol scheme ""
Usage:
  myapp fetch [url] [flags]

Flags:
  -h, --help   help for fetch

fetch failed: Get "a": unsupported protocol scheme ""
exit status 1
Enter fullscreen mode Exit fullscreen mode

Optionally suppress Cobra's duplicate "Error: ..." print

var rootCmd = &cobra.Command{
    Use: "myapp",

    SilenceErrors: true, // quiet errors down stream
    SilenceUsage:  true, // silence usage when an error occurs

    Short: "A brief description of myapp",
    Long: `myapp is a demo CLI built with Cobra.
It shows how to structure a Go CLI application.`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("Welcome to myapp! Run --help for usage.")
    },
}
Enter fullscreen mode Exit fullscreen mode
~ go run . fetch a    
fetch failed: Get "a": unsupported protocol scheme ""
exit status 1
Enter fullscreen mode Exit fullscreen mode

📝 Conclusion

Cobra is the gold standard for building CLI tools in Go. It takes care of all the boring stuff — help pages, flag parsing, shell completion — so you can focus on what your tool actually does.

Here's what we covered:

  • ✅ Installing Cobra and setting up a project
  • ✅ Creating root and subcommands
  • ✅ Flags
  • ✅ Argument validation
  • ✅ Lifecycle hooks
  • ✅ Error handling with RunE

The next step? Try pairing Cobra with Viper for configuration management — they're built to work together. Happy coding! 🥂

Support My Work ☕

If you enjoy my work, consider buying me a coffee! Your support helps me keep creating valuable content and sharing knowledge. ☕

Buy Me A Coffee

Top comments (0)