1. Why avoid direct os.Stdout/os.Stdin in a CLI
When you write your first CLI, your hand naturally reaches for fmt.Println(...), and then for fmt.Scan(...). That is a normal stage of growing up: first “as long as it works,” then “why can’t I test this properly?”. The problem is not fmt; the problem is that by default fmt writes to the process-wide global stdout and reads from the process-wide global stdin.
Imagine you want to test a command automatically: pass arguments, get output, compare it to the expected result. If the command prints directly to os.Stdout, the test has to “capture” the global stdout of the whole process (and pray parallel tests do not create a mess). And if the command reads from os.Stdin, the test must replace stdin (and again you get global state and fragility). In the end, tests stop being “logic verification” and turn into an exorcism session.
I/O injection (passing io.Reader and io.Writer as parameters) solves this calmly, the Go way: the command becomes a regular function that reads from the provided source and writes to the provided sink.
Anti-example: “everything directly”
First, let’s look at code that seems logical to a beginner, but creates problems later. This example is not “bad”; it is simply “early.”
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("todo-cli") // writes to the process stdout
fmt.Fscan(os.Stdin, new(string))
}
From the outside everything is fine: the program prints a greeting, then reads something from stdin. But now try to answer: “how do I test that it prints exactly todo-cli\n?”. You will have to capture the stdout of the whole process, and that is no longer about logic, but about acrobatics.
We want our logic to be able to work with any io.Reader/io.Writer, while os.Stdin/os.Stdout are only one option that is wired up at the boundary—in main.
2. What io.Reader and io.Writer are
io.Reader and io.Writer are two very small interfaces from the standard library that define the idea of a data “stream.” A Reader reads bytes; a Writer accepts bytes. What matters is not even that they are bytes, but that this is a contract you can plug anything into: a file, the network, an in-memory buffer, a string, a logger, a gzip wrapper—anything that can read/write.
Why does Go love this idea so much? Because it makes code flexible without magic and without frameworks. In official Go style materials, you will repeatedly see the idea “design around interfaces,” and io.Writer is one of the most popular examples: it is easy to layer behavior on top without changing the calling code.
For a CLI this is especially convenient: stdout is just an io.Writer. Stdin is just an io.Reader. Which means: if we pass them into the command, the command stops depending on a real terminal and becomes testable.
3. The command contract: Run(...) int and a thin main
The command as a Run function
Now we introduce today’s central idea: the core of a CLI should be a function that takes arguments and I/O as dependencies, and returns an exit code. This contract is simple but powerful: it removes global dependencies and makes behavior predictable.
Typically the signature looks like this:
Run(args []string, in io.Reader, out, errOut io.Writer) int
What matters here is that out and errOut are separated. stdout and stderr are not “two names for the same thing,” but two different semantic channels: one for results, the other for problems and hints. And it is also important that Run does not call os.Exit—because os.Exit kills the process instantly, and the test will not be able to verify anything afterward.
A minimal scaffold for our todo-cli:
package cli
import (
"fmt"
"io"
)
func Run(args []string, in io.Reader, out, errOut io.Writer) int {
_ = in // not used yet
fmt.Fprintln(out, "todo-cli") // todo-cli
return 0
}
Yes, this is boring for now. But now Run can be called from main, from a test, or from other code. And every time, the output will go exactly where we say.
main as a thin wrapper
It is very easy to accidentally turn main into a huge function where everything is mixed together. But for a CLI it is cleaner to keep main thin: it should only connect the “real wires” (args, stdin/stdout/stderr) and call our core.
Example main.go:
package main
import (
"os"
"example.com/todo/cli"
)
func main() {
code := cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)
os.Exit(code)
}
Notice a funny thing: os.Exit is acceptable here and even useful—this is the application boundary. We are not trying to “ban os.Exit entirely”; we just do not let it leak into the command’s business logic.
Exit codes as part of the contract
It may feel like the exit code is a separate topic, but in practice it goes hand-in-hand with I/O injection. When you make a command a Run function, it cannot “just terminate however it wants”: it must return a result, and that result should be standardized.
To avoid magic numbers, define constants:
package cli
const (
exitOK = 0
exitFail = 1
exitUsage = 2
)
And use them in code:
package cli
import (
"fmt"
"io"
)
func Run(args []string, in io.Reader, out, errOut io.Writer) int {
_ = in
if len(args) == 0 {
fmt.Fprintln(errOut, "missing command") // missing command
return exitUsage
}
fmt.Fprintln(out, "ok") // ok
return exitOK
}
Pay attention to the discipline: the error message went to errOut, while the “successful result” went to out. This becomes critical when people start using your app in scripts: stdout can be parsed, stderr can be read by humans.
Rule: results go to stdout, diagnostics go to stderr
Here is a small rule that may sound pedantic, but saves hours of debugging. When you write a CLI, mentally classify every output line: is it a result, or is it diagnostics? Then print to the appropriate writer.
If later you want to implement todo-cli list and redirect the result to a file, the user will do this:
todo-cli list > tasks.txt
And they expect tasks.txt to contain only the task list, not “error: could not open file” or “hint: use the --help flag”. Therefore, errors and usage should always go to stderr.
Let’s show this on a simple command dispatcher:
package cli
import (
"fmt"
"io"
)
func Run(args []string, in io.Reader, out, errOut io.Writer) int {
_ = in
switch {
case len(args) == 0:
fmt.Fprintln(errOut, "missing command") // missing command
return exitUsage
case args[0] == "version":
fmt.Fprintln(out, "todo-cli dev") // todo-cli dev
return exitOK
default:
fmt.Fprintf(errOut, "unknown command: %s\n", args[0]) // unknown command: ...
return exitUsage
}
}
4. Injecting stdin and controlling flag output
Injecting stdin: read from in
With stdout, things are usually clear: “don’t print directly; print to a writer.” With stdin, beginners often hit a small mental glitch: “how do I read a string now?”.
Answer: the same way as before, only from a different source. Most input functions in Go can work not only with stdin, but with any io.Reader. For example, fmt.Fscan takes a reader as its first argument—and that is exactly our case.
Suppose we want an echo command that reads one word from input and prints it (a toy example, but it shows the mechanics):
package cli
import (
"fmt"
"io"
)
func runEcho(in io.Reader, out, errOut io.Writer) int {
var s string
if _, err := fmt.Fscan(in, &s); err != nil {
fmt.Fprintln(errOut, "failed to read input") // failed to read input
return exitUsage
}
fmt.Fprintln(out, s) // prints the word that was read
return exitOK
}
The key point: we read from in, not from os.Stdin. In the real main we will pass os.Stdin, and in a test we can pass a string via strings.NewReader("hello"). Even without thinking about tests, you can already see the function became more universal.
flag.FlagSet: configure output via SetOutput
There is another annoying detail that breaks CLI testability: the standard flag package also likes to write “somewhere” by default. So when you create a subcommand with flags, it is better not to use the global flag.Parse(), but to create a separate FlagSet and configure its output.
That is, you do not just call flag.NewFlagSet; you also call SetOutput(errOut). This is again about I/O injection: we do not let internals print wherever they feel like.
Mini example of a list command with a -limit flag:
package cli
import (
"flag"
"fmt"
"io"
)
func runList(args []string, out, errOut io.Writer) int {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
fs.SetOutput(errOut)
limit := fs.Int("limit", 10, "max items")
if err := fs.Parse(args); err != nil {
return exitUsage
}
fmt.Fprintf(out, "limit=%d\n", *limit) // limit=10
return exitOK
}
What matters here is that even flag parsing errors will go to errOut, which we control. This makes behavior predictable both in a terminal and in automated checks.
5. How data flows, and why this is better than direct os.Stdout/os.Stdin
Mini diagram of the data flow
Sometimes understanding improves dramatically when you can see the “wires” explicitly. In our design, main is where real system streams get connected, and cli.Run is the logic that knows nothing about the OS.
flowchart LR
A[main.go] -->|args| B[cli.Run]
A -->|os.Stdin| B
B -->|out io.Writer| C[os.Stdout]
B -->|errOut io.Writer| D[os.Stderr]
B -->|exit code| A
This approach is exactly what makes a CLI into code you can run anywhere: in a terminal, in tests, or inside another application as a library (yes, that happens sometimes).
Table: before and after
To reinforce the idea, let’s summarize it in a table. This is useful because visually it is obvious: we are not making the logic more complex; we are simply moving dependencies to function inputs.
| Task | “Directly” (bad for tests) | “With injection” (good) |
|---|---|---|
| Print a result | |
|
| Print an error/usage | |
|
| Read input | |
|
| Terminate the program | anywhere |
from the logic, only in |
6. Common mistakes when injecting io.Reader/io.Writer
Mistake #1: inside the command they still print to os.Stdout “out of habit”.
This often happens when you create a “nice” signature like Run(... out io.Writer ...), but then somewhere deep down you leave a fmt.Println or fmt.Fprintln(os.Stdout, ...). As a result, half the output goes into the test buffer, and half goes to the real terminal, and you get mystery-level behavior like “why does it print twice?”. The fix is simple: print only via out/errOut, and in your IDE you can search the project for os.Stdout and fmt.Print.
Mistake #2: mixing stdout and stderr into a single stream.
Sometimes it feels like “who cares, the user sees the text anyway.” The difference shows up the moment someone uses a pipe: todo-cli list | grep ... or a redirect > file. If errors and hints end up in stdout, they break machine processing of the result. The habit of separating channels from day one saves a lot of time and makes the CLI more professional.
Mistake #3: Run calls os.Exit and thereby kills the test (and defer).
This is not only about tests: os.Exit terminates the process without running deferred defer calls, which means you might not close a file, flush a buffer, or finish writing data. The contract must be ironclad: the logic returns a code, and main decides when to call os.Exit.
Mistake #4: flag prints messages “somewhere” on its own because you did not configure SetOutput.
Even if you are disciplined and print everything to errOut, FlagSet may, by default, print flag parsing errors somewhere other than where you expected. That is why each subcommand with flags should have its own FlagSet, and it should have fs.SetOutput(errOut)—this is part of the same I/O injection discipline.
Mistake #5: making output nondeterministic and then being surprised that it is “hard to compare.”
When you start comparing output in tests, it suddenly matters that lines must match exactly, including the trailing \n. If one branch prints with a newline and another without, the behavior becomes “almost the same,” but the test (and user scripts) will not agree. Choose a style (usually each line ends with \n) and stick to it in all branches.
GO TO FULL VERSION