Hello! Welcome to the next lesson in our journey to build powerful DevOps tools with Go.
In the last lesson, we focused on how to make our tools aware of their environment. You learned to read configuration from environment variables using the os package and to handle file paths in a cross-platform way with path/filepath. These skills are about getting data into your application.
Today, we'll tackle the other side of the coin: getting data out in a clean, professional, and readable way. The learning outcome for this lesson is to produce formatted and column-aligned terminal output using fmt and text/tabwriter.
Think about the CLI tools you use every day, like kubectl get pods, docker ps, or git status. Their output is structured, aligned, and easy to parse at a glance. This lesson will give you the skills to build that same level of professionalism into your own Go applications.
1. The fmt Package: Your Formatting Workhorse
The fmt package is one of the first packages you encounter in Go, and for good reason. It's the standard library's solution for formatted I/O. While you've likely used fmt.Println already, the package offers much more control.
The functions in fmt generally come in three families:
Print...: Writes to standard output (os.Stdout).Fprint...: Writes to a specifiedio.Writer. This is incredibly useful for directing output to files, network connections, or, as we'll see, standard error (os.Stderr).Sprint...: Formats data into a string variable in memory, without printing it.

Fine-Grained Control with fmt.Printf
fmt.Printf is the key to precise formatting. It uses "format verbs" (placeholders like %s or %d) to control how data is represented in the output string.
The following video provides an excellent introduction to the fmt package, covering the differences between Println and Printf, and introducing the most common format verbs.
Go's full output power: fmt, ANSI Colors & (s)logging
Watch this segment from the video "Go's full output power" by Flo Woelki to understand the fundamentals of formatted printing.
Watch from the beginning to 05:48. Pay attention to: The key differences between fmt.Println and fmt.Printf (00:09 - 02:55). The table of format verbs like %s (string), %d (decimal), %f (float), %v (default value), and %+v (struct with field names) (02:55 - 05:48). After that, jump to 09:46 - 11:00 to see how fmt.Sprintf is used to format a string into a variable for later use.
As you saw, verbs like %v, %T, and %+v are fantastic for debugging, while %s, %d, and %f give you control over production output.
Manual Alignment: Width and Precision
You can add numbers to format verbs to control width and padding. This is a first step towards creating aligned columns, though as we'll see, it has its limits.
%10s: Pads a string with spaces on the left to be 10 characters wide.%-10s: Pads a string with spaces on the right (left-justifies it) to be 10 characters wide.%.2f: Formats a floating-point number with 2 decimal places of precision.%04d: Pads an integer with leading zeros to be 4 digits wide.
This next video provides a hands-on demonstration of these features.
Golang Tutorial #4 - Printing to Console & fmt
This clip from Tech With Tim's tutorial on the fmt package specifically covers how to use width and precision specifiers to control alignment and padding.
Watch from 09:04 to 12:36. This section clearly demonstrates: How to use a number (e.g., %9s) to specify width and pad with spaces. How to use a negative number (%-9s) for left-justification. How to control decimal precision (.2f). How to pad with leading zeros (%07d). Finally, the video briefly mentions \t (tab) and \n (newline) from 13:08 to 14:00, which are essential characters for structuring text output.
Let's try a quick example. Imagine printing a list of running services:
package main
import "fmt"
func main() {
fmt.Printf("%-15s %-10s %5s\n", "SERVICE", "STATUS", "PORT")
fmt.Printf("%-15s %-10s %5d\n", "api-gateway", "Running", 8080)
fmt.Printf("%-15s %-10s %5d\n", "user-database", "Running", 5432)
fmt.Printf("%-15s %-10s %5d\n", "auth-service", "Failed", 8081)
}
This produces a nicely aligned table:
SERVICE STATUS PORT
api-gateway Running 8080
user-database Running 5432
auth-service Failed 8081
However, this manual approach is brittle. If a service name were longer than 15 characters, the alignment would break. For robust, dynamic tables, we need a better tool.
2. Professional Tables with text/tabwriter
Go's standard library provides the text/tabwriter package to solve the problem of column alignment once and for all. It works by acting as a filter: you write tab-delimited text to it, and it automatically calculates the required padding to make all columns align perfectly.
The core workflow is simple:
- Create a new
tabwriter.Writer. - Write your content to it, using tab characters (
\t) to separate columns and newlines (\n) to separate rows. You typically usefmt.Fprintffor this. - Flush the writer to produce the final, formatted output on its destination
io.Writer.
The following article is a fantastic guide to tabwriter. We'll work through it step-by-step.
A Guide to Go's `text/tabwriter` Package: Aligning Text
First, let's understand the core concept and basic usage pattern of tabwriter. This article, "A Guide to Go's text/tabwriter Package," provides clear explanations and code examples.
Please read the first three sections: Understanding the text/tabwriter Package, Core Concepts and the Writer Structure, and Basic Usage Pattern. Focus on the roles of the character and the essential w.Flush() call.
Let's rewrite our previous service list example using tabwriter:
package main
import (
"fmt"
"os"
"text/tabwriter"
)
func main() {
// 1. Create a new tabwriter that writes to os.Stdout
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) // minwidth, tabwidth, padding, padchar, flags
// 2. Write tab-separated values
fmt.Fprintln(w, "SERVICE\tSTATUS\tPORT")
fmt.Fprintln(w, "api-gateway\tRunning\t8080")
fmt.Fprintln(w, "user-database-with-a-very-long-name\tRunning\t5432") // Alignment holds!
fmt.Fprintln(w, "auth-service\tFailed\t8081")
// 3. Flush the writer to print the formatted output
w.Flush()
}
Output:
SERVICE STATUS PORT
api-gateway Running 8080
user-database-with-a-very-long-name Running 5432
auth-service Failed 8081
Notice how tabwriter automatically handled the long service name, keeping all columns aligned.
Configuring tabwriter
You can customize the output by changing the parameters in tabwriter.NewWriter. The most important ones are minwidth, padding, and flags.
A Guide to Go's `text/tabwriter` Package: Aligning Text
Now let's explore how to configure the tabwriter's behavior. Please continue with the same article.
Read the sections Configuration Parameters Explained, Practical Configuration Examples, and Working with Formatting Flags. Pay special attention to the AlignRight flag for numbers and the Debug flag for troubleshooting. Then, review the Real-World Use Cases, which shows a process monitor example highly relevant to your DevOps work. Finally, and most importantly, read Common Pitfalls and Solutions. Forgetting to call Flush() is the most common mistake new users make!
A common and robust pattern is to use defer to ensure Flush() is always called.
func printTable() {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
defer w.Flush() // Guarantees the buffer is flushed before the function returns.
fmt.Fprintln(w, "ID\tNAME\tSTATUS")
fmt.Fprintln(w, "1\tnginx\tRunning")
fmt.Fprintln(w, "2\tpostgres\tRunning")
}
Test your understanding!
Modify the service list example to right-align the PORT column. Hint: You'll need to use a flag and potentially separate the header from the data to apply different alignment, or simply accept that the "PORT" header will also be right-aligned.
Show answer
A simple approach is to apply tabwriter.AlignRight to the entire writer. This will right-align all columns.
package main
import (
"fmt"
"os"
"text/tabwriter"
)
func main() {
// Use the AlignRight flag
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.AlignRight)
defer w.Flush()
fmt.Fprintln(w, "SERVICE\tSTATUS\tPORT")
fmt.Fprintln(w, "api-gateway\tRunning\t8080")
fmt.Fprintln(w, "user-database-with-a-very-long-name\tRunning\t5432")
fmt.Fprintln(w, "auth-service\tFailed\t8081")
}
Output:
SERVICE STATUS PORT
api-gateway Running 8080
user-database-with-a-very-long-name Running 5432
auth-service Failed 8081
Notice how the PORT column is now neatly aligned on the right, which is standard for numeric data.
3. Putting It All Together: A Reusable Table Printer
For larger applications, you'll want to encapsulate the tabwriter logic into a reusable helper function. This separates your data-gathering logic from your presentation logic, leading to cleaner code.
The following resource presents a complete, practical example of creating a generic PrintTable function. This pattern is something you can directly adapt for your own projects.
Display tables in the CLI using just the Go standard library
This article, "Display tables in the CLI using just the Go standard library," builds a reusable function around tabwriter. This is a great example of turning a standard library feature into a powerful, project-specific utility.
Study the code in the printer/table_printer.go and main.go files. Pay close attention to: The PrintTable function signature: how it accepts an io.Writer, headers, and a function to get rows. The userToRows function in main.go: this shows the important step of transforming your application's data structures (like a user struct) into the [][]string format that the printer needs. The final output, which demonstrates how multi-line entries for a single user are handled gracefully.
This approach is powerful because the PrintTable function doesn't need to know anything about users or permissions. It only deals with strings, making it a truly generic and reusable component for any tabular data you need to display.
Conclusion
You've now added a crucial skill to your Go toolkit: creating professional terminal output. This ability to present data clearly is what separates a quick script from a polished, user-friendly tool.
Key Takeaways:
- Use
fmt.Printfwith format verbs (%s,%d,%v) for fine-grained control over string formatting. - Use
fmt.Sprintfwhen you need to format data into a string variable for later use. text/tabwriteris the standard and robust way to create column-aligned tables.- The
tabwriterworkflow is:NewWriter, write tab-delimited content, andFlush. - Always remember to call
w.Flush(). Usingdefer w.Flush()right after creating the writer is a failsafe pattern. - For complex applications, encapsulate
tabwriterlogic in a reusable helper function that separates data transformation from presentation.
Next Up
You now know how to parse arguments, interact with the OS, handle data formats like JSON/YAML, and produce clean, formatted output. You have all the pieces to build a complete and useful CLI tool. In our next lesson, we'll cover the final step in the development lifecycle: creating installable, cross-compiled binaries for different operating systems. This will allow you to easily distribute your new tools to colleagues and servers, regardless of their platform.