JavaRush /Courses /Go SELF /Exit codes in CLI: help=0, usage=2, runtime/internal=1

Exit codes in CLI: help=0, usage=2, runtime/internal=1

Go SELF
Level 50, Lesson 1
Available

1. What an exit code is and why it matters more than it seems

When we write a CLI, it is very easy to think only about what the program prints to the screen. But in reality it has one more “communication channel” with the outside world: the process exit code (exit code). It is read by the shell, scripts, CI, and other programs. And if you agree on the codes “somehow later”, later it turns out that “somehow” is the most expensive option.

Imagine a user runs your todo from a bash script or a Makefile. The script usually does not want to parse your nicely formatted messages. It needs a simple answer: “success” or “error”, and ideally which type of error. And this is where the exit code becomes your honest, short, machine-readable contract.

Within the course, we lock the agreement down strictly and without creativity:

Scenario What happened Exit code
help the user asked for help (-h, help)
0
usage invalid invocation: arguments/flags/startup validation
2
runtime/internal the invocation was correct, but execution failed (I/O, unexpected failure)
1

Why exactly like this? Because it is predictable, it is the typical “unix way”, and it lets scripts distinguish “user made a mistake” from “the program is broken”.

2. One agreement 0/1/2: help, usage, and runtime

Help is success

At first, many people are surprised: “Wait, I ran todo -h, and the program didn’t do anything—why is that 0?” Because help is not an error; it is a normal scenario. The user asked for information—the program provided information. That’s it. Successful completion.

If you return 2 or 1 for -h, you create weird UX: in a script, todo -h will look like a failure. Even worse, some tools will start thinking your program “crashes” and will highlight it as a problem.

There is a nuance here specifically with the Go package flag. In ContinueOnError mode, the flag parser does not terminate the process on its own; it returns an error. And when the user asks for help (usually -h), flag returns a special error flag.ErrHelp. This is not a “usage error” in the human sense; it is a signal: help was shown or should be shown. Therefore, we must handle it separately and exit with code 0.

We are already used to the idea that “errors are values”. flag.ErrHelp is exactly such an example: it is a value that we use to decide which branch to take.

os.Exit only at the top level

If you do one thing right in a CLI application, let it be this: don’t scatter os.Exit(...) throughout the code. Give yourself a chance to test commands, reuse functions, and avoid turning the application into a set of “jumps out the window” (and os.Exit is exactly that jump, just without the romance).

The right style: almost all code returns error (or sometimes (T, error)), and the decision “which exit code to set” is made by one single layer—usually main().

This matches the Go approach to error handling well: “checked → if error, return upward”. This style makes control flow simple and readable.

The minimal scaffold for todo looks like this:

package main

import (
	"os"
)

const (
	exitOK    = 0
	exitFail  = 1
	exitUsage = 2
)

func main() {
	code := run(os.Args[1:])
	os.Exit(code)
}

func run(args []string) int {
	_ = args
	return exitOK
}

What matters here is that run is a “normal function”: you can test it, you can pass any arguments to it, and it does not terminate the process by itself.

Usage vs runtime/internal: how to distinguish error classes

From the outside, any error is an error. But our contract requires us to distinguish at least two kinds of “bad”: usage (2) and runtime/internal (1). Trying to tell them apart by the error text is a fast path into a world of pain, because the string changes, gets localized, formatted, and generally lives its own life.

In Go, it is much more reliable to distinguish errors by type. This is a direct continuation of the idea “errors are values”: a value can have a concrete type, and that type carries meaning.

Let’s create our own type usageError:

package main

type usageError struct {
	msg string
}

func (e usageError) Error() string {
	return e.msg
}

Now we can decide centrally:

package main

func exitCode(err error) int {
	if err == nil {
		return exitOK
	}
	if _, ok := err.(usageError); ok {
		return exitUsage
	}
	return exitFail
}

Even at this level there is a nice effect: you stop depending on how errors are worded and you don’t glue the architecture together “on strings”.

How to correctly account for help: flag.ErrHelp

Help is a separate scenario, and in Go it often “pops up” via flag.ErrHelp if you use flag.ContinueOnError.

The minimal pattern is: parse flags, and if help came in, say “this is success” and return nil (or a separate successful result) so that the exit code is 0.

package main

import (
	"errors"
	"flag"
	"os"
)

func parseRootFlags(argv []string) (showHelp bool, err error) {
	fs := flag.NewFlagSet("todo", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	_ = fs.Bool("h", false, "show help") // just so the example is self-contained

	if err := fs.Parse(argv); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			return true, nil
		}
		return false, usageError{msg: err.Error()}
	}

	return false, nil
}

Note an important point: if this is not help but a real parsing problem, we return usageError, so that at the top level it is guaranteed to become exit code 2.

What we print, what we return, what we encode

In a CLI there is always a temptation: “print the error here, print it there, and at the top print it again just in case”. The result is a triple echo of the same problem. This annoys users and makes output unpredictable.

It is useful to fix a simple protocol:

  • Help/usage text is usually printed to stderr, because it is not “command result data” but service information.
  • Command result text (for example, a task list) goes to stdout.
  • Errors also usually go to stderr.

But the exit code is set after we have printed everything, in one place.

The diagram looks like this:

flowchart TD
    A[main] --> B["run(args)"]
    B --> C{help?}
    C -->|yes| D[print help to stderr]
    D --> E["return exitOK(0)"]
    C -->|no| F{err?}
    F -->|nil| G[print result to stdout]
    G --> H["return exitOK(0)"]
    F -->|usageError| I[print short error + usage to stderr]
    I --> J["return exitUsage(2)"]
    F -->|other error| K[print error to stderr]
    K --> L["return exitFail(1)"]

Why you can’t “slightly tweak” the 0/1/2 agreement

In a CLI there is a dangerous illusion: “okay, here we’ll return 3, here 4, here 10—looks nicer”. The problem is that if your user is a human, they probably won’t even notice the difference between 1 and 10. But if your user is a script, it expects stability.

The 0/1/2 agreement is the minimal common language that you can later expand (if you really need to), but within a learning application we explicitly forbid “drift”. It disciplines the architecture: you start thinking not “which code do I want”, but “which class of error do I actually have”.

One more important point: help must be 0 always. Even if help is printed “in response to an error” (for example, an invalid flag), it still does not mean help is a failure. The failure is the invalid invocation, and that is 2. Help is just a hint that can accompany 2, but it does not turn into an error by itself because of that.

3. Example: todo with a command dispatcher

Let’s assemble the pieces into a clear application “skeleton”. Imagine we already have commands add and list, and today we make it so that all branches exit with the correct codes.

main and run: one way out

package main

import (
	"fmt"
	"os"
)

const (
	exitOK    = 0
	exitFail  = 1
	exitUsage = 2
)

func main() {
	code := run(os.Args[1:])
	os.Exit(code)
}

func run(args []string) int {
	if err := dispatch(args); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err.Error())
		return exitCode(err)
	}
	return exitOK
}

What matters here: dispatch returns an error, and only run turns it into an exit code.

Dispatcher: commands and help as a separate branch

package main

import (
	"fmt"
	"os"
)

func dispatch(args []string) error {
	if len(args) == 0 || args[0] == "help" {
		printRootHelp()
		return nil
	}

	switch args[0] {
	case "add":
		return cmdAdd(args[1:])
	case "list":
		return cmdList(args[1:])
	default:
		return usageError{msg: fmt.Sprintf("unknown command: %s", args[0])}
	}
}

func printRootHelp() {
	fmt.Fprintln(os.Stderr, "Usage:")
	fmt.Fprintln(os.Stderr, "  todo 
  
    [flags] [args]") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Commands:") fmt.Fprintln(os.Stderr, " add add a task") fmt.Fprintln(os.Stderr, " list list tasks") } 
  

Notice: help as a command is a success (we return nil), and the code will be 0.

Command add: startup errors are usage (2)

package main

import (
	"flag"
	"fmt"
	"os"
	"strings"
)

func cmdAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	title := fs.String("title", "", "task title (required)")

	if err := fs.Parse(argv); err != nil {
		// In this lecture we do not go deep into printing usage.
		// The only important thing: this is a startup error => usageError.
		return usageError{msg: err.Error()}
	}

	if strings.TrimSpace(*title) == "" {
		return usageError{msg: "missing -title"}
	}

	// In a real app, this is where adding a task would happen.
	fmt.Fprintln(os.Stdout, "added:", *title) // added: Buy milk
	return nil
}

If the user forgot -title, this is not “the program broke”, it is “the user invoked the command incorrectly”. That means the exit code must be 2.

Command list: runtime/internal is 1

For now, let list “read” from a file (conceptually). A file read error is not usage, but runtime/internal: the invocation is correct, but execution failed.

package main

import (
	"fmt"
	"os"
)

func cmdList(argv []string) error {
	if len(argv) != 0 {
		return usageError{msg: "list does not accept positional args"}
	}

	// Imagine we want to open a file with the task list.
	_, err := os.Open("todo.txt")
	if err != nil {
		return fmt.Errorf("open storage: %v", err)
	}

	fmt.Fprintln(os.Stdout, "no tasks yet") // no tasks yet
	return nil
}

Note: here we return a regular error (not usageError), so at the top level it becomes exit code 1.

By the way, the pattern “check error → return upward” is a basic and very Go-ish habit. The sooner you learn to love it, the less spaghetti code you will have.

4. Common mistakes

Mistake #1: returning 2 for -h or help.
Usually this happens because it feels like “well, the command didn’t do anything”. But help is an action: the user requested help, and you provided it. If you return 2, the shell and scripts will start treating help as an error. The correct logic is: help always finishes with exit code 0.

Mistake #2: calling os.Exit inside command handlers.
Such code is hard to test, hard to reuse, and unpleasant to extend. It breaks the idea “a command is a function you can call”. It is much better when cmdAdd, cmdList, and others return error, and os.Exit lives only in main, in a single place.

Mistake #3: classifying errors by text (strings.Contains(err.Error(), ...)).
It looks “fast and convenient” until you change the message wording or add context to the error. Then everything falls apart. It is more reliable to distinguish usage errors by type (for example, usageError), because the type is part of your internal contract, while the string is a UX layer.

Mistake #4: turning all errors into exitFail = 1.
This makes the CLI unpleasant for the user: they cannot tell whether they invoked it incorrectly or the program broke. It is also bad for automation: it is useful for a script to distinguish “invoked incorrectly” (fix arguments) from “execution broke” (retry, fix environment, check permissions).

Mistake #5: printing command output and error messages to the same stream.
When everything goes to stdout, users who parse the output (even with simple grep) will get data and diagnostics mixed together. A good habit: command data goes to stdout, and help/usage/errors go to stderr.

1
Task
Go SELF, level 50, lesson 1
Locked
Status indicator
Status indicator
1
Task
Go SELF, level 50, lesson 1
Locked
Flag-based greeting
Flag-based greeting
1
Task
Go SELF, level 50, lesson 1
Locked
File size
File size
1
Task
Go SELF, level 50, lesson 1
Locked
Pocket calculator
Pocket calculator
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION