Why Go doesn't format tables for you
You built a CLI tool that lists active connections. You print them with fmt.Println. The output looks like a wall of text where columns drift apart because names have different lengths. You try padding with spaces, but the math gets annoying fast. You need a grid that aligns columns automatically, handles wrapping, and looks decent in a terminal.
Go's standard library does not include a table formatter. This is a deliberate design choice. The standard library focuses on correctness and primitives. It gives you strings, bytes, and a lightweight tab-alignment tool. It leaves rich formatting to third-party packages. Formatting text for a terminal involves measuring character widths, handling ANSI escape codes, wrapping long lines, and drawing borders. Preferences vary wildly. Some users want borders, some want none. Some want auto-wrapping, some want truncation. The standard library avoids baking in opinions about UI. You pick a library that matches your needs.
The standard library gives you primitives. You build the UI.
The simplest table with a third-party library
The most common package for this job is github.com/olekukonko/tablewriter. It handles column alignment, borders, headers, and auto-wrapping out of the box. You install it with go get, create a writer, append rows, and render.
Here's the minimal setup. You create a writer bound to stdout, set headers, append data, and call render.
package main
import (
"os"
"github.com/olekukonko/tablewriter"
)
func main() {
// Create a writer bound to stdout.
table := tablewriter.NewWriter(os.Stdout)
// Set headers. The library uses these for alignment.
table.SetHeader([]string{"Name", "Age", "Role"})
// Append rows as slices of strings.
table.Append([]string{"Alice", "30", "Admin"})
table.Append([]string{"Bob", "25", "User"})
// Render draws the table to the writer's output.
table.Render()
}
The import order follows Go convention. Standard library imports come first, then third-party packages. gofmt enforces this automatically. Most editors run gofmt on save, so you never argue about indentation or import grouping.
How the renderer works
When you call NewWriter, the library stores a reference to os.Stdout. It does not print anything yet. SetHeader tells the renderer which columns exist. Append adds data to an internal buffer. Render iterates over the buffer, calculates column widths based on the longest cell in each column, pads shorter cells with spaces, draws the borders, and writes the result to stdout.
The key insight is that the library buffers the data. It needs to see all rows to calculate the maximum width for each column. If you stream rows one by one without buffering, you cannot align columns unless you know the widths in advance. Buffering enables alignment. Streaming requires fixed widths.
This buffering has a cost. If you try to render a million rows, the table writer stores all that text in memory before printing. For large datasets, you need pagination or a streaming approach. For typical CLI output of dozens or hundreds of rows, the memory cost is negligible.
Standard library alternative: text/tabwriter
If you want zero dependencies, the standard library offers text/tabwriter. It aligns columns based on tab characters. It does not draw borders. It is lighter and faster, but less feature-rich.
Here's how you use it. You create a tabwriter, write rows with tabs, and flush.
package main
import (
"os"
"text/tabwriter"
)
func main() {
// Create a tabwriter that writes to stdout.
// Parameters control min width, tab width, padding, and alignment.
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
// Write headers. Tabs separate columns.
w.Write([]byte("Name\tAge\tRole\n"))
// Write rows. The writer expands tabs to align columns.
w.Write([]byte("Alice\t30\tAdmin\n"))
w.Write([]byte("Bob\t25\tUser\n"))
// Flush writes the buffered content to stdout.
w.Flush()
}
The NewWriter parameters define the alignment rules. The first zero means no minimum width. The second zero means no minimum tab width. The 2 sets the padding between columns. The space character is the padding byte. The final zero disables alignment flags. tabwriter calculates column widths by scanning the input, then rewrites the output with expanded tabs.
Zero dependencies cost nothing. Third-party packages cost a review.
Realistic example: structs, context, and errors
In production code, your data lives in structs. You fetch it from a database or API. You need to handle errors and respect cancellation. Functions that fetch data take a context.Context as the first parameter and return an error. This is a universal Go convention.
Here's the data fetcher. It checks for cancellation before doing work.
package main
import (
"context"
)
type User struct {
ID int
Name string
Email string
}
// FetchUsers simulates a database call.
// It takes context as the first parameter per convention.
func FetchUsers(ctx context.Context) ([]User, error) {
// Check for cancellation before expensive work.
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Simulate data.
return []User{{1, "Alice", "alice@example.com"}}, nil
}
Here's the renderer. It fetches data, converts structs to strings, and renders the table. It returns an error if anything fails.
package main
import (
"context"
"fmt"
"os"
"github.com/olekukonko/tablewriter"
)
// PrintUsersTable renders the table.
// It returns an error if rendering fails.
func PrintUsersTable(ctx context.Context) error {
users, err := FetchUsers(ctx)
if err != nil {
return fmt.Errorf("fetch users: %w", err)
}
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"ID", "Name", "Email"})
for _, u := range users {
table.Append([]string{
fmt.Sprintf("%d", u.ID),
u.Name,
u.Email,
})
}
return table.Render()
}
The error handling follows the standard pattern. if err != nil is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You wrap the error with fmt.Errorf and %w to preserve the error chain. Context flows down. Errors bubble up.
Testing table output
Testing CLI output requires capturing stdout. You can swap os.Stdout for a bytes.Buffer and check the contents. This lets you verify the table structure without printing to the terminal.
Here's a test that captures output and asserts content.
package main
import (
"bytes"
"testing"
"github.com/olekukonko/tablewriter"
)
func TestTableOutput(t *testing.T) {
// Use a buffer to capture output instead of stdout.
var buf bytes.Buffer
table := tablewriter.NewWriter(&buf)
table.SetHeader([]string{"A", "B"})
table.Append([]string{"1", "2"})
table.Render()
// Check that the buffer contains expected text.
if !bytes.Contains(buf.Bytes(), []byte("1")) {
t.Error("expected table to contain '1'")
}
}
The buffer implements io.Writer, so the table writer treats it exactly like stdout. You can inspect the bytes after rendering. This pattern works for any CLI tool that writes to stdout.
Pitfalls and compiler errors
Tables introduce a few common issues.
If you pass the wrong type to Append, the compiler rejects the code. The library expects []string. If you pass []int, you get a type mismatch. The compiler complains with cannot use []int{...} as []string value in argument. You must convert numbers to strings using fmt.Sprintf or strconv.Itoa.
Unicode characters can break alignment. Some characters are wider than others. A full-width character might take two terminal cells. Naive string length calculations misalign columns. tablewriter handles Unicode width better than tabwriter. If you display non-ASCII text, test the output in your target terminal.
Goroutine leaks can happen if you spawn a goroutine to fetch data and forget to wait for it. If the goroutine writes to a channel that never gets closed, it blocks forever. Always have a cancellation path. Context is plumbing. Run it through every long-lived call site.
Wide characters break naive alignment. Measure twice.
When to use which approach
Use tablewriter when you need borders, auto-alignment, and rich formatting for small to medium datasets. Use text/tabwriter when you want zero dependencies and simple column alignment without borders. Use fmt.Printf with width specifiers when you have fixed-width columns and want to avoid external packages for trivial output. Use a custom CSV or JSON formatter when the output targets a machine parser rather than a human reader.
Pick the tool that matches your output audience.