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 |
|---|---|---|
|
|
Where to actually write the result (stdout, file, buffer) |
|
|
Minimum column width |
|
|
Tab stop width (in characters) for internal calculations |
|
|
How many spaces to add between columns |
|
|
What to fill the “empty space” with (usually a space) |
|
|
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.
GO TO FULL VERSION