☁ Problem
Printing some data to the CLI, as a table, usually requires one of the following
- fiddling with spaces and tabs to evenly align data and dealing with various edge cases, long strings etc.
- using a third-party library, which can have the benefit of nice-looking results, but at the cost of bringing in an extra-dependency just for that
⚑ Goal
- Printing tables to the CLI without fiddling with spaces, tabs etc. AND without using a third-party library.
☂ Solution
Use the test/tabwriter package provided by the Go standard library and create a custom function around it that suits almost any scenario that doesn't require sophisticated output.
☵ Example
Boilerplate
Create a folder named print-cli-tables, cd into it and initialize it as a Go module by running go mod init print-cli-tables.
Then create the following tree of folders and files:
printer
- table_printer.go
main.go
Code
printer/table_printer.go
package printer
import (
"fmt"
"io"
"strconv"
"strings"
"text/tabwriter"
)
// PrintTable ...
func PrintTable(
w io.Writer,
caption string,
cols []string,
getNextRows func() (bool, [][]string),
) {
nbCols := len(cols)
if nbCols == 0 {
return
}
if len(caption) > 0 {
fmt.Fprintf(w, "%s:\n\n", caption)
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
colSep := "\t"
header := append([]string{"#"}, cols...)
fmt.Fprintf(tw, "%s%s\n", strings.Join(header, colSep), colSep)
var sb strings.Builder
i := 1
hasMore, rows := getNextRows()
for hasMore {
for iRow, row := range rows {
nbRowCols := len(row)
for j := 0; j < nbCols; j++ {
if j < nbRowCols {
sb.WriteString(row[j])
}
sb.WriteString(colSep)
}
iRowStr := ""
if iRow == 0 {
iRowStr = strconv.Itoa(i)
}
fmt.Fprintf(tw, "%s%s%s\n", iRowStr, colSep, sb.String())
sb.Reset()
}
i++
hasMore, rows = getNextRows()
}
_ = tw.Flush()
}
main.go
package main
import (
"fmt"
"print-cli-tables/printer"
"strings"
)
type permission struct {
database string
permission string
tablePermissions map[string]string
}
type user struct {
username string
email string
permissions []*permission
}
var users = []*user{
&user{
username: "user1",
email: "[email protected]",
permissions: []*permission{
&permission{
database: "db1",
tablePermissions: map[string]string{
"table1": "RW",
"table2": "R",
},
},
&permission{
database: "db2",
permission: "admin",
},
},
},
&user{
username: "user2",
email: "[email protected]",
permissions: []*permission{
&permission{
database: "db2",
permission: "RW",
},
&permission{
database: "db3",
tablePermissions: map[string]string{
"table3": "R",
},
},
},
},
&user{
username: "superadmin",
email: "[email protected]",
permissions: []*permission{
&permission{
database: "*",
permission: "admin",
},
},
},
&user{
username: "inactiveuser",
email: "[email protected]",
},
}
func main() {
cols := []string{"User", "Email", "Database", "Table", "Permissions"}
i := 0
strBuilder := &strings.Builder{}
printer.PrintTable(
strBuilder, // or just pass os.Stdout
fmt.Sprintf("%d user(s)", len(users)),
cols,
func() (bool, [][]string) {
if len(users)-1 < i {
return false, nil
}
u := users[i]
i++
return true, userToRows(u)
})
fmt.Print(strBuilder.String())
}
func userToRows(u *user) [][]string {
var rows [][]string
if len(u.permissions) == 0 {
return [][]string{[]string{u.username, u.email, "-", "-", "-"}}
}
rows = make([][]string, 0, len(u.permissions))
for _, ps := range u.permissions {
if len(ps.tablePermissions) == 0 {
rows = append(rows, []string{"", "", ps.database, "*", ps.permission})
continue
}
first := false
for t, p := range ps.tablePermissions {
row := []string{"", "", "", t, p}
if first == false {
row[2] = ps.database
first = true
}
rows = append(rows, row)
}
}
rows[0][0] = u.username
rows[0][1] = u.email
return rows
}
Reading and running the code
In the code above one can observe that the PrintTable function is designed so that it takes the following params:
- An
io.Writerwhich can be, for example,os.Stdoutif one wishes to print directly to the CLI orstrings.Builderif one wants to do something else with the output. - A caption for the table
- The table heading (i.e. column names)
- A callback function which returns a
boolindicating whether there are more rows or not, a two-dimensionalarrayofstrings which holds the next row(s). The reason for the two-dimensionality of it is that one might need to print multiple rows for a single data item, as can be seen in the example code and data frommain.gowhere theuserToRowsfunction returns more than one row for one user (i.e. multiple permissions for databases and tables).
💡 Basically the PrintTable function only deals with arrays of strings delegating to the caller the responsibility of converting any data it has and also the one of deciding when the table ends (e.g. by keeping a counter while feeding the data through the callback).
Running the code will show the following output:
➤ go run main.go
4 user(s):
# User Email Database Table Permissions
1 user1 [email protected] db1 table1 RW
table2 R
db2 * admin
2 user2 [email protected] db2 * RW
db3 table3 R
3 superadmin [email protected] * * admin
4 inactiveuser [email protected] - - -

