JavaRush /Courses /Go SELF /JSON output in a CLI: formatting and determinism

JSON output in a CLI: formatting and determinism

Go SELF
Level 51, Lesson 2
Available

1. Why a CLI needs JSON output

If a table looks nice, a natural question comes up: “why do we need JSON as well?”. In practice it’s simple: tables are for eyes, JSON is for tools. A table is pleasant to read in a terminal, but inconvenient to parse in bash scripts, CI, monitoring, and pipes. JSON, on the other hand, is easy for machines to read—and tolerable for humans (especially with pretty-printing enabled).

In the CLI world, a JSON mode is not “a checkbox feature”; it’s a way to make output a stable contract. For example, a user might do this:

  • print a list of tasks as JSON,
  • then filter it with an external tool,
  • then turn it into a report.

And if your JSON “drifts” from run to run, those scenarios start breaking… sometimes. And “sometimes it breaks” is the favorite state of bugs: you didn’t change anything, but it’s already not working.

To keep the mental model simple, it helps to think like this: a command produces data, and a renderer turns it into a format.

flowchart LR
    A["data: []Task"] --> B[renderer]
    B --> C[stdout: table]
    B --> D[stdout: JSON]

2. The JSON output contract

When we say “JSON output”, we usually mean a very specific rule: the command must print exactly one JSON value (an array or an object) to stdout and not mix in any “prettiness”. In JSON mode you must not print "OK", "Total: 5", "Done!", or other friendly words—because friendliness breaks parsing.

This rule sounds boring, but it saves you from surprises. After all, a user might do something like:

tasker list --format=json | jq '.items[] | select(.done == false)'

If you suddenly add a line like "found 10 tasks" before the JSON, jq will say “I don’t understand”, and it will be right. A table can be chatty; JSON must be silent and predictable.

It’s also important to remember stderr. If an error happens (for example, you failed to read the database file), in JSON mode you don’t “insert the error into JSON” (unless you explicitly designed the protocol that way). Instead, print the error to stderr and return a non-zero exit code. This continues our earlier agreement about stdout/stderr and the general Go approach: “errors are values; return them explicitly and handle them”.

3. struct instead of map[string]any

When a student wants to output JSON for the first time, their hand reaches for map[string]any: “I’ll just stuff fields in there and go”. It works… but only until you want stability, readability, and predictability.

struct has three practical advantages.

First, a struct is an explicit contract: the set of fields is known, the types are known, the tags are known. The IDE helps, the compiler helps, and you make fewer mistakes.

Second, serializing a struct in Go is usually more predictable in terms of field order (this matters to us because of text determinism and golden tests).

Third, encoding/json has official extension points: the Marshaler and Unmarshaler interfaces, in case you ever need fine-grained serialization control. We won’t go deep into custom marshaling today, but it’s useful to know that an “official path” exists—this is not hacks or magic.

So in the learning app (our CLI task manager) we’ll produce JSON responses via a wrapper struct like:

  • an object { items: [...], total: N } for a list,
  • and inside items—tasks as DTOs (structs “shaped for the format”).

4. json.Marshal and json.Encoder

In Go there are two common ways to get JSON. The first is json.Marshal(v) and then out.Write(bytes). The second is json.NewEncoder(out).Encode(v). Both are fine, but for a CLI Encoder is often more convenient because it writes directly to an io.Writer and returns an error.

Let’s compare them in a small table (no philosophy—just the feel from a developer who doesn’t want to suffer):

Approach What it looks like What’s convenient Where you can stumble
json.Marshal + Write
“Bytes first, then print” Convenient if you need to further process the bytes Easy to forget \n, easy to forget to handle write errors
json.Encoder.Encode
“Write directly to the stream” Fits io.Writer perfectly, simpler for large data You must remember encoder settings (indent, escape)

Since we already agreed that renderers should accept an io.Writer and return an error, Encoder fits perfectly: it’s literally “streaming rendering”. And it aligns nicely with Go’s philosophy of explicit error handling.

Mini example: “manual bytes”

package main

import (
	"encoding/json"
	"io"
)

func renderJSONViaMarshal(out io.Writer, v any) error {
	b, err := json.Marshal(v)
	if err != nil {
		return err
	}
	_, err = out.Write(append(b, '\n'))
	return err
}

Everything is fine here, but note that we manually added \n. If you forget it, the output won’t end with a trailing newline. It seems minor, but it affects text determinism (and annoys terminals).

Mini example: “write via encoder”

package main

import (
	"encoding/json"
	"io"
)

func renderJSONViaEncoder(out io.Writer, v any) error {
	enc := json.NewEncoder(out)
	return enc.Encode(v)
}

Less code, and it expresses the intent better: “I encode and write”.

5. Readable JSON: SetIndent and SetEscapeHTML

By default JSON comes out “on one line”. That’s fine for machines, but for humans it’s harder to read: it turns into a “wall of text”.

When a CLI runs in interactive mode, pretty JSON is a reasonable way to care about the user. In Go you do it via Encoder.SetIndent(prefix, indent).

package main

import (
	"encoding/json"
	"io"
)

func renderPrettyJSON(out io.Writer, v any) error {
	enc := json.NewEncoder(out)
	enc.SetIndent("", "  ")
	return enc.Encode(v)
}

Two spaces are a typical compromise: pretty enough without being too “wide”.

One more nuance is HTML escaping. Go’s JSON encoder escapes some characters by default (<, >, &) to make JSON safer to embed into HTML. For a CLI this is usually unnecessary, and sometimes it even hurts readability (what if your task says “Fix <bug>”?). The setting exists on Encoder as SetEscapeHTML(false).

package main

import (
	"encoding/json"
	"io"
)

func renderPrettyJSONNoEscape(out io.Writer, v any) error {
	enc := json.NewEncoder(out)
	enc.SetIndent("", "  ")
	enc.SetEscapeHTML(false)
	return enc.Encode(v)
}

The key point is not to confuse this: we are not making the program “unsafe”; we’re simply making the output more expected for a terminal. We’re not embedding this JSON into a browser (we’re building a CLI, not a frontend).

6. Determinism of JSON output

The word “determinism” sounds like a spell from an algorithms book, but in a CLI it means a simple thing: if the data is the same, the output must match byte-for-byte (including the trailing newline). This matters for scripts, snapshot tests, comparing outputs in CI, and just peace of mind.

In practice, three things most often break determinism in JSON output.

The first is the order of elements in an array. A JSON array ([...]) is ordered. That means if your tasks come in a different order, the JSON will be different. So the task list must arrive to the renderer already in the correct order. We fixed this at the “output pipeline” level, and we discuss sorting as a separate topic. Our position here is simple: the renderer does not sort; it prints what it’s given.

The second is map. Iteration order over a map in Go is not guaranteed, and that is a deliberate language property: you must not build an output contract on a random key order. So if you expose a JSON object via map[string]any, you will almost certainly get shaky output. It’s much calmer to return a struct.

The third is “random” fields. For example, you add a "generated_at" field with the current time. Formally the data is the same, but the text is always different. Sometimes you do want that, but then it’s a conscious decision: you removed determinism yourself. In the learning app we won’t do that: the output of the list command must not depend on the current second.

By the way, since we’re talking about JSON and errors: the encoding/json package returns typed errors, for example *json.SyntaxError when parsing invalid JSON. This is a good example of how in Go errors are also data with structure, not just a string. Today we focus more on output, but remember the idea: errors can be a “contract” too.

7. Integrating the JSON renderer into the app

TaskDTO and ListResponse

Now let’s assemble everything in code for our learning CLI app (let’s call it tasker). Suppose our domain model for a task is like this (minimal, without extra complexity):

package main

import "time"

type Task struct {
	ID        int
	Title     string
	Done      bool
	CreatedAt time.Time
}

If we start printing Task as-is, we quickly run into two problems. First, the domain model may change for its own reasons, while the output format changes for its own reasons. Second, time.Time looks fine in JSON, but you may want to control the fields (for example, print "created_at" in UTC or print only the date). So we’ll make a DTO—a “struct shaped for the format”.

package main

type TaskDTO struct {
	ID        int    `json:"id"`
	Title     string `json:"title"`
	Done      bool   `json:"done"`
	CreatedAt string `json:"created_at"`
}

Notice: CreatedAt became a string. Not “because time is bad”, but because in a CLI contract you often want to explicitly decide what exact text you expose. If tomorrow you want to change the date format, you’ll do it in one place.

Now the response wrapper:

package main

type ListResponse struct {
	Items []TaskDTO `json:"items"`
	Total int       `json:"total"`
}

And a small conversion function (intentionally short and straightforward):

package main

import "time"

func toTaskDTO(t Task) TaskDTO {
	return TaskDTO{
		ID:        t.ID,
		Title:     t.Title,
		Done:      t.Done,
		CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
	}
}

Why UTC()? Because it’s a simple way to remove “dependency on the machine’s locale”. If one developer runs the CLI in UTC-8 and another in UTC+3, the time output in a “local zone” may differ. In a learning app, stability is more useful.

renderJSON(out, tasks) as a “pure renderer”

Now we write the renderer. It should do three things: build DTOs, configure the encoder, write the result. It must not filter, sort, read flags, or touch files. Only print prepared data.

package main

import (
	"encoding/json"
	"io"
)

func renderJSON(out io.Writer, tasks []Task) error {
	items := make([]TaskDTO, 0, len(tasks))
	for _, t := range tasks {
		items = append(items, toTaskDTO(t))
	}

	resp := ListResponse{Items: items, Total: len(items)}

	enc := json.NewEncoder(out)
	enc.SetIndent("", "  ")
	enc.SetEscapeHTML(false)
	return enc.Encode(resp)
}

There are a few important “small details” here that actually define the contract.

First, we always return total as len(items). This is deterministic and does not depend on external circumstances. Second, Encode writes one JSON object and terminates it with a newline (convenient for terminals and pipes). Third, we don’t use a map, so the field order in the object is more predictable (and, more importantly, the contract structure is explicit).

If you want to see how it looks, you can imagine output like this:

{
  "items": [
    {
      "id": 1,
      "title": "buy milk",
      "done": false,
      "created_at": "2026-01-16T00:00:00Z"
    }
  ],
  "total": 1
}

Element order: responsibility of data preparation

Sometimes you really want to “for convenience” put sorting right into renderJSON. It seems logical: “since I print the list, I’ll also sort it”. But it’s an architectural trap: the renderer starts making decisions that belong to the output business logic.

It’s better to keep discipline: you have a data preparation step (pipeline), and the renderer only formats. Then you can guarantee that the table and JSON show elements in the exact same order, because they receive the same prepared list.

This discipline is especially useful when you have two output modes (table/json). If the order is defined in two places, one day you’ll get “one thing in the table, another in JSON”, and it will annoy users as much as possible—both humans and scripts.

8. Common mistakes

Mistake #1: the “JSON mode” prints extra lines.
A very common problem: you implemented JSON, but somewhere in the code there’s still a leftover fmt.Println("OK") or “Total tasks: 10”. For the eye it’s harmless, but for a parser it’s a catastrophe. The best cure is stdout/stderr discipline: JSON goes to stdout, any messages (errors, hints) go to stderr, plus a single rendering entry point.

Mistake #2: JSON is built via map[string]any, and key order starts drifting.
On small examples it may seem that everything is “already stable”, but that’s an illusion. As soon as you run the program many times or change the environment, key order can start changing. For JSON as a format this is not an “error” (semantically an object is unordered), but for tests and output comparisons it’s painful. In a CLI it’s almost always better to use a struct with json tags.

Mistake #3: encoding or write errors are ignored.
Sometimes people write json.NewEncoder(out).Encode(v) and don’t check the error. Then they wonder why “sometimes the pipe is empty”. Write errors are real: stdout can be closed, disk space can run out, the pipe can break. In Go it’s customary to return an error and handle it at the top, because errors are values.

Mistake #4: time.Time is printed “however it comes out” and depends on the machine’s local time zone.
If you serialize time in the local zone, two runs on different machines can produce different results. This is especially noticeable when one developer is in UTC, another in PST, and CI lives its own life. If you want a stable contract—normalize time (often via UTC) and use an explicit string format.

Mistake #5: the renderer starts doing filtering/sorting/flag reading.
This breaks responsibility boundaries: your “formatting” suddenly becomes “business logic”. As a result, the code is harder to test and easier to accidentally desynchronize table and JSON modes. Keep the renderer pure: it should receive ready data and turn it into text, without deciding “what to show”.

1
Task
Go SELF, level 51, lesson 2
Locked
User profile
User profile
1
Task
Go SELF, level 51, lesson 2
Locked
Pretty Title
Pretty Title
1
Task
Go SELF, level 51, lesson 2
Locked
List and total
List and total
1
Task
Go SELF, level 51, lesson 2
Locked
Stable JSON
Stable JSON
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION