JavaRush /Courses /Go SELF /Why output stability matters

Why output stability matters

Go SELF
Level 51, Lesson 0
Available

1. What stable CLI output is

When you write your first CLI utility, it feels like output is just fmt.Println(...) and “well, it works”. But if your program lives longer than five minutes, people, scripts, pipes, CI, tests appear around it… and each of them starts treating the output as a contract. That is, as a promise: “this is what the result will look like, and I rely on it”.

Stable output is when, given the same input, the program prints the same result: the same lines, in the same order, with the same spaces, tabs, and a trailing newline at the end. Sometimes this sounds like “as boring as possible”, but in engineering, boring is a compliment. In the official Go blog, there is even the idea that “boring is stable” (“boring means stable”) in the context of compatibility and predictability.

To ground things, let’s introduce a working definition.

CLI output stability is a set of rules that make a command’s result predictable: same data → same text/JSON, with no “floating” order, no random timestamps, no junk in stdout, and with the same headers/columns.

And yes, this directly affects trust in your program. A user may forgive you for not coloring output in 17 shades. But a user (and CI) will not forgive “yesterday the list was like this, and today it’s the same list, but in a different order”.

2. Who consumes output: a person, a script, and a test

If you write a CLI only for yourself, there’s a temptation to think: “I’m looking at it with my eyes—works for me”. But a CLI almost never remains “just for the eyes”. And then three types of output consumers appear, each with their own pain.

A person reads output visually and wants it to be clear and consistent: task order doesn’t jump around, headers stay in place, new versions of the program don’t turn familiar output into a quest of “find where the STATUS column went”.

A script reads output as a stream of characters. A script has no empathy. It will not guess that you “only added the word Total: in the middle of the output”. A script either parses it or breaks. And it usually breaks at night, on Friday, in production—just so you don’t расслаблялись.

A test (and especially the snapshot approach that we will use in course project checks) compares the expected text to the actual text. In Go you can see this even with example tests: the framework captures what you print to stdout and compares it with the // Output: comment. If the output “shakes”, the test will fail in inconsistent ways.

So output stability is not “beauty”, it’s lowering the cost of living for the project: fewer false test failures, fewer broken pipes, less “it works differently on my machine”.

3. Where output instability comes from

Usually output becomes unstable not because the developer is a bad person, but because the developer is… a person, and they have a map, time.Now(), and the urge to “slightly improve the message”.

Let’s go through the most common causes. To make it easier to keep in mind, we’ll put them into a table. This is not “formal math”, but a real list of pitfalls that even experienced folks step on (especially when they’re in a hurry).

Source of instability What it looks like in a CLI Why it hurts How to fix it
Iteration order of a map Items appear in a different order on each run Tests/scripts “sometimes pass, sometimes don’t” Convert to a slice and sort
“Print as we iterate” Output is scattered across different if branches Impossible to guarantee order and format Prepare data first, then print
time.Now() in output generated at ... in every command Each run is unique → tests are meaningless Don’t print time by default, or make it optional
Mixing stdout and stderr Errors and “result” go into the same pipe Scripts get garbage instead of data Result → stdout, diagnostics → stderr
Unstable spaces/tabulation “Looks fine”, but lines differ String comparison breaks Explicit separators, consistent rules
Floating headers/columns Today “ID TITLE”, tomorrow “TITLE ID” This changes the contract Fix the order and treat it as part of the API

The idea is simple: output should be boring and repeatable. Computers are actually strong at that: they diligently repeat the same thing. Even in official material about random-number generators there’s a good phrasing: “computers aren’t random… they try to execute programs the same way every time”—and that’s exactly what we need from output.

4. Strategy: data first, then printing

Now we’ll start evolving our learning CLI task app (let’s call it todo). For now, no files, no JSON modes, and no pretty tables: today it’s important to catch the principle “output is a contract”.

Let’s start with the task model. It will be simple so it doesn’t distract from output.

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Now imagine that for now we store tasks in a map[int]Task (typical: it’s convenient to fetch by ID). And you want to print the list.

Unstable printing from a map

After this heading, you want to take a breath and admit it: almost everyone has done it this way. Me too. And then tests started living their own life.

package main

import "fmt"

func printTasksBad(tasks map[int]Task) {
	for id, t := range tasks {
		fmt.Printf("%d: %s (done=%v)\n", id, t.Title, t.Done)
	}
}

The problem is that the iteration order of a map in Go is not guaranteed. This means you can get a different line order between runs. Sometimes it “seems the same”, but that’s exactly the kind of program that is saving a surprise for later.

And here the main principle of the day is born: stability is not a “fmt setting”, it’s pipeline architecture.

Make it deterministic: map → []Task → sort

We need two steps: first get the task list as a []Task, then sort it, and only then print.

package main

import "sort"

func tasksToSlice(tasks map[int]Task) []Task {
	out := make([]Task, 0, len(tasks))
	for _, t := range tasks {
		out = append(out, t)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out
}

Notice: we only prepared the data. There isn’t a single fmt here. This is a very important habit: printing in one place, preparation in another.

And now printing becomes predictable:

package main

import "fmt"

func printTasksStable(tasks []Task) {
	for _, t := range tasks {
		fmt.Printf("%d: %s (done=%v)\n", t.ID, t.Title, t.Done)
	}
}

Now we have a stable order: by ID. That’s already a contract: “the task list is always sorted by ID ascending”.

5. Practice: make output boring and predictable

Output pipeline: got it → prepared it → printed it

Once you realize that “printing is a contract”, the next step becomes a very simple but powerful structure: an output pipeline. It helps not only stabilize the result, but also keep the code readable.

You can think of output not as a single “print” button, but as a small assembly line: raw material (data) goes through operations (preparation), and the final product (text/JSON) comes out. If you start drilling the casing at a stage where you don’t even have the part yet, it will be crooked.

The pipeline looks like this:

flowchart LR
    A[Data source] --> B[List preparation]
    B --> C[Order stabilization]
    C --> D[Render to stdout]
    E[Errors/diagnostics] --> F[stderr]

In code terms, you can wrap it in one function that clearly shows the order of steps:

package main

func listPipeline(tasks map[int]Task) []Task {
	items := tasksToSlice(tasks) // including sorting
	return items
}

Yes, for now it’s “too simple”. But these “too simple” functions later save the project when you add filters, sorting by different fields, and multiple output formats. If you lock in the pipeline idea early, extending it won’t turn into a mess of fmt.Printf inside if.

Separate stdout and stderr

Many beginners sincerely think: “I’ll just add fmt.Println("OK") so it’s clearer”. Visually, it really is clearer. But if the command is used in a pipe, you just planted a mine.

It’s important to switch your mindset: stdout is “data”, stderr is “the program talking about its problems”. If you mix them, the user might not notice. But the script will notice—only when it’s too late.

The rule is simple: everything that is the command result goes to stdout. Any errors, warnings, usage, hints go to stderr.

Mini example:

package main

import (
	"fmt"
	"os"
)

func warn(msg string) {
	fmt.Fprintln(os.Stderr, "warning:", msg)
}

Now imagine you have a todo list command that prints tasks to stdout. If you accidentally print “Loaded 10 tasks” to stdout, and the user runs todo list | wc -l, then wc will count “Loaded 10 tasks” too. The script is not at fault. We are.

This immediately creates another micro-contract: in a mode where stdout is data, stdout must be “clean”. No banners, no “success!”, no “like and subscribe”.

Format details: trailing newline and spaces

Sometimes it feels like “who cares if there’s a \n at the end or not”. The difference shows up when you compare strings in a test, concatenate outputs, use diff, store golden files, and so on. Then one “extra” or “missing” newline becomes a permanent minor pain.

A simple rule: any command that prints a textual result ends output with a newline. This makes output terminal-friendly and predictable for comparisons.

In Go, this usually means you prefer fmt.Println(...) or fmt.Fprintf(out, "... \n"), and you don’t forget \n in Printf.

package main

import "fmt"

func printOneLine() {
	fmt.Println("hello") // hello
}

Spaces are similar. If you sometimes use one space, sometimes three, sometimes a tab, sometimes “I’ll align it manually”, you will inevitably get “looks fine, but the test failed”. Output is a byte string; it has no concept of “roughly the same”.

So “stability” is also discipline: one separator style, one header style, one boolean style. For example, you decide done=true/false—and you stick to it. Or you decide DONE/UNDONE—and you stick to it. The key is not changing it “depending on mood”.

Why output tests don’t work without determinism

We’re not diving deep into CLI testing yet, but it’s important to understand the motivation: output tests become possible only when output is deterministic.

It’s convenient to recall Go example tests: an example in documentation can simultaneously be a test because go test captures stdout and compares it with the expected // Output:. And if your output depends on map order or current time, the “example test” turns into a lottery.

Determinism makes output an “artifact” you can compare: yesterday’s output = today’s output (with the same data). And then you get a superpower: you can automatically catch regressions when someone “slightly tweaked the format”.

In development terms, it looks like this: you don’t argue “does the list print нормально now?”, you just see the test say “the format changed”. And then you decide whether it was an intentional contract change or an accidental break.

Mini example: todo list with stable output

Let’s assemble a small but complete program fragment so it feels like “I can run this, and it will be stable”.

We’re not doing full architecture and we’re not hooking up storage. We’re just locking in that even with mock data we can print a task list in a way that doesn’t jump around.

package main

import "fmt"

func main() {
	tasks := map[int]Task{
		2: {ID: 2, Title: "buy milk", Done: false},
		1: {ID: 1, Title: "learn Go", Done: true},
	}

	items := tasksToSlice(tasks)
	printTasksStable(items)

	fmt.Print("") // don't print any "service" stuff to stdout
}

If you run this many times, the order will always be the same:

1: learn Go (done=true)
2: buy milk (done=false)

And that feeling “I can rerun it and get the same result” is the foundation of stable output.

6. Typical mistakes when designing stable output

Mistake No. 1: “range over a map and print right away—what’s the big deal”.
While there’s little data, it seems the order is “roughly the same”. But in reality you just got lucky. As soon as you start comparing output in tests or using it in scripts, the order will start shaking. Fix it by separating data preparation from printing: map → []Task → sort → render.

Mistake No. 2: “I’ll add a little explanation to stdout, it’s convenient”.
Convenient for eyes, but it breaks pipes. The command result must be clean, especially if it is meant to be parsed or forwarded. Any “Loaded X tasks”, “OK”, “Done” should go either to stderr or be optional (for example via a --verbose flag, but the flag itself is a separate design question).

Mistake No. 3: mixing filtering/logic and printing in one function.
As soon as you get constructs like “if the task matches—print it”, you lose control over order, format, and what is even considered the command result. It’s much more stable to first assemble the list of “what to output”, and then, with one function, define “how to output it”.

Mistake No. 4: “floating details”—a forgotten \n, different spaces, different headers.
It sounds like nonsense until you see a test fail because of one character. If you treat output as a contract, then the contract is every byte. The discipline here is boring, but saving: one consistent line style and a trailing newline at the end.

Mistake No. 5: random data in output (time, random ids, hashes) without need.
It’s very tempting to add “for debugging”, but if it ends up in the main command output, you kill determinism. Such things are either not printed by default, or printed to stderr as diagnostics, or enabled via a separate mode.

1
Task
Go SELF, level 51, lesson 0
Locked
Stable catalog
Stable catalog
1
Task
Go SELF, level 51, lesson 0
Locked
Safe division
Safe division
1
Task
Go SELF, level 51, lesson 0
Locked
Log frequencies
Log frequencies
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION