Skip to main content
/ Tutorial

Go CLI with Cobra & Viper: Zero to Hero Guide (2026)

Sacha Roussakis-NotterSacha Roussakis-Notter
22 min read
GoGo
CobraCobra
Share

Build professional command-line tools in Go using Cobra and Viper. Complete guide with copy-paste commands, cheat sheets, testing patterns, and production-ready examples.

Why Go for CLI Tools?

Go is the language of choice for modern CLI tools. Kubernetes (kubectl), Docker, GitHub CLI (gh), Terraform, and Hugo are all built with Go and Cobra.

This guide takes you from zero to production-ready CLI. By the end, you'll understand how to structure commands, handle configuration from multiple sources, validate input, write tests, and ship cross-platform binaries.

What we'll build: A task management CLI called "taskmaster" with commands for adding, listing, and completing tasks.

Prerequisites Checklist

Before starting, ensure you have everything set up. Don't worry if you're missing something - the installation tabs below will help you get started.

Environment Setup
0/5
0% completebuun.group

Quick Reference Cheat Sheet

If you just need to get Go and Cobra installed quickly, use these platform-specific commands. We'll explain everything in detail as we build.

Installation Commands

Install Go
# Install via Homebrew
brew install go

# Verify installation
go version
4 optionsbuun.group

Install Cobra CLI

The cobra-cli tool generates boilerplate code for new projects and commands. It's optional but saves significant time when scaffolding.

Install cobra-cli
# Install the Cobra CLI generator
$go install github.com/spf13/cobra-cli@latest
# Verify installation
$cobra-cli --version
cobra-cli version v1.8.0
2 commandsbuun.group

Part 1: Project Setup

Let's create our taskmaster CLI from scratch. Cobra uses a specific project structure that keeps commands organized and makes the codebase easy to navigate as it grows.

The key insight is that every command is a separate file in the cmd/ directory. This keeps things modular - you can add new commands without touching existing code.

Create Your CLI Project
1 / 3

1Create Project Directory

Create a new directory and initialize a Go module.

bash
mkdir taskmaster && cd taskmaster
go mod init github.com/yourusername/taskmaster
Step 1 of 3
3 stepsbuun.group

Generated Project Structure

After running cobra-cli init, you'll have a minimal but complete CLI structure. The main.go file is intentionally simple - it just calls cmd.Execute(). All the interesting logic lives in the cmd/ package.

taskmaster/
taskmaster
cmd/
root.go// Root command (entry point)
go.mod// Module definition
go.sum// Dependency checksums
LICENSE
main.go// Just calls cmd.Execute()
6 items at rootbuun.group

Understanding the Root Command

The root command is the foundation of your CLI. When users run taskmaster without any subcommand, this is what executes. It's also where you define global flags that should be available to all subcommands.

cmd/root.go explainedgo
7 annotations
1package cmd
2
3import (
4 "os"
5 "github.com/spf13/cobra"
6)
7
8var rootCmd = &cobra.Command{
9 Use: "taskmaster",
10 Short: "A task management CLI tool",
11 Long: "Taskmaster helps you manage tasks...",
12}
13
14func Execute() {
15 err := rootCmd.Execute()
16 if err != nil {
17 os.Exit(1)
18 }
19}
20
21func init() {
22 rootCmd.PersistentFlags().StringP("config", "c", "", "config file")
23 rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
24}
Click annotations to highlight codebuun.group

Add Commands

Now let's add the commands that make our CLI useful. Each cobra-cli add creates a new file with boilerplate code that you'll customize.

Adding Commands
# Creates taskmaster add
$cobra-cli add add
# Creates taskmaster list
$cobra-cli add list
# Creates taskmaster complete
$cobra-cli add complete
# Creates taskmaster config
$cobra-cli add config
4 commandsbuun.group

Part 2: Building Commands

With our project structure in place, let's implement actual functionality. Each command follows a similar pattern: define the command struct, implement the handler logic, and register it with its parent command.

The most important decision is choosing between Run and RunE. Always prefer `RunE` - it lets you return errors that Cobra handles gracefully, setting appropriate exit codes and printing error messages.

Basic Command Structure

Here's a complete example of the add command. Notice how we define flags in init() but read their values inside RunE. This is important because flag parsing happens between these two phases.

cmd/add.go - Command with flagsgo
6 annotations
1package cmd
2
3import (
4 "fmt"
5 "github.com/spf13/cobra"
6)
7
8var addCmd = &cobra.Command{
9 Use: "add [task name]",
10 Short: "Add a new task",
11 Long: "Add a new task with optional priority and due date.",
12 Args: cobra.MinimumNArgs(1),
13 RunE: func(cmd *cobra.Command, args []string) error {
14 priority, _ := cmd.Flags().GetString("priority")
15 dueDate, _ := cmd.Flags().GetString("due")
16 taskName := args[0]
17
18 fmt.Printf("Adding task: %s\n", taskName)
19 fmt.Printf("Priority: %s\n", priority)
20 if dueDate != "" {
21 fmt.Printf("Due: %s\n", dueDate)
22 }
23 return nil
24 },
25}
26
27func init() {
28 rootCmd.AddCommand(addCmd)
29 addCmd.Flags().StringP("priority", "p", "medium", "task priority")
30 addCmd.Flags().StringP("due", "d", "", "due date (YYYY-MM-DD)")
31}
Click annotations to highlight codebuun.group

Cobra supports many flag types out of the box. The pattern is always the same: declare the flag in init(), then retrieve its value in your command handler. Here's a quick reference for the most common types:

Flag Types Cheat Sheet

Common Cobra/Viper flag patterns

String flagcommon
cmd.Flags().StringP("name", "n", "default", "description")
Get string value
viper.GetString("name")
Bool flagcommon
cmd.Flags().BoolP("verbose", "v", false, "description")
Get bool value
viper.GetBool("verbose")
Int flag
cmd.Flags().IntP("count", "c", 10, "description")
Get int value
viper.GetInt("count")
Duration flagadvanced
cmd.Flags().DurationP("timeout", "t", 30*time.Second, "desc")
Get duration
viper.GetDuration("timeout")
String sliceadvanced
cmd.Flags().StringSliceP("tags", "t", []string{}, "desc")
Get string slice
viper.GetStringSlice("tags")
→ Usage: --tags foo,bar
Required flagrequired
cmd.MarkFlagRequired("name")
Persistent flagtip
cmd.PersistentFlags().String("config", "", "desc")
3 sections • 12 itemsbuun.group

Part 3: Viper Configuration

While Cobra handles command structure and flags beautifully, real-world CLIs need more sophisticated configuration. Users want to set defaults in config files, override them with environment variables, and override those with command-line flags.

This is where Viper shines. It's the standard companion to Cobra, handling the complexity of merging configuration from multiple sources.

Setup Viper in Root Command

The magic of Viper is in the initConfig() function. This runs before any command executes, loading configuration from files, binding environment variables, and setting defaults. The key function is viper.BindPFlag() - it connects your Cobra flags to Viper so that viper.GetString() returns the right value regardless of where it came from.

cmd/root.go with Vipergo
5 annotations
1package cmd
2
3import (
4 "fmt"
5 "os"
6 "github.com/spf13/cobra"
7 "github.com/spf13/viper"
8)
9
10var cfgFile string
11
12func init() {
13 cobra.OnInitialize(initConfig)
14 rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
15 rootCmd.PersistentFlags().StringP("output", "o", "text", "output format")
16 viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
17}
18
19func initConfig() {
20 if cfgFile != "" {
21 viper.SetConfigFile(cfgFile)
22 } else {
23 home, _ := os.UserHomeDir()
24 viper.AddConfigPath(home)
25 viper.AddConfigPath(".")
26 viper.SetConfigType("yaml")
27 viper.SetConfigName(".taskmaster")
28 }
29
30 viper.SetEnvPrefix("TASKMASTER")
31 viper.AutomaticEnv()
32 viper.SetDefault("output", "text")
33
34 if err := viper.ReadInConfig(); err == nil {
35 fmt.Fprintln(os.Stderr, "Using config:", viper.ConfigFileUsed())
36 }
37}
Click annotations to highlight codebuun.group

Example Config File

Users can create a config file to set their preferences. Viper automatically searches common locations like the home directory and current directory. YAML is the most popular format, but Viper also supports JSON, TOML, and even Java properties files.

yaml
1# ~/.taskmaster.yaml
2output: json
3verbose: true
4
5database:
6 path: ~/.taskmaster.db
7
8defaults:
9 priority: medium
10 tags:
11 - work
12
13notifications:
14 enabled: true
15 email: you@example.com

Part 4: Configuration Priority

Understanding how Viper resolves conflicting values is crucial. If a user sets output: json in their config file but passes --output=text on the command line, which wins?

Viper resolves configuration in this order (highest to lowest priority):

flowchart

--output=json

TASKMASTER_OUTPUT=json

output: json

viper.SetDefault

1. Command-line flags
2. Environment variables
3. Config file values
4. Default values

Final Value

Ctrl+scroll to zoom • Drag to pan47%

Priority Quick Reference

Flag vs Vipergo
33
1-// ❌ WRONG - Only reads flag value
2-output, _ := cmd.Flags().GetString("output")
3-// Ignores config file and env vars!
4+// ✅ CORRECT - Full priority chain
5+output := viper.GetString("output")
6+// Flag > Env > Config > Default
3 → 3 linesbuun.group

Part 5: Argument Validation

Good CLIs fail fast with helpful error messages. Cobra's argument validators let you enforce constraints declaratively rather than writing boilerplate validation code.

The difference between flags and arguments is important: flags are named (--priority high) while arguments are positional (taskmaster add "Buy milk"). Validators help ensure users provide the right number and type of arguments.

Argument Validators

Built-in Cobra argument validation

No arguments
Args: cobra.NoArgs
Exactly N argscommon
Args: cobra.ExactArgs(2)
Minimum N argscommon
Args: cobra.MinimumNArgs(1)
Maximum N args
Args: cobra.MaximumNArgs(3)
Range of args
Args: cobra.RangeArgs(1, 3)
Only valid args
Args: cobra.OnlyValidArgs
→ ValidArgs: []string{...}
6 itemsbuun.group

Custom Validator Example

Sometimes built-in validators aren't enough. You might need to validate that an argument is a valid email, a positive number, or matches a specific pattern. Custom validators give you full control:

go
1var completeCmd = &cobra.Command{
2 Use: "complete [task-id]",
3 Short: "Mark a task as complete",
4 Args: func(cmd *cobra.Command, args []string) error {
5 if len(args) != 1 {
6 return fmt.Errorf("requires exactly 1 task ID")
7 }
8 // Validate it's a number
9 if _, err := strconv.Atoi(args[0]); err != nil {
10 return fmt.Errorf("task ID must be a number: %s", args[0])
11 }
12 return nil
13 },
14 RunE: func(cmd *cobra.Command, args []string) error {
15 taskID, _ := strconv.Atoi(args[0])
16 fmt.Printf("Completing task #%d\n", taskID)
17 return nil
18 },
19}

Part 6: Testing Your CLI

Testing CLI applications can be tricky. You need to capture output, simulate user input, and verify exit codes. The key is designing your commands for testability from the start.

The biggest mistake is writing directly to os.Stdout. Instead, accept an io.Writer that your code writes to. In production, pass os.Stdout. In tests, pass a bytes.Buffer you can inspect.

Testing Pattern: Constructor Function

Instead of defining commands as global variables, use constructor functions. This makes dependency injection possible and your commands testable in isolation.

Testable Command Patterngo
3 annotations
1// cmd/add.go
2func NewAddCmd(out io.Writer) *cobra.Command {
3 cmd := &cobra.Command{
4 Use: "add [task]",
5 Short: "Add a new task",
6 Args: cobra.MinimumNArgs(1),
7 RunE: func(cmd *cobra.Command, args []string) error {
8 priority, _ := cmd.Flags().GetString("priority")
9 fmt.Fprintf(out, "Added: %s (%s)\n", args[0], priority)
10 return nil
11 },
12 }
13 cmd.Flags().StringP("priority", "p", "medium", "priority")
14 return cmd
15}
16
17func init() {
18 rootCmd.AddCommand(NewAddCmd(os.Stdout))
19}
Click annotations to highlight codebuun.group

Test File

Now writing tests is straightforward. Use table-driven tests (Go's idiomatic pattern) to cover multiple scenarios. Each test case creates its own buffer, executes the command, and verifies the output.

go
1// cmd/add_test.go
2func TestAddCmd(t *testing.T) {
3 tests := []struct {
4 name string
5 args []string
6 expected string
7 wantErr bool
8 }{
9 {
10 name: "add with default priority",
11 args: []string{"Buy groceries"},
12 expected: "Added: Buy groceries (medium)\n",
13 },
14 {
15 name: "add with high priority",
16 args: []string{"--priority", "high", "Fix bug"},
17 expected: "Added: Fix bug (high)\n",
18 },
19 {
20 name: "missing task name",
21 args: []string{},
22 wantErr: true,
23 },
24 }
25
26 for _, tt := range tests {
27 t.Run(tt.name, func(t *testing.T) {
28 buf := new(bytes.Buffer)
29 cmd := NewAddCmd(buf)
30 cmd.SetArgs(tt.args)
31
32 err := cmd.Execute()
33
34 if (err != nil) != tt.wantErr {
35 t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
36 }
37 if !tt.wantErr && buf.String() != tt.expected {
38 t.Errorf("got %q, want %q", buf.String(), tt.expected)
39 }
40 })
41 }
42}

Run Tests

Go's testing is fast - you can run the entire test suite in milliseconds. Use -v for verbose output when debugging, and -cover to track code coverage.

terminal
${
$"title": "Test Commands",
$"commands": [
${ "command": "go test ./...", "comment": "Run all tests" },
${ "command": "go test -v ./cmd/...", "comment": "Verbose output" },
${ "command": "go test -cover ./...", "comment": "With coverage" },
${ "command": "go test -coverprofile=coverage.out ./...", "output": "ok github.com/you/taskmaster/cmd 0.015s coverage: 85.2%", "comment": "Generate coverage report" }
$]
$}
9 commandsbuun.group

Part 7: Production Features

Before shipping your CLI, you'll want a few production essentials: version information, proper build automation, and cross-platform binaries. Let's add these finishing touches.

Version Command

Every CLI should have a version command. The trick is embedding build information at compile time using Go's linker flags (-ldflags). This way, the version is baked into the binary.

go
1// cmd/version.go
2var (
3 version = "dev"
4 commit = "none"
5 buildDate = "unknown"
6)
7
8var versionCmd = &cobra.Command{
9 Use: "version",
10 Short: "Print version information",
11 Run: func(cmd *cobra.Command, args []string) {
12 fmt.Printf("taskmaster %s\n", version)
13 fmt.Printf(" Commit: %s\n", commit)
14 fmt.Printf(" Built: %s\n", buildDate)
15 fmt.Printf(" Go: %s\n", runtime.Version())
16 },
17}

Build with Version Info

Here's how to inject version information at build time. These commands set shell variables and pass them to the Go linker. In a real project, you'd put this in a Makefile or CI script.

Build with ldflags
# Set version
$VERSION=1.0.0
# Get commit hash
$COMMIT=$(git rev-parse --short HEAD)
# Build timestamp
$DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Build with embedded version
$go build -ldflags "-X cmd.version=$VERSION -X cmd.commit=$COMMIT -X cmd.buildDate=$DATE" -o taskmaster
4 commandsbuun.group

Cross-Compilation

One of Go's killer features is effortless cross-compilation. Set two environment variables (GOOS and GOARCH) and Go builds a binary for that platform - no virtual machines or complex toolchains required.

Build for Different Platforms
GOOS=linux GOARCH=amd64 go build -o taskmaster-linux
4 optionsbuun.group

Complete Project Structure

Here's what a production-ready Cobra CLI looks like. Notice the internal/ directory - this is a Go convention for code that shouldn't be imported by other packages. Keep your business logic here, separate from command definitions.

Production-Ready Structure
taskmaster
cmd/
root.go// Root command + Viper init
root_test.go
add.go
add_test.go
list.go
complete.go
config.go
version.go
completion.go// Shell completions
internal/
store/
store.go// Storage interface
file.go
store_test.go
task/
task.go// Task model
task_test.go
go.mod
go.sum
main.go
Makefile// Build automation
README.md
18 items at rootbuun.group

Quick Commands Reference

Keep this handy while developing. These are the commands you'll use daily when building and testing your CLI.

Go CLI Quick Reference

Essential commands for Cobra development

Create projectcommon
go mod init && cobra-cli init
Add commandcommon
cobra-cli add commandName
Run locally
go run main.go
Build binary
go build -o mycli
Run testscommon
go test -v ./...
Coverage
go test -cover ./...
Install globally
go install
Format code
go fmt ./...
Lint
golangci-lint run
9 itemsbuun.group

Knowledge Check

You've covered a lot of ground. Test your understanding with these questions - they focus on the concepts that trip up most developers.

Cobra & Viper Quiz
1/4

What is the correct way to read a configuration value that respects flags, env vars, and config files?

Interactive quizbuun.group

Glossary

New to Go or CLI development? Here are the key terms you'll encounter in this guide and beyond.

CLI Development Terms
7 terms
C
L
P
R
V
Click terms to expandbuun.group

Build great CLIs. Ship single binaries. Make users happy.

Need help building Go CLI tools?

Topics

Go CLI tutorial 2026Golang Cobra tutorialViper configuration GoGo command line toolCobra CLI testingGo CLI best practicescobra-cli initGolang CLI framework

Share this post

Share

Comments

Sign in to join the conversation

Login

No comments yet. Be the first to share your thoughts!

Found an issue with this article?

/ Let's Talk

Want to work with us?

Whether you need help with architecture, development, or technical consulting, our team is here to help bring your vision to life.