October DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsWindows FixRecommendedWindows errors stealing your time? Find the fix fastScan stability, cleanup and performance issues.Fix NowOctober DealsAmazon USDeal season is back - check today's better picksAmazon US: current deals, useful picks and tech finds.See Picks×
Skip to content
World desk9 min

Building a Go CLI Tool with Cobra and Configuration Management

A practical guide to building a multi-command Go CLI with Cobra, using Viper for flags, environment variables and config files, with Viper's precedence order and the error handling that keeps configuration predictable.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A Go command-line tool with several subcommands and layered configuration works best when Cobra owns the command tree and flags, Viper merges the configuration sources, and the rest of your application receives one typed configuration struct instead of reading Viper directly. Viper’s documented precedence runs from explicit Set calls, through bound flags and environment variables, to config files, key/value stores and finally defaults. The steps below build that layout in a small example tool called mytool.

Before you start, note that the code is illustrative rather than pinned to a release. Add Cobra and Viper to your module with a specific version recorded in go.mod, and check the current Cobra and Viper documentation for API changes. The official project documentation was accessed on 7 October 2026, and repositories change.

As an Amazon Associate I earn from qualifying purchases.

Set up the project and generate the command skeleton

Create a module and fetch the two libraries. Cobra’s README describes an optional generator, cobra-cli, that writes the boilerplate for you. Install it at a tagged version of your choice and run the following from an empty directory.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Initialise the module: go mod init example.com/mytool.

  2. Fetch the libraries at the versions you want to pin: go get github.com/spf13/cobra and go get github.com/spf13/viper, then review the resulting entries in go.mod.

  3. Generate the root command: cobra-cli init --pkg-name example.com/mytool.

  4. Generate a subcommand: cobra-cli add serve.

The layout looks like this:

mytool/
├── go.mod
├── main.go
└── cmd/
    ├── root.go
    └── serve.go

The Cobra guide presents cmd/ with a main.go that only calls into the command package as a common convention, not a requirement. Keep main.go that small so the command tree is testable.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Local and persistent flags

Cobra has two kinds of flag, and the choice determines which commands can see them.

Local flags

A local flag belongs to one command only. Define it on serve with serveCmd.Flags(), and mytool serve --port 9000 works while mytool --port 9000 does not. A child command does not parse a parent’s local flags by default; the Cobra guide documents TraverseChildren for the case where you want that behaviour.

Persistent flags

A persistent flag is defined on a command and inherited by its descendants. Settings that every subcommand needs, such as the config file path and log level, belong on the root command’s PersistentFlags().

Cobra also supports constraints between flags, including required flags, flags that must be given together and mutually exclusive flags. Use these functions when a combination of options is invalid, instead of checking the combination by hand inside RunE.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Where configuration comes from, and in what order

Viper merges values from several sources and returns the highest-ranked one. The table lists the Viper README’s precedence, from highest to lowest, with what each source means for a CLI.

Rank Source How it is set or found Changes per invocation? Typical use in a CLI
1 Explicit Set call viper.Set("log.level", "debug") in code Only when code runs it Tests and programmatic overrides
2 Bound flag Bound with BindPFlag or BindPFlags Yes, when the user passes the flag One-off overrides typed at the terminal
3 Environment variable Read on each access, using the prefix and key mapping you configure Yes, per shell or process CI jobs, containers, secrets supplied by a platform
4 Config file An explicit path from --config, or a search name in configured directories No, it is read once at startup Shared, persistent settings for a user or project
5 Key/value store External store supported by Viper Depends on the store Not used in this tutorial
6 Default viper.SetDefault No Fallback values when nothing else is set

Viper keys are case-insensitive, while environment variable names are case-sensitive. Use lowercase, dot-separated keys such as log.level and map them to environment names explicitly.

Keep flag defaults out of the picture if a config file should be able to win. A flag’s own default is the value Cobra uses when the flag is not passed, and it sits at the flag layer, above the config file. Put the fallback in viper.SetDefault instead, so it sits at the bottom of the stack.

Wire Cobra to Viper

The root command registers a single initialisation function with cobra.OnInitialize. Cobra runs it before a command executes, after flags are parsed, so the config file and environment are ready when your commands read them. Replace the generated cmd/root.go with the following.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
package cmd

import (
	"errors"
	"fmt"
	"os"
	"strings"

	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

var cfgFile string

var rootCmd = &cobra.Command{
	Use:           "mytool",
	Short:         "Example tool with layered configuration",
	Long:          "mytool runs commands configured by flags, environment variables, or a config file.",
	SilenceUsage:  true,
	SilenceErrors: true,
}

// Execute runs the root command and prints any returned error once.
func Execute() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

func init() {
	cobra.OnInitialize(initConfig)

	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path")
	rootCmd.PersistentFlags().String("log-level", "info", "log level: debug, info, warn, error")

	if err := viper.BindPFlag("log.level", rootCmd.PersistentFlags().Lookup("log-level")); err != nil {
		panic(err)
	}
}

func initConfig() {
	viper.SetEnvPrefix("MYTOOL")
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
	viper.AutomaticEnv()

	if cfgFile != "" {
		viper.SetConfigFile(cfgFile)
	} else {
		home, err := os.UserHomeDir()
		cobra.CheckErr(err)
		viper.AddConfigPath(home)
		viper.SetConfigName(".mytool")
	}

	if err := viper.ReadInConfig(); err != nil {
		var notFound viper.ConfigFileNotFoundError
		if cfgFile != "" || !errors.As(err, &notFound) {
			cobra.CheckErr(fmt.Errorf("reading config: %w", err))
		}
	}
}

Three details matter here. The MYTOOL prefix and the replacer turn log.level into MYTOOL_LOG_LEVEL. The config file is searched for as .mytool.yaml in the home directory, following the Cobra guide’s example of a dot-prefixed file in the home directory; this is an example, not a default you must copy. Viper reads a single config file, so the search order is the only thing that decides which file is used.

Config file discovery and the error rule

Treat a missing file and an unreadable or malformed file as different situations. The guide’s approach, which the code above follows, is this:

Swallowing every error from ReadInConfig hides typos in a config file until the value is silently ignored, which is the most common support question for this kind of tool. After a successful read, viper.ConfigFileUsed() returns the path that was loaded, which is useful for a --verbose message or for a config subcommand that reports where settings came from.

Environment variables and key mapping

Environment variables are read every time they are accessed rather than cached, so changes take effect without a restart of the library state, though the process itself still has to be started with the new value. An empty variable is treated as unset unless you call viper.AllowEmptyEnv(true). Name each mapping on purpose:

Config key Environment variable Config file form How the name is produced
log.level MYTOOL_LOG_LEVEL log: then level: nested beneath it Prefix, then the replacer turns dots into underscores
server.port MYTOOL_SERVER_PORT server: then port: nested beneath it Same rule as above
api_token MYTOOL_API_TOKEN api_token: at the top level Explicit BindEnv call, shown below

The unmarshalling gotcha

The spf13 Go skills guide, an implementation guide from the project skills repository rather than Cobra itself, warns that AutomaticEnv combined with viper.Unmarshal can miss a key that exists only in the environment. Viper builds the struct from the keys it knows about. A key that is never defined, bound or defaulted may be invisible to Unmarshal, even when the environment variable is set. Register such keys explicitly with BindEnv (or with a default) before unmarshalling.

Passing configuration to application code

The same skills guide recommends unmarshalling once into a typed struct and passing that struct down, instead of letting every package call Viper’s global getters. The benefit is that business logic can be tested with an ordinary struct value. Define the struct in cmd/config.go.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
package cmd

type Config struct {
	Log      LogConfig    `mapstructure:"log"`
	Server   ServerConfig `mapstructure:"server"`
	APIToken string       `mapstructure:"api_token"`
}

type LogConfig struct {
	Level string `mapstructure:"level"`
}

type ServerConfig struct {
	Port int `mapstructure:"port"`
}

Now add the serve command, which owns its port flag and the explicit token binding.

package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

var serveCmd = &cobra.Command{
	Use:   "serve [flags]",
	Short: "Start the HTTP server",
	Long:  "serve starts the HTTP server. Flags override environment variables, which override the config file.",
	Args:  cobra.NoArgs,
	RunE: func(cmd *cobra.Command, args []string) error {
		var c Config
		if err := viper.Unmarshal(&c); err != nil {
			return fmt.Errorf("decoding configuration: %w", err)
		}
		return runServer(c)
	},
}

func init() {
	rootCmd.AddCommand(serveCmd)

	serveCmd.Flags().Int("port", 8080, "port to listen on")
	if err := viper.BindPFlag("server.port", serveCmd.Flags().Lookup("port")); err != nil {
		panic(err)
	}
	if err := viper.BindEnv("api_token", "MYTOOL_API_TOKEN"); err != nil {
		panic(err)
	}
}

// runServer is application code. It receives a Config and never calls Viper.
func runServer(c Config) error {
	fmt.Printf("listening on port %d at log level %sn", c.Server.Port, c.Log.Level)
	return nil
}

Binding server.port to the flag means the flag’s value takes precedence over MYTOOL_SERVER_PORT and over server.port in the config file. Binding does not copy values into the Go variable behind a flag, so read merged values through the typed struct, not through the variable you passed to a flag function.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Errors and exit behaviour

Return errors from RunE rather than calling os.Exit inside command logic, so the top-level Execute decides how to report them. Setting SilenceUsage to true keeps a runtime failure from printing the full help text after the error, and SilenceErrors stops Cobra from printing the error a second time because Execute already prints it. Wrap errors with %w so callers can still inspect them with errors.Is or errors.As.

Help text, documentation and shell completion

Cobra generates help for every command and a help command for applications with subcommands. The text you write in Use, Short, Long and each flag’s usage string becomes the interface users read, so write those fields as complete sentences. The Cobra README puts the principle this way: “The best applications read like sentences when used, and as a result, users intuitively know how to interact with them.”

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The Cobra guide also describes generating command documentation and shell completion scripts for Bash, Zsh, Fish and PowerShell. For example, mytool completion bash prints a Bash script that you then load according to your shell’s configuration. Check mytool completion --help in your version, since the subcommand names come from the Cobra release you pin.

Troubleshooting checklist

Keep secrets such as api_token out of files you commit to version control. Supply them through environment variables or a secret manager your platform provides, and keep the config file for settings that are safe to share.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
Windows Errors? Fix Them Before They SpreadFree repair scan
Outdated Drivers Are Slowing You DownFree scan - exact matches

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.