JavaRush /Courses /Go SELF /Tabular output in the CLI — text/tabwriter

Tabular output in the CLI — text/tabwriter

Go SELF
Level 51, Lesson 1
Available

1. Why you need a table in a CLI

When you write the first version of a CLI, you really want to do this: “well, I’ll just print the fields separated by spaces.” With small data sets it looks fine… right up until you get strings of different lengths. And then the terminal’s classic pain begins: columns “drift,” your eye can’t latch onto what you need, and the user starts reading diagonally and getting annoyed (and an annoyed user is almost like an annoyed compiler: it won’t explain the reason, but it will ruin your life).

A table solves this in a simple way: we agree up front on the columns and alignment. In Go, the standard library provides a ready-to-use tool for this—the text/tabwriter package.

Why manual alignment breaks quickly

Let’s start with an honest “how beginners do it” (and sometimes—tired grown-up developers on a Friday evening).

Imagine our todo utility stores tasks:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Now let’s print the list “head-on”:

package main

import "fmt"

func printNaive(tasks []Task) {
	fmt.Println("ID TITLE DONE")
	for _, t := range tasks {
		fmt.Println(t.ID, t.Title, t.Done)
	}
}

The problem is that fmt.Println separates arguments with spaces, but it does not align columns. If headers and values have different lengths, everything “floats.” You can try adding spaces manually, but that quickly turns into a mini-game of “guess the column width,” especially once you have multiple languages, long names, statuses, dates, and so on.

We need a tool that will do the alignment for us—and ideally without writing our own mini-Excel in 300 lines.

2. How text/tabwriter works

The idea: “write with tabs—get neat columns”

tabwriter is like a “typesetter” for monospaced text. You write lines where columns are separated by the tab character \t, and it decides how many spaces to insert to make everything align nicely.

It’s important to lock in two rules right away, because people often mix them up.

A table in tabwriter is built not from spaces, but from tabs \t. You literally write "ID\tTITLE\tDONE\n", and that means “three columns.”

End-of-line \n is the end of a table row. In other words, you form the table “by rows,” as if you were printing CSV, except you use tabs instead of commas.

If you keep the analogy, then \t is the “column boundary,” and tabwriter is the thing that picks the right number of spaces so the columns line up.

Minimal example: a writer and printing a table

Now let’s assemble the smallest working example that produces a neatly aligned table. Notice: tabwriter.NewWriter returns a writer (io.Writer)—that’s what we will “print” into.

package main

import (
	"fmt"
	"os"
	"text/tabwriter"
)

func main() {
	tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)

	fmt.Fprintln(tw, "ID\tTITLE\tDONE")
	fmt.Fprintln(tw, "1\tBuy milk\tfalse")
	fmt.Fprintln(tw, "2\tVery long task title\ttrue")

	_ = tw.Flush()
}

Here you can already see the key idea: we print into tw, not into os.Stdout. tabwriter buffers and formats internally. And at the end we call Flush() so it “writes out” everything into the real Stdout.

Flush() is a required final step

Beginners often get the feeling that Flush() is some optional ceremony. Like, “well, the table already printed… probably.” And that’s where the terminal starts playing psychological games with you: “sometimes it works, sometimes it doesn’t.”

The truth is: tabwriter buffers data, and until you call Flush(), part of the output may remain inside it. In that sense, it’s similar to other buffered writers that accumulate data and only report an error at the end. For example, with bufio.Writer the typical pattern is many Write calls, then one Flush that returns the error.

For us, the practical rule is simple: table rendering ends with Flush(), and the Flush() error must be returned upward like a normal write error.

3. A render function without tying it to os.Stdout

renderTasksTable(out, tasks)

In a real CLI you rarely want to “hardcode” os.Stdout directly inside your renderer. The reason is simple: the fewer hard dependencies your code has, the easier it is to reuse and test. So we’ll make a function that accepts an io.Writer.

Let’s start with the signature:

package main

import "io"

func renderTasksTable(out io.Writer, tasks []Task) error {
	// ...
	return nil
}

Now let’s fill it in. I’ll intentionally keep the example short, but working:

package main

import (
	"fmt"
	"io"
	"text/tabwriter"
)

func renderTasksTable(out io.Writer, tasks []Task) error {
	tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)

	fmt.Fprintln(tw, "ID\tTITLE\tSTATUS")
	for _, t := range tasks {
		status := "UNDONE"
		if t.Done {
			status = "DONE"
		}
		fmt.Fprintf(tw, "%d\t%s\t%s\n", t.ID, t.Title, status)
	}
	return tw.Flush()
}

Notice the good “CLI character” of this function: it knows nothing about flags, sorting, data sources, and so on. It does one thing: turns []Task into a table.

Parameters of tabwriter.NewWriter

When you first see this:

tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)

there are two common reactions. The first: “I don’t understand anything, but I copied it.” The second: “I don’t understand anything, and now I’m afraid it’s witchcraft.”

Let’s turn it into a clear picture. The signature looks like this (simplified in meaning):

Parameter Type Plain-English idea
out
io.Writer
Where to actually write the result (stdout, file, buffer)
minwidth
int
Minimum column width
tabwidth
int
Tab stop width (in characters) for internal calculations
padding
int
How many spaces to add between columns
padchar
byte
What to fill the “empty space” with (usually a space)
flags
uint
Behavior flags (default is 0)

Why do people set 0, 0, 2, ' ', 0 almost everywhere? Because it’s a good baseline default: “don’t force a minimum width,” “don’t complicate tab stops,” “use 2 spaces between columns,” “pad with spaces,” “no special modes.”

If you want a “denser” table, reduce padding. If you want more breathing room, increase it. This isn’t math—it’s taste.

The table header is part of the contract

A CLI table is not just “pretty output.” For the user (and especially for automation) two things matter: the column order and the column names. Even if the user doesn’t write scripts, they remember visually: “ID on the left, status on the right.” If you swap columns or rename them, the user will feel like they were tricked.

So the header is printed the same way as the rows: separated by \t, in a fixed order:

fmt.Fprintln(tw, "ID\tTITLE\tSTATUS")

And then you print rows in exactly the same order. The idea is simple: the header and rows must match structurally.

4. Real life: data and debugging

What to do with \t and \n in data

This is where real life begins. A user can create a task with a title that contains a tab or a newline. For example, they pasted a chunk of text from chat (ironically, yes?), and it contained \n. What happens?

tabwriter will honestly treat \t as a column separator, and \n as the end of a row. And your table will “fall apart” as if it tried to escape from the terminal into the woods.

So before printing into a table, it’s useful to “neutralize” such characters. The simplest option is to replace them with spaces:

package main

import "strings"

func cleanCell(s string) string {
	s = strings.ReplaceAll(s, "\t", " ")
	s = strings.ReplaceAll(s, "\n", " ")
	return s
}

And use it:

fmt.Fprintf(tw, "%d\t%s\t%s\n", t.ID, cleanCell(t.Title), status)

This isn’t “perfect handling for everything in the universe,” but for basic tabular output it’s a fair and understandable step: “a table must remain a table.”

End-to-end example: render a task list as a table

Now let’s assemble a small piece of “our app” so everything reads like one story rather than disconnected fragments.

First, let’s prepare the data:

package main

func demoTasks() []Task {
	return []Task{
		{ID: 1, Title: "Buy milk", Done: false},
		{ID: 2, Title: "Write report", Done: true},
		{ID: 3, Title: "Call mom (important!)", Done: false},
	}
}

Now a main that prints the table:

package main

import (
	"fmt"
	"os"
)

func main() {
	tasks := demoTasks()

	if err := renderTasksTable(os.Stdout, tasks); err != nil {
		fmt.Fprintln(os.Stderr, "render error:", err)
		os.Exit(1)
	}
}

And if you did everything right, the output will look roughly like this (spaces may differ, but the columns will be aligned):

ID  TITLE                 STATUS
1   Buy milk              UNDONE
2   Write report           DONE
3   Call mom (important!) UNDONE

Notice the nice part: we didn’t count spaces at all. Not a single space was harmed in manual alignment.

How to “see tabs”: Debug mode

Sometimes it’s very useful to understand where exactly tabwriter inserts spaces, and what’s happening “under the hood.” To do that, you can enable the debug flag (in the package it’s called tabwriter.Debug). Then tabwriter will show column boundaries using special symbols.

I won’t turn this lecture into a tour of flags, but remember the technique: if you don’t understand why your table looks weird, temporarily enable Debug and check where you really have tabs and where you really have spaces.

5. Common mistakes when working with text/tabwriter

Mistake #1: you forgot to call Flush(), and part of the output “disappeared.”
tabwriter is not required to print everything immediately: it formats columns and may buffer. So “the table printed, but not all of it” is a classic symptom of a missing Flush(). A correct table render ends with return tw.Flush(), and the error from there should propagate upward. The “many Write calls, then Flush and check the error” pattern is typical for buffered writers.

Mistake #2: you separate columns with spaces instead of \t.
tabwriter aligns by tabs, not by “any number of spaces.” If you write "ID TITLE STATUS", that’s just a string with spaces, and there will be no alignment. A tabwriter table starts where you start placing \t.

Mistake #3: you print to os.Stdout instead of tw.
A common situation: you print the header to tw, but the rows are accidentally printed with fmt.Println(...) without tw. As a result, the header is “pretty,” but the rows are “crooked.” Make a deal with yourself: while you’re in table-output mode, everything related to the table goes into tw.

Mistake #4: the data contains \t or \n, and the table falls apart.
User input is tricky. One tab in a task title turns into an “extra column,” and one newline turns into an “extra row.” The simplest baseline recipe is to clean such characters before output (strings.ReplaceAll) so the table remains a table.

Mistake #5: you ignore the Flush() error and lose write errors.
If out is a file or a pipe, writing can fail (for example, if the pipe was closed). If you do _ = tw.Flush() and don’t return the error, you deprive yourself of the chance to handle the problem correctly. It’s better to return an error from the renderer and handle it at the command’s top level.

1
Task
Go SELF, level 51, lesson 1
Locked
Currency Exchange Board
Currency Exchange Board
1
Task
Go SELF, level 51, lesson 1
Locked
Exam Report
Exam Report
1
Task
Go SELF, level 51, lesson 1
Locked
Inventory table
Inventory table
1
Task
Go SELF, level 51, lesson 1
Locked
Tasks table
Tasks table
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION