JavaRush /Courses /Go SELF /Exit codes: the 0/1/2 contract for CLI

Exit codes: the 0/1/2 contract for CLI

Go SELF
Level 49, Lesson 3
Available

1. Why does a CLI need exit codes if we already have error?

When you write a console program for the first time, it feels like printing “error!” is enough. But as soon as your utility stops living in a vacuum and starts being launched from scripts, CI, cron, Docker entrypoints, and simply other programs, it becomes important not only what you print, but also how the process ended. That’s where exit codes come in.

error is a convenient mechanism inside Go code: return upward, wrap with context, classify via errors.Is, print for a human. But the shell (bash, PowerShell) and the environment around your program don’t read error. They only see an integer: “0 or not 0”. And we want a bit more meaning than “everything is bad”.

As a result, a CLI gets an external contract: by which exit code the outside world understands what happened. And this contract must be stable: you can change message texts, but the codes must remain predictable—otherwise your future script will break.

2. The 0/1/2 contract for CLI

Let’s agree on a minimal but very practical standard. You’ll see it in many CLIs (and in assignments/checkers it’s often exactly what they expect): three outcomes, three codes.

Code Name (conventional) Meaning for the environment Typical causes
0
success Command completed successfully all good
2
usage The user invoked the command incorrectly unknown command, invalid arguments, -h/-help, flag parsing error
1
failure The command started correctly, but could not do the work I/O error, “access denied”, internal error

Why is

2
a separate code and not also
1
? Because “usage” is not “the program broke”. It’s “the user violated the invocation contract”. Scripts and shells often use this distinction—for example, to avoid retries if the problem is in the arguments, or to print help differently.

There’s also a human-friendly benefit:

2
is a polite signal, “try again, here’s the correct way”. And
1
is “you did everything right, but the world around us failed” (or we messed up in code, which also happens).

3. os.Exit and the defer nuance

As soon as we talk about exit codes in Go, we run into os.Exit(code). main() has no return value, so you can’t directly “return

2
”. So someone has to call os.Exit.

But os.Exit has a personality, and it’s pretty abrupt: it doesn’t “exit a function”, it instantly terminates the process. Which means all deferred calls (the ones you wrote via defer) do not run.

You can catch this in practice very easily:

package main

import (
	"fmt"
	"os"
)

func main() {
	defer fmt.Println("defer: I will NOT be printed")
	os.Exit(2)
}

If you run it, you won’t see the line from defer. So the rule sounds almost like a law of physics: keep os.Exit only at the very top level, and let all other code return error (or int via a separate function), so that defer-based cleanup happens normally.

This is especially important when you have files, locks, buffered writers, and generally anything that must be closed/flushed. Once you start using defer for real, “exiting from deep inside” becomes your favorite source of weird bugs like “why is the file empty when I definitely wrote to it?”.

4. Single exit point: run → error → exitCode → os.Exit

Let’s build an architecture that almost always turns out to be convenient in a CLI. The idea is: let the application’s business logic return an error, and compute the exit code in one place. This makes behavior predictable and removes “dancing” all over the codebase.

Imagine the control flow like this:

flowchart TD
    A[main] --> B["run(args) error"]
    B -->|err == nil| C[exit code 0]
    B -->|"errors.Is(err, ErrUsage)"| D[exit code 2]
    B -->|otherwise| E[exit code 1]
    C --> F["os.Exit(code)"]
    D --> F
    E --> F

Key point: run(...) does not call os.Exit. It simply returns an error. This means that inside run and inside command handlers you can safely use defer for cleanup—everything will work.

The Go code skeleton looks like this (short and to the point):

package main

import "os"

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

And here is where the logic lives: printing errors and converting error → exit code:

package main

import (
	"fmt"
	"os"
)

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

Notice: we print the error before os.Exit. This matters, because after os.Exit it’s too late to print anything (the process is dead).

5. Name the codes: no “magic 1 and 2”

Next step: stop scattering “1” and “2” across the code. First, it’s easier to read. Second, it’s harder to make a mistake: you won’t confuse where usage is and where fail is.

package main

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

Now exitCode(err) can be very compact:

package main

import "errors"

func exitCode(err error) int {
	if err == nil {
		return exitOK
	}
	if errors.Is(err, ErrUsage) {
		return exitUsage
	}
	return exitFail
}

Here we use errors.Is to determine the error class. It’s important that errors.Is works not only with “plain” errors, but also with wrapped errors—i.e., if you added context via %w, the check will still walk the chain. This is exactly how the standard library recommends classifying errors semantically via a “cause chain”.

6. ErrUsage and the usagef helper

For exitCode to work based on the meaning (not the text), we need a stable marker. Usually this is a sentinel error: one variable ErrUsage, and we “wrap” it into all invalid-invocation errors.

package main

import "errors"

var ErrUsage = errors.New("usage")

Why is this good? Because error text is fragile. Today you write missing command, tomorrow you change it to command is required, and suddenly someone’s code that did strings.Contains(err.Error(), "missing") breaks. But errors.Is(err, ErrUsage) doesn’t depend on the text and is designed for exactly these scenarios.

Let’s make a tiny helper to conveniently create usage errors:

package main

import "fmt"

func usagef(format string, args ...any) error {
	return fmt.Errorf("%w: "+format, append([]any{ErrUsage}, args...)...)
}

Yes, the line looks a bit “magical”, but essentially it does something simple: it adds ErrUsage as the first argument and formats the message. You get both classification and human-readable text.

7. Usage errors in a CLI with subcommands

Now let’s tie this to our application (let it be a learning todo/tasker), which already knows how to choose a subcommand and pass it argv.

A subcommand dispatcher usually handles two usage situations: the command is not provided and the command is unknown. This is a usage error, so we return usagef(...).

package main

func run(args []string) error {
	if len(args) == 0 {
		return usagef("missing command")
	}

	switch args[0] {
	case "add":
		return runAdd(args[1:])
	case "list":
		return runList(args[1:])
	default:
		return usagef("unknown command: %s", args[0])
	}
}

Notice an important style choice: run doesn’t print, doesn’t call os.Exit, and doesn’t decide “what the user should do”. It simply reports upward: “this is usage” or “this is runtime”.

And a command handler can return either a usage error (e.g., invalid arguments) or a normal runtime error (e.g., failed to write a file). That difference will then automatically turn into

2
or
1
.

Flag parsing errors are almost always exitUsage

Let’s take the add command. Suppose it accepts a -title flag and does not accept positional arguments (for simplicity). We create a FlagSet in flag.ContinueOnError mode so that Parse() returns an error instead of terminating the process by itself.

package main

import (
	"flag"
	"fmt"
)

func runAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	title := fs.String("title", "", "task title")

	if err := fs.Parse(argv); err != nil {
		return usagef("%v", err)
	}

	if *title == "" {
		return usagef("missing -title")
	}

	fmt.Println("added:", *title) // added: Buy milk
	return nil
}

The key idea here: an “invalid flag” is not “the program broke”. It’s the user typed -tittle instead of -title (a classic). So the return code should be

2
, and we achieve that by wrapping with ErrUsage.

Why you should not call os.Exit inside runAdd

Sometimes it’s tempting: “The error is already here—why delay? I’ll do fmt.Fprintln(os.Stderr, ...) and os.Exit(2) right inside runAdd.” In the short term it seems simpler. In the long term you get two problems.

The first problem is that defer won’t run. This hurts especially when later you add file writes, buffering, temp files, locks. You opened a file, did defer f.Close(), and then somewhere in the middle you called os.Exit: the file may not close, buffers may not flush, a lock may not be released.

The second problem is that the code becomes “ragged”: some errors are printed in one place, some in another. As a result you can accidentally print the same error twice or, conversely, forget to print it at all. A single print point is like a single entrance to the subway: there might be a line, but nobody jumps the turnstiles.

So the grown-up rule is: all functions except the very top level return errors, rather than terminating the process.

8. Printing errors: stdout vs stderr

Since we have a single exit point, it’s logical to print errors there as well. The usual policy is: everything that is the “command result” goes to stdout, and everything that is diagnostics goes to stderr.

In our skeleton, this is already reflected:

fmt.Fprintln(os.Stderr, "error:", err)

That’s useful because the user can do:

  • app list > out.txt and get a clean list of tasks without noise,
  • while still seeing errors in the terminal.

One more subtle point: don’t turn error messages into 20-line novels. The message should be short and clear. Technical details are often better kept “inside” the error chain, while printing one compact text on the outside. (We’re not enabling logging and debug modes here—just building basic hygiene.)

9. Minimal tasker skeleton with 0/1/2 codes

Below is a skeleton that ties the ideas into one scheme. It doesn’t implement real task storage (that’s fine), but it shows where exit codes live.

package main

import (
	"errors"
	"fmt"
	"os"
)

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

var ErrUsage = errors.New("usage")

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

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

func exitCode(err error) int {
	if err == nil {
		return exitOK
	}
	if errors.Is(err, ErrUsage) {
		return exitUsage
	}
	return exitFail
}

And run(...), which for now only knows how to “recognize” commands:

package main

func run(args []string) error {
	if len(args) == 0 {
		return usagef("missing command")
	}

	switch args[0] {
	case "add":
		return runAdd(args[1:])
	case "list":
		return runList(args[1:])
	default:
		return usagef("unknown command: %s", args[0])
	}
}

Plus the helper:

package main

import "fmt"

func usagef(format string, args ...any) error {
	return fmt.Errorf("%w: "+format, append([]any{ErrUsage}, args...)...)
}

And a stub command (so it compiles):

package main

import "fmt"

func runList(argv []string) error {
	_ = argv
	fmt.Println("no tasks yet") // no tasks yet
	return nil
}

Now the behavior by codes will be: tasker list returns

0
, tasker without a command returns
2
, and any “real” runtime errors (when they appear later) will yield
1
.

10. Common mistakes

Mistake #1: calling os.Exit inside command handlers.
At first it seems convenient: the error is nearby, so the “exit” is nearby too. But this breaks deferred cleanup and turns the program into a set of inconsistent “death points”. As a result, some errors are printed twice, some not printed at all, and resources sometimes aren’t closed. It’s better to return an error upward and terminate the process in one place.

Mistake #2: relying on defer after you already called os.Exit.
defer runs on a normal return from a function. os.Exit does not return from a function—it terminates the process. Therefore any defer “after os.Exit” will not run. A reliable pattern is a separate realMain that does all the work, while main only calls os.Exit with the code returned by realMain.

Mistake #3: mapping all errors to code

1
.
If you don’t separate usage errors from runtime errors, your users and scripts lose important information. Invalid arguments, unknown command, flag parsing errors—these are exitUsage =
2
. Network, file, and “internal” errors—these are exitFail =
1
. Separation makes a CLI much more “automation-friendly”.

Mistake #4: determining usage by error text.
Checks like strings.Contains(err.Error(), "usage") feel like a quick solution, but they break with the smallest refactor of the text. It’s much more reliable to have a marker ErrUsage and check via errors.Is. This is the standard mechanism for semantic error classification in Go.

Mistake #5: printing errors at every layer.
When runAdd, the dispatcher, and main all print errors, the user gets a “nested doll” of messages. Sometimes it turns into three identical lines like error: .... It’s more hygienic to print once at the top level, and below only return an error, adding context via wrapping when needed.

1
Task
Go SELF, level 49, lesson 3
Locked
Three outcomes
Three outcomes
1
Task
Go SELF, level 49, lesson 3
Locked
Usage sentinel
Usage sentinel
1
Task
Go SELF, level 49, lesson 3
Locked
Repeat with flags
Repeat with flags
1
Task
Go SELF, level 49, lesson 3
Locked
Code and error
Code and error
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION