1. Why an exit code is a contract, not an implementation detail
Imagine your CLI does not live in a vacuum, but in an environment where it is launched by bash scripts, Makefiles, CI pipelines, and other programs. They do not read your nice messages; they read the process exit code. And if that code “drifts” from command to command, automation turns into guessing: “did everything break, or did the user just forget a flag?”
An exit code is the machine “meeting outcome.” Error text is a conversation for a human. Humans have mood, localization, and a desire to “make the message more polite.” An exit code must not have a mood.
The baseline 0/1/2 contract: minimally strict and maximally useful
Next, there will be a temptation to make 47 different codes, because “we are engineers, let’s classify everything.” I get it: many of us have a little accountant inside who wants a separate column for every type of pain. But the baseline practice for CLIs is simple and surprisingly effective:
- 0 — success (no error).
- 2 — usage error: invalid input, invalid arguments, missing required flags, wrong format.
- 1 — runtime error: file did not open, data did not read, something “internal” went wrong.
This scheme is good because it is stable and sufficient almost always. You do not force the user to remember codes; you force the script to distinguish “the user is at fault” from “the system/program is at fault.”
And yes: this is a contract. If one command returns 2 for “bad input,” and another returns 1 for “bad input,” you are creating technical debt for yourself—with interest.
2. How to determine the exit code: not by text, but by the error class
Why you cannot compute an exit code from the error text
This is where the first tragedy of a beginner CLI author usually happens: someone writes something like:
if strings.Contains(err.Error(), "missing") {
os.Exit(2)
}
os.Exit(1)
At first it seems like it works. But then you change the text “missing” to “required” (because you improved the English), and suddenly scripts start treating it as an internal error. Or you add context via fmt.Errorf("parse args: %w", err)—and the word “missing” starts appearing in a different place. Or you localize messages.
Text is a bad foundation for branching.
In Go, it is common to keep structure alongside the error: an error type, a “kind,” context fields, and the ability to unwrap. This is exactly the approach discussed in materials about errors and errors.Is/errors.As: program logic should rely on values and types, not on a string.
Error class as input to an exit code table
Before building a table, you need to agree on which “classes” (kinds) you recognize at all. Within the todo CLI application, it is convenient to keep a small fixed set:
- validation — invalid user input / invalid arguments.
- notfound — resource not found (for example, a task by id does not exist).
- io — file read/write issues, stdin/stdout, etc.
- internal — everything else you did not expect.
Important: the list must be small. The more classes, the more chances that different commands will start interpreting them differently—and drift begins again.
Minimal base for an enum:
package apperr
type Kind int
const (
KindInternal Kind = iota
KindValidation
KindNotFound
KindIO
)
4. Kind → exit code table: lock in the policy and make it a “single source of truth”
Baseline table 0/1/2
Now we do the main thing in this lecture: we lock in a table that will be the same for all commands. This should not be a “table in the author’s head,” but a “table in code” and in the project documentation.
The table should be so boring you can forget it—and still use it correctly. Boredom here is not a drawback; it is a feature: the fewer surprises, the more reliable the CLI is in automation.
| State / error class | What it means for the CLI | Exit code | Why |
|---|---|---|---|
|
the command completed | |
success |
|
the user used the command incorrectly | |
this is a usage error |
|
a resource/entity was not found | |
not usage, but not success |
|
read/write failed | |
runtime error |
|
“something broke” | |
runtime error |
Notice: in the baseline policy notfound is not assigned a separate code. This is intentional so the baseline contract does not sprawl. “Task not found” is not success and not a usage error (arguments are usually valid), so it stays 1.
A bit later we will carefully add an optional extension—so that the baseline mode remains unchanged.
Where Kind comes from in real code
So the table is not just a picture, we need to be able to extract Kind from an error. A typical approach is your own AppError type + errors.As so it works through wrapping. The idea of unwrapping and a chain of causes is discussed in materials about Go 1.13 errors: if an error supports Unwrap(), then errors.As and errors.Is can walk the chain.
Minimal implementation:
package apperr
type AppError struct {
Kind Kind
Op string
Err error
}
func (e *AppError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *AppError) Unwrap() error { return e.Err }
And extracting the class:
package apperr
import "errors"
func KindOf(err error) Kind {
var ae *AppError
if errors.As(err, &ae) {
return ae.Kind
}
return KindInternal
}
Implementing ExitCode(err) as a table in code
It is very tempting to “quickly” write os.Exit(2) right in the flag handler, and os.Exit(1) right in the file handler. But that is like fixing wiring in an apartment: twist it once, and later everything sparks in some random place.
We need one function that applies the table. Then all commands will be consistent automatically.
Constant codes:
package apperr
const (
ExitOK = 0
ExitFail = 1
ExitUsage = 2
)
And the function:
package apperr
func ExitCode(err error) int {
if err == nil {
return ExitOK
}
if KindOf(err) == KindValidation {
return ExitUsage
}
return ExitFail
}
This is intentionally “too simple.” That is the point: the baseline mode should never turn into “here are 7 if statements and our own religion.”
5. Extended codes: a separate mode, not a replacement for the baseline
Yes, there are cases where it is genuinely useful to distinguish notfound from io. For example, a script may want to handle a missing task “softly,” but fail “hard” on a disk problem.
However, if you simply change the baseline ExitCode and start returning 3 for notfound, you break expectations of users and scripts. Therefore, the extension must be optional and enabled explicitly (a flag, a setting, a separate command), not “suddenly it became different.”
Example table of extended codes
The extended table is an “overlay.” It may evolve and be project-specific, but it must not replace the baseline. That is, you always have a safe mode 0/1/2, and only on request—more details.
| Kind | Exit code | Comment |
|---|---|---|
|
|
still usage, do not touch |
|
|
separate semantics |
|
|
separate code for I/O problems |
|
|
same as before |
|
|
same as before |
Implementation as a separate function:
package apperr
func ExitCodeDetailed(err error) int {
if err == nil {
return ExitOK
}
switch KindOf(err) {
case KindValidation:
return ExitUsage
case KindNotFound:
return 3
case KindIO:
return 4
default:
return ExitFail
}
}
An architectural point matters here: we do not change ExitCode. We add a separate function. Technically it is slightly longer, but conceptually it is more honest and stable for users.
6. Example with the todo CLI: how error classes turn into codes
Let’s make this not theory, but situations. Imagine the commands look like this:
- todo add -title "buy milk"
- todo done -id 10
And the data is stored in a file (for example, JSON), and sometimes the file cannot be read.
Validation: the user forgot a required flag
Validation errors are the most “friendly.” This is not a catastrophe; it is a reason to show usage and suggest how to do it correctly. And that is exactly why we must return 2, so scripts understand: “this is not a system crash; this is an incorrect invocation.”
package todo
import (
"errors"
"example.com/todo/internal/apperr"
)
func parseTitle(title string) error {
if title == "" {
return &apperr.AppError{
Kind: apperr.KindValidation,
Op: "parse -title",
Err: errors.New("title is required"),
}
}
return nil
}
Such an err must turn into 2 via apperr.ExitCode(err).
NotFound: a task with the id does not exist
notfound often triggers a philosophical argument: “is it an error or just no result?”. In a CLI, it is usually still a command execution error: you asked to perform an action, but the object was not found. Therefore, in the baseline mode it will be 1, and in the extended mode—a separate code, if you need it.
package todo
import (
"errors"
"fmt"
"example.com/todo/internal/apperr"
)
func markDone(id int) error {
if id == 42 {
return nil
}
return &apperr.AppError{
Kind: apperr.KindNotFound,
Op: fmt.Sprintf("mark done id=%d", id),
Err: errors.New("task not found"),
}
}
IO: the file cannot be read
An I/O error is the classic “everything was correct, but the world is more complicated.” The file path may be wrong, permissions may be missing, the disk may be busy. This is not “bad command input” in the usage sense, so in the baseline scheme it is 1.
package storage
import (
"fmt"
"os"
"example.com/todo/internal/apperr"
)
func load(path string) error {
_, err := os.ReadFile(path)
if err != nil {
return &apperr.AppError{
Kind: apperr.KindIO,
Op: "read tasks file",
Err: fmt.Errorf("%w", err),
}
}
return nil
}
Here %w matters so the error does not lose the root cause and can be diagnosed properly: errors.Is/errors.As should be able to walk the chain.
7. Where to decide the exit code: the application boundary
Do not compute the exit code in the middle of business logic. Task logic should not care that somewhere there is a “process code.” That is the responsibility of the CLI boundary: the place where you turn the work result into process behavior.
A classic approach: you return the error outward, and at the top you decide “what to print” and “which code to exit with.” For a CLI, it usually looks like this: run() error does the work, main() prints and calls os.Exit(code).
And yes: we remember that after os.Exit, deferred calls do not run, so os.Exit should only be at the top.
Mini skeleton:
package main
import (
"fmt"
"os"
"example.com/todo/internal/apperr"
)
func main() {
err := run()
code := apperr.ExitCode(err)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
}
os.Exit(code)
}
This part is deliberately simple: here we lock in the code table itself. “Nice messages for the user” and “logging details” live nearby, but do not get mixed with exit code calculation.
8. Typical mistakes when building an exit code table
Mistake #1: different commands return different codes for the same situation.
This is the most unpleasant kind of drift: todo add for “missing -title” returns 2, while todo done for “missing -id” returns 1. In scripts, the user starts writing workarounds, and you start catching bugs like “why is CI sometimes red?”. The fix is simple: one ExitCode(err) function and one shared table, not os.Exit(...) inside every command.
Mistake #2: notfound suddenly counts as success, because “well, we did not find it and that’s fine”.
Sometimes people do this to “avoid noise.” But then a command like todo done -id 999 looks successful in automation, even though nobody marked the task as done. If you truly need a “soft notfound,” it must be an explicit mode or a different command, not an accidental interpretation. In the baseline scheme notfound is 1; if you need otherwise, enable the extended mode and document it.
Mistake #3: computing the exit code from the error string (err.Error()).
This breaks at the first refactor of the text, localization, or when you add context. The correct approach is to structure the error (type + fields), preserve causes via wrapping, and extract meaning via errors.As/errors.Is.
Mistake #4: extended codes quietly replace the baseline ones.
Today you return 3 for notfound; tomorrow you decide notfound should be 4, because “that’s how we feel.” User scripts do not know how to feel; they know how to fail. The extension must be a separate function/mode, while the baseline 0/1/2 must remain unchanged.
Mistake #5: calling os.Exit in the middle of command code.
As a result, you lose control over output, break deferred file closes, and turn the code into a set of “points where the process disappears suddenly.” It is much calmer to return the error upward and terminate the process only at the CLI boundary, when the exit code has already been computed from the table.
GO TO FULL VERSION