1. Why reading stdin can be a surprise
If you ask a beginner “add reading from stdin”, you typically get a program that suddenly hangs and stays silent because it is waiting for input, while the user did not plan to type anything. In a CLI this is especially painful: you do not have a “Cancel” button, and the user starts thinking the app is broken, even though it is simply honestly sitting there waiting for bytes.
The problem is that stdin is used differently in the Unix and Windows worlds. Sometimes it is the keyboard, sometimes it is a pipe from another command, sometimes it is redirected from a file. That is why we must make the read protocol explicit: the program reads stdin only when the user explicitly asked for it, not “because it is more convenient in our code”.
To make it crystal clear, here is a typical real-life flow:
flowchart TD
A[User runs the command] --> B[Parse args/flags]
B --> C{Do we need to read stdin?}
C -->|no| D[Run the command without stdin]
C -->|yes, explicit mode| E[Read stdin]
E --> F[Parse/validate data]
F --> G[Run the command]
It is exactly the “Do we need to read stdin?” node that makes behavior predictable.
2. The - protocol: when to read stdin
When you start designing a CLI, you really want to do “what is convenient”: if -title was not provided, let us read it from stdin. But that is precisely the path to unexpected hangs: the user runs todo add, and the program goes silent and waits—and it is unclear what is happening.
That is why the CLI world has a very old and very effective convention: the string - means “read from stdin”. A ton of utilities use this, and it is genuinely convenient: the user sees - and understands that data must come via a pipe/redirection or be typed manually.
We will introduce this protocol into our learning todo app like this:
- todo add -title "buy bread" — normal mode; we do not touch stdin.
- echo "buy bread" | todo add -title - — read the title from stdin.
- todo import - — read many lines from stdin (each line is a separate task).
Key idea: first parse and validate the mode, and only then read. This protects us from hanging “too early”.
3. Unifying input: file or stdin
When you add a “file or stdin” mode, it is tempting to write everywhere: if path == "-" { ... } else { ... }. After three commands this turns into sticky spaghetti. Better to make a small function that returns a single interface: io.ReadCloser.
And this is where a useful thing appears: io.NopCloser. It turns an io.Reader (for example, os.Stdin) into an io.ReadCloser with a safe Close() (that does nothing). This is convenient because code higher up the stack can always do defer r.Close() and not worry, “is it forbidden to close stdin?”. Formally, you “can”, but practically, you should not. NopCloser helps enforce discipline.
Mini helper:
package main
import (
"io"
"os"
)
func openInput(path string) (io.ReadCloser, error) {
if path == "-" {
return io.NopCloser(os.Stdin), nil // Close() is safe and breaks nothing
}
return os.Open(path) // the file will need to be closed
}
And a small but important nuance about defer: if you return a ReadCloser to the outside, it should be closed by whoever received it. If you opened it inside a command handler function, close it there. defer in Go is very reliable: it runs when the function exits and helps guarantee resources are released.
4. Reading from stdin: one line and many lines
When we need a single title, the simplest approach is to read one line. A question immediately comes up: what should we use to read?
There are options like fmt.Scanln, but it depends on spaces and can behave unexpectedly if the title contains multiple words. You can use bufio.Reader and ReadString('\n'), which is also fine. But for beginners, the most straightforward path is often bufio.Scanner: it reads “tokens”, and by default a token is a line.
Let us make a readSingleLine function that reads one line, trims whitespace on the edges, and checks that it is not empty.
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func readSingleLine(r io.Reader) (string, error) {
sc := bufio.NewScanner(r)
if !sc.Scan() {
return "", fmt.Errorf("expected one line, got EOF")
}
if err := sc.Err(); err != nil {
return "", fmt.Errorf("read input: %w", err)
}
return strings.TrimSpace(sc.Text()), nil
}
Notice an important “ritual pair” for Scanner: first Scan(), then Err(). Even if Scan() returns false, it might not be EOF but a read error—and Err() is what reveals that.
Now let us apply this to todo add -title -. Imagine we already parse flags for the add subcommand (like you did in previous lectures about FlagSet). Inside the handler:
package main
import (
"fmt"
"io"
)
func resolveTitle(titleFlag string, in io.Reader) (string, error) {
if titleFlag != "-" {
return titleFlag, nil
}
title, err := readSingleLine(in)
if err != nil {
return "", err
}
if title == "" {
return "", fmt.Errorf("title must not be empty")
}
return title, nil
}
The idea is simple: until the user writes -, we do not touch stdin at all. That means we do not hang unexpectedly.
Reading “many lines” is a classic CLI scenario. The user wants to do something like: “take this list and add everything”.
We will add a command idea to our app: import, which accepts one argument: a file path or - for stdin. Inside, we read lines, skip empty ones, and add the rest as tasks.
The “read all lines” function looks like this:
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func readNonEmptyLines(r io.Reader) ([]string, error) {
sc := bufio.NewScanner(r)
var lines []string
for sc.Scan() {
s := strings.TrimSpace(sc.Text())
if s != "" {
lines = append(lines, s)
}
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("read lines: %w", err)
}
return lines, nil
}
This is a small example, but it already shows good style: we validate data at the boundary (here: “empty lines”), and we wrap a read error with context read lines: ... so that higher up it is clear what exactly broke.
Now here is how it might look in a command handler:
package main
import (
"fmt"
)
func runImport(path string) error {
in, err := openInput(path)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer in.Close()
titles, err := readNonEmptyLines(in)
if err != nil {
return err
}
_ = titles // later we will add tasks to storage here
return nil
}
You may have noticed: we have defer in.Close() here, and it works the same way for both a file and stdin, because in the stdin case Close() is a “no-op” via io.NopCloser.
5. Large input and limits: be careful with io.ReadAll
Sometimes you want to make it simpler: “let us just do io.ReadAll(os.Stdin) and we are done”. And that really can be convenient... until someone accidentally feeds your program a gigabyte of data. Then you suddenly learn that memory is not infinite, and swap is not your friend.
So a very practical rule is: use io.ReadAll only when you are sure the input is small, or you artificially limited the size.
You can enforce a limit via io.LimitReader. Even if you are not yet building “decorators” like io.TeeReader, this one technique alone increases reliability a lot.
Mini example: read no more than 64KB from stdin (the number is approximate).
package main
import (
"fmt"
"io"
)
func readAllLimited(r io.Reader, limit int64) ([]byte, error) {
lr := io.LimitReader(r, limit)
b, err := io.ReadAll(lr)
if err != nil {
return nil, fmt.Errorf("read all: %w", err)
}
return b, nil
}
In “real” utilities, limits are often larger and depend on the task. But as a learning principle this is excellent for training the right habit: any external input can be large.
6. Errors and UX: usage vs runtime, and a hint for interactive input
Now the most important part of the day (and of all CLI UX): correctly classifying errors.
If the user specified the input mode incorrectly, that is usage. For example, the command requires a path argument, but the user provided nothing. Or provided two paths. Or wrote something we treat as “the wrong format”. This should produce exit code 2.
If the user chose the mode correctly (for example -), but reading failed (I/O error, unexpected pipe close, system error), that is runtime/internal, and it is exit code 1.
Psychologically this greatly improves UX, because:
- 2 means “fix the command”;
- 1 means “the command is correct, but something went wrong during execution”.
In Go, it is convenient to do this via a separate error type, for example usageError. We used a similar technique earlier, but now it is especially useful because stdin-related errors are easy to confuse.
package main
type usageError struct{ msg string }
func (e usageError) Error() string { return e.msg }
func isUsage(err error) bool {
_, ok := err.(usageError)
return ok
}
And then somewhere at the top level:
package main
func exitCode(err error) int {
if err == nil {
return 0
}
if isUsage(err) {
return 2
}
return 1
}
This clearly shows Go's philosophy: errors are ordinary values; we check them and make decisions.
Even with the - protocol, the user can get confused. For example, they run:
todo add -title -
and expect it to “pick something up on its own”, but it is waiting for a line. That is their choice, but you can make the UX a little friendlier: if we see that stdin is a terminal (meaning the user is not piping anything), we can print a hint to stderr like: “expecting a line from stdin”.
In Go, you can roughly check whether stdin is a terminal via os.Stdin.Stat() and the os.ModeCharDevice bit. It is not a perfect detector for everything, but it is enough for basic friendliness.
package main
import (
"os"
)
func stdinIsTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false // if we cannot determine it, do not get clever
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
And you can use it carefully, without turning the code into “system voodoo”:
package main
import (
"fmt"
"os"
)
func warnIfInteractiveStdin() {
if stdinIsTerminal() {
fmt.Fprintln(os.Stderr, "reading from stdin: enter one line and press Enter")
}
}
Important: this is not “mandatory logic”, just a small UX hint. Our architectural guarantee still relies on the explicit - protocol.
7. Common mistakes when reading stdin
Mistake #1: reading stdin “by default”, without an explicit mode.
This kind of code almost always leads to a “hang” at an unexpected moment. The user runs a command, expects a result, and the program waits for input. The correct approach is to first parse args/flags, understand the mode, and only if the user explicitly chose - (or a special flag), move on to reading.
Mistake #2: mixing result output to stdout with prompts or errors in stdout.
If a command can be used in pipelines, stdout should remain “data”. Any prompts like “enter a line” and any error messages are better printed to stderr. Then todo ... | other-tool will not break due to unexpected “oops” text.
Mistake #3: using bufio.Scanner without checking Err().
Scanner is designed so that read errors show up only via Err(). If you did not check Err(), you risk treating “truncated input” as “just EOF” and drawing wrong conclusions, especially when reading multi-line input.
Mistake #4: closing os.Stdin “because we call Close() everywhere”.
Formally, stdin is also a file, but you usually do not need to close it, and sometimes it can break the environment (especially if your process is not the only participant in the pipe). If you want to unify logic and always do defer Close(), use io.NopCloser(os.Stdin) so that Close() becomes safe and does nothing.
Mistake #5: io.ReadAll without limits on potentially large input.
This is a classic way to accidentally eat all memory. If you really want to read “everything at once”, at least cap the size via io.LimitReader so behavior is predictable and does not turn into a lottery.
GO TO FULL VERSION