diff --git a/cmd/pkg.go b/cmd/pkg.go
index 809efdf71a41673b4a9e93853c0697eaa0bd7747..e0ba3bc644c6f9ab650b6c42bf7ea7177fa9a993 100644
--- a/cmd/pkg.go
+++ b/cmd/pkg.go
@@ -23,7 +23,7 @@ import (
 
 	"github.com/ksonnet/ksonnet/metadata"
 	"github.com/ksonnet/ksonnet/metadata/parts"
-	str "github.com/ksonnet/ksonnet/strings"
+	"github.com/ksonnet/ksonnet/pkg/util/table"
 	"github.com/spf13/cobra"
 )
 
@@ -248,13 +248,9 @@ var pkgListCmd = &cobra.Command{
 			return err
 		}
 
-		headerRows := [][]string{
-			[]string{registryHeader, nameHeader, installedHeader},
-			[]string{
-				strings.Repeat("=", len(registryHeader)),
-				strings.Repeat("=", len(nameHeader)),
-				strings.Repeat("=", len(installedHeader))},
-		}
+		t := table.New(os.Stdout)
+		t.SetHeader([]string{registryHeader, nameHeader, installedHeader})
+
 		rows := make([][]string, 0)
 		for name := range app.Registries() {
 			reg, _, err := manager.GetRegistry(name)
@@ -263,12 +259,15 @@ var pkgListCmd = &cobra.Command{
 			}
 
 			for libName := range reg.Libraries {
+				var row []string
 				_, isInstalled := app.Libraries()[libName]
 				if isInstalled {
-					rows = append(rows, []string{name, libName, installed})
+					row = []string{name, libName, installed}
 				} else {
-					rows = append(rows, []string{name, libName})
+					row = []string{name, libName}
 				}
+
+				rows = append(rows, row)
 			}
 		}
 
@@ -279,13 +278,9 @@ var pkgListCmd = &cobra.Command{
 			return nameI < nameJ
 		})
 
-		rows = append(headerRows, rows...)
+		t.AppendBulk(rows)
+		t.Render()
 
-		formatted, err := str.PadRows(rows)
-		if err != nil {
-			return err
-		}
-		fmt.Print(formatted)
 		return nil
 	},
 	Long: `
diff --git a/cmd/registry.go b/cmd/registry.go
index 32ec425f38430e806c079d2a43e30fd60bac507e..1ee4dcf2bcf68bc3c72dea15dea90f35ba6e2c9b 100644
--- a/cmd/registry.go
+++ b/cmd/registry.go
@@ -23,7 +23,7 @@ import (
 
 	"github.com/ksonnet/ksonnet/metadata"
 	"github.com/ksonnet/ksonnet/pkg/kubecfg"
-	str "github.com/ksonnet/ksonnet/strings"
+	"github.com/ksonnet/ksonnet/pkg/util/table"
 	"github.com/spf13/cobra"
 )
 
@@ -103,23 +103,15 @@ var registryListCmd = &cobra.Command{
 			return err
 		}
 
-		rows := [][]string{
-			[]string{nameHeader, protocolHeader, uriHeader},
-			[]string{
-				strings.Repeat("=", len(nameHeader)),
-				strings.Repeat("=", len(protocolHeader)),
-				strings.Repeat("=", len(uriHeader)),
-			},
-		}
+		t := table.New(os.Stdout)
+		t.SetHeader([]string{nameHeader, protocolHeader, uriHeader})
+
 		for name, regRef := range app.Registries() {
-			rows = append(rows, []string{name, regRef.Protocol, regRef.URI})
+			t.Append([]string{name, regRef.Protocol, regRef.URI})
 		}
 
-		formatted, err := str.PadRows(rows)
-		if err != nil {
-			return err
-		}
-		fmt.Print(formatted)
+		t.Render()
+
 		return nil
 	},
 	Long: `
diff --git a/pkg/kubecfg/component.go b/pkg/kubecfg/component.go
index 88a275507699ec30961fa224c55f0ecb9cfa6125..80619174540463479d4fd1796dcf64dd16dd7eac 100644
--- a/pkg/kubecfg/component.go
+++ b/pkg/kubecfg/component.go
@@ -16,12 +16,10 @@
 package kubecfg
 
 import (
-	"fmt"
 	"io"
 	"sort"
-	"strings"
 
-	str "github.com/ksonnet/ksonnet/strings"
+	"github.com/ksonnet/ksonnet/pkg/util/table"
 )
 
 const (
@@ -49,8 +47,7 @@ func (c *ComponentListCmd) Run(out io.Writer) error {
 		return err
 	}
 
-	_, err = printComponents(out, components)
-	return err
+	return printComponents(out, components)
 }
 
 // ComponentRmCmd stores the information necessary to remove a component from
@@ -74,21 +71,14 @@ func (c *ComponentRmCmd) Run() error {
 	return manager.DeleteComponent(c.component)
 }
 
-func printComponents(out io.Writer, components []string) (string, error) {
-	rows := [][]string{
-		[]string{componentNameHeader},
-		[]string{strings.Repeat("=", len(componentNameHeader))},
-	}
+func printComponents(out io.Writer, components []string) error {
+	t := table.New(out)
+	t.SetHeader([]string{componentNameHeader})
 
 	sort.Strings(components)
 	for _, component := range components {
-		rows = append(rows, []string{component})
+		t.Append([]string{component})
 	}
 
-	formatted, err := str.PadRows(rows)
-	if err != nil {
-		return "", err
-	}
-	_, err = fmt.Fprint(out, formatted)
-	return formatted, err
+	return t.Render()
 }
diff --git a/pkg/kubecfg/component_test.go b/pkg/kubecfg/component_test.go
index e2cc526a422d11b6873617c5a78cfc763f366d86..0303ca4b6e2b5e5ba4b1378f37b873dbf00694ee 100644
--- a/pkg/kubecfg/component_test.go
+++ b/pkg/kubecfg/component_test.go
@@ -16,18 +16,20 @@
 package kubecfg
 
 import (
-	"os"
+	"bytes"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 )
 
 func TestPrintComponents(t *testing.T) {
-	for _, tc := range []struct {
+	cases := []struct {
+		name       string
 		components []string
 		expected   string
 	}{
 		{
+			name:       "print",
 			components: []string{"a", "b"},
 			expected: `COMPONENT
 =========
@@ -35,8 +37,8 @@ a
 b
 `,
 		},
-		// Check that components are displayed in alphabetical order
 		{
+			name:       "Check that components are displayed in alphabetical order",
 			components: []string{"b", "a"},
 			expected: `COMPONENT
 =========
@@ -44,18 +46,21 @@ a
 b
 `,
 		},
-		// Check empty components scenario
 		{
+			name:       "Check empty components scenario",
 			components: []string{},
 			expected: `COMPONENT
 =========
 `,
 		},
-	} {
-		out, err := printComponents(os.Stdout, tc.components)
-		if err != nil {
-			t.Fatalf(err.Error())
-		}
-		require.EqualValues(t, tc.expected, out)
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			var buf bytes.Buffer
+			err := printComponents(&buf, tc.components)
+			require.NoError(t, err)
+			require.EqualValues(t, tc.expected, buf.String())
+		})
 	}
 }
diff --git a/pkg/kubecfg/env.go b/pkg/kubecfg/env.go
index f9bc84a0926cf9428828f019756bd052367d2008..3dbe700677b45543ff02ca4abf15781ba6d2a53d 100644
--- a/pkg/kubecfg/env.go
+++ b/pkg/kubecfg/env.go
@@ -16,14 +16,12 @@
 package kubecfg
 
 import (
-	"fmt"
 	"io"
 	"sort"
-	"strings"
 
 	"github.com/ksonnet/ksonnet/env"
 	"github.com/ksonnet/ksonnet/metadata"
-	str "github.com/ksonnet/ksonnet/strings"
+	"github.com/ksonnet/ksonnet/pkg/util/table"
 )
 
 type EnvAddCmd struct {
@@ -69,6 +67,7 @@ func NewEnvListCmd(manager metadata.Manager) (*EnvListCmd, error) {
 	return &EnvListCmd{manager: manager}, nil
 }
 
+// Run builds a list of environments.
 func (c *EnvListCmd) Run(out io.Writer) error {
 	const (
 		nameHeader       = "NAME"
@@ -90,30 +89,19 @@ func (c *EnvListCmd) Run(out io.Writer) error {
 	// Sort environments by ascending alphabetical name
 	sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
 
-	rows := [][]string{
-		[]string{nameHeader, k8sVersionHeader, namespaceHeader, serverHeader},
-		[]string{
-			strings.Repeat("=", len(nameHeader)),
-			strings.Repeat("=", len(k8sVersionHeader)),
-			strings.Repeat("=", len(namespaceHeader)),
-			strings.Repeat("=", len(serverHeader))},
-	}
+	t := table.New(out)
+	t.SetHeader([]string{nameHeader, k8sVersionHeader, namespaceHeader, serverHeader})
 
 	for _, env := range envs {
-		rows = append(rows, []string{
+		t.Append([]string{
 			env.Name,
 			env.KubernetesVersion,
 			env.Destination.Namespace(),
 			env.Destination.Server()})
 	}
 
-	formattedEnvsList, err := str.PadRows(rows)
-	if err != nil {
-		return err
-	}
-
-	_, err = fmt.Fprint(out, formattedEnvsList)
-	return err
+	t.Render()
+	return nil
 }
 
 // ==================================================================
diff --git a/pkg/kubecfg/param.go b/pkg/kubecfg/param.go
index 2483ca03b336003bc6bde26b33ca4aaa8fc9c0bd..d723653e28c405630fef6436019bdeabb25ddc52 100644
--- a/pkg/kubecfg/param.go
+++ b/pkg/kubecfg/param.go
@@ -22,11 +22,11 @@ import (
 	"reflect"
 	"sort"
 	"strconv"
-	"strings"
 
 	"github.com/fatih/color"
 	"github.com/ksonnet/ksonnet/component"
 	param "github.com/ksonnet/ksonnet/metadata/params"
+	"github.com/ksonnet/ksonnet/pkg/util/table"
 	str "github.com/ksonnet/ksonnet/strings"
 	"github.com/pkg/errors"
 	"github.com/spf13/afero"
@@ -162,56 +162,42 @@ func (c *ParamListCmd) Run(out io.Writer) error {
 		}
 
 		p := params[c.component]
-		return outputParamsFor(c.component, p, out)
+		outputParamsFor(c.component, p, out)
+		return nil
 	}
 
-	return outputParams(params, out)
+	outputParams(params, out)
+	return nil
 }
 
-func outputParamsFor(component string, params param.Params, out io.Writer) error {
+func outputParamsFor(component string, params param.Params, out io.Writer) {
 	keys := sortedParams(params)
 
-	rows := [][]string{
-		[]string{paramNameHeader, paramValueHeader},
-		[]string{strings.Repeat("=", len(paramNameHeader)), strings.Repeat("=", len(paramValueHeader))},
-	}
+	t := table.New(out)
+	t.SetHeader([]string{paramNameHeader, paramValueHeader})
 	for _, k := range keys {
-		rows = append(rows, []string{k, params[k]})
+		t.Append([]string{k, params[k]})
 	}
 
-	formatted, err := str.PadRows(rows)
-	if err != nil {
-		return err
-	}
-	_, err = fmt.Fprint(out, formatted)
-	return err
+	t.Render()
 }
 
-func outputParams(params map[string]param.Params, out io.Writer) error {
+func outputParams(params map[string]param.Params, out io.Writer) {
 	keys := sortedKeys(params)
 
-	rows := [][]string{
-		[]string{paramComponentHeader, paramNameHeader, paramValueHeader},
-		[]string{
-			strings.Repeat("=", len(paramComponentHeader)),
-			strings.Repeat("=", len(paramNameHeader)),
-			strings.Repeat("=", len(paramValueHeader))},
-	}
+	t := table.New(out)
+	t.SetHeader([]string{paramComponentHeader, paramNameHeader, paramValueHeader})
+
 	for _, k := range keys {
 		// sort params to display alphabetically
 		ps := sortedParams(params[k])
 
 		for _, p := range ps {
-			rows = append(rows, []string{k, p, params[k][p]})
+			t.Append([]string{k, p, params[k][p]})
 		}
 	}
 
-	formatted, err := str.PadRows(rows)
-	if err != nil {
-		return err
-	}
-	_, err = fmt.Fprint(out, formatted)
-	return err
+	t.Render()
 }
 
 // ----------------------------------------------------------------------------
diff --git a/pkg/util/table/table.go b/pkg/util/table/table.go
new file mode 100644
index 0000000000000000000000000000000000000000..85351000cb387d84a7e000884ca622cf1ab7b359
--- /dev/null
+++ b/pkg/util/table/table.go
@@ -0,0 +1,123 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package table
+
+import (
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+const (
+	// sepChar is the character used to separate the header from the content in a table.
+	sepChar = "="
+)
+
+// Table creates an output table.
+type Table struct {
+	w io.Writer
+
+	header []string
+	rows   [][]string
+}
+
+// New creates an instance of table.
+func New(w io.Writer) *Table {
+	return &Table{
+		w: w,
+	}
+}
+
+// SetHeader sets the header for the table.
+func (t *Table) SetHeader(columns []string) {
+	t.header = columns
+}
+
+// Append appends a row to the table.
+func (t *Table) Append(row []string) {
+	t.rows = append(t.rows, row)
+}
+
+// AppendBulk appends multiple rows to the table.
+func (t *Table) AppendBulk(rows [][]string) {
+	t.rows = append(t.rows, rows...)
+}
+
+// Render writes the output to the table's writer.
+func (t *Table) Render() error {
+	var output [][]string
+
+	if len(t.header) > 0 {
+		headerRow := make([]string, len(t.header), len(t.header))
+		sepRow := make([]string, len(t.header), len(t.header))
+
+		for i := range t.header {
+			sepLen := len(t.header[i])
+			headerRow[i] = strings.ToUpper(t.header[i])
+			sepRow[i] = strings.Repeat(sepChar, sepLen)
+		}
+
+		output = append(output, headerRow, sepRow)
+	}
+
+	output = append(output, t.rows...)
+
+	counts := colLens(output)
+
+	// print rows
+	for _, row := range output {
+		var parts []string
+		for i, col := range row {
+			val := col
+			if i < len(row)-1 {
+				format := fmt.Sprintf("%%-%ds", counts[i])
+				val = fmt.Sprintf(format, col)
+			}
+			parts = append(parts, val)
+
+		}
+		_, err := fmt.Fprintf(t.w, "%s\n", strings.Join(parts, " "))
+		if err != nil {
+			return errors.Wrap(err, "render table")
+		}
+	}
+
+	return nil
+}
+
+func colLens(rows [][]string) []int {
+	// count the number of columns
+	colCount := 0
+	for _, row := range rows {
+		if l := len(row); l > colCount {
+			colCount = l
+		}
+	}
+
+	// get the max len for each column
+	counts := make([]int, colCount, colCount)
+	for _, row := range rows {
+		for i := range row {
+			if l := len(row[i]); l > counts[i] {
+				counts[i] = l
+			}
+		}
+	}
+
+	return counts
+}
diff --git a/pkg/util/table/table_test.go b/pkg/util/table/table_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..375b0b4ed528becc102674f512b761620ee54b5a
--- /dev/null
+++ b/pkg/util/table/table_test.go
@@ -0,0 +1,62 @@
+// Copyright 2018 The ksonnet authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package table
+
+import (
+	"bytes"
+	"io/ioutil"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestTable(t *testing.T) {
+	var buf bytes.Buffer
+	table := New(&buf)
+
+	table.SetHeader([]string{"name", "version", "Namespace", "SERVER"})
+	table.Append([]string{"default", "v1.7.0", "default", "http://default"})
+	table.AppendBulk([][]string{
+		{"dev", "v1.8.0", "dev", "http://dev"},
+		{"east/prod", "v1.8.0", "east/prod", "http://east-prod"},
+	})
+
+	table.Render()
+
+	b, err := ioutil.ReadFile("testdata/table.txt")
+	require.NoError(t, err)
+
+	assert.Equal(t, string(b), buf.String())
+}
+
+func TestTable_no_header(t *testing.T) {
+	var buf bytes.Buffer
+	table := New(&buf)
+
+	table.Append([]string{"default", "v1.7.0", "default", "http://default"})
+	table.AppendBulk([][]string{
+		{"dev", "v1.8.0", "dev", "http://dev"},
+		{"east/prod", "v1.8.0", "east/prod", "http://east-prod"},
+	})
+
+	table.Render()
+
+	b, err := ioutil.ReadFile("testdata/table_no_header.txt")
+	require.NoError(t, err)
+
+	assert.Equal(t, string(b), buf.String())
+}
diff --git a/pkg/util/table/testdata/table.txt b/pkg/util/table/testdata/table.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e064b929f6a320c1c82ac0ec817d19e183000ad5
--- /dev/null
+++ b/pkg/util/table/testdata/table.txt
@@ -0,0 +1,5 @@
+NAME      VERSION NAMESPACE SERVER
+====      ======= ========= ======
+default   v1.7.0  default   http://default
+dev       v1.8.0  dev       http://dev
+east/prod v1.8.0  east/prod http://east-prod
diff --git a/pkg/util/table/testdata/table_no_header.txt b/pkg/util/table/testdata/table_no_header.txt
new file mode 100644
index 0000000000000000000000000000000000000000..b4e6ac3128a52e92062d6eb37aaa4d3eb07bb87d
--- /dev/null
+++ b/pkg/util/table/testdata/table_no_header.txt
@@ -0,0 +1,3 @@
+default   v1.7.0 default   http://default
+dev       v1.8.0 dev       http://dev
+east/prod v1.8.0 east/prod http://east-prod
diff --git a/prototype/specification.go b/prototype/specification.go
index 7df294c8a5c8c4759b4122c5cf16824f1416a81c..234ea8e203c277ca06c5d5d75c20cd48ea26ee05 100644
--- a/prototype/specification.go
+++ b/prototype/specification.go
@@ -23,9 +23,8 @@ import (
 	"strings"
 
 	"github.com/blang/semver"
-	str "github.com/ksonnet/ksonnet/strings"
+	"github.com/ksonnet/ksonnet/pkg/util/table"
 	"github.com/pkg/errors"
-	log "github.com/sirupsen/logrus"
 )
 
 //
@@ -180,20 +179,17 @@ func (ss SpecificationSchemas) String() string {
 
 	sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name })
 
-	rows := [][]string{
-		[]string{nameHeader, descriptionHeader},
-		[]string{strings.Repeat("=", len(nameHeader)), strings.Repeat("=", len(descriptionHeader))},
-	}
+	var buf bytes.Buffer
+	t := table.New(&buf)
+	t.SetHeader([]string{nameHeader, descriptionHeader})
+
 	for _, proto := range ss {
-		rows = append(rows, []string{proto.Name, proto.Template.ShortDescription})
+		t.Append([]string{proto.Name, proto.Template.ShortDescription})
 	}
 
-	formatted, err := str.PadRows(rows)
-	if err != nil {
-		log.Errorf("Failed to print spec rows:\n%v", err)
-	}
+	t.Render()
 
-	return formatted
+	return buf.String()
 }
 
 // RequiredParams retrieves all parameters that are required by a prototype.