Go String Formatting: fmt Package Guide

• The fmt package provides three function families—Print (stdout), Sprint (strings), and Fprint (io.Writer)—each with base, ln, and f variants that control newlines and formatting verbs.

Key Insights

• The fmt package provides three function families—Print (stdout), Sprint (strings), and Fprint (io.Writer)—each with base, ln, and f variants that control newlines and formatting verbs.

• Format verbs like %v, %+v, and %#v offer progressive levels of detail for debugging, while %T reveals type information—critical tools for inspecting complex data structures during development.

• Implementing the fmt.Stringer interface on custom types gives you complete control over string representation, making your types more debuggable and integration-friendly across logging, error handling, and serialization.

Introduction to the fmt Package

Go’s fmt package is your primary tool for formatted input and output operations. While string concatenation with + works for simple cases, fmt provides type-safe formatting, automatic type conversion, and sophisticated output control that becomes essential as programs grow in complexity.

The package handles three core scenarios: printing to standard output, formatting strings for later use, and writing to arbitrary io.Writer destinations. This flexibility makes it the de facto standard for everything from debug prints to structured logging.

package main

import "fmt"

func main() {
    name := "Alice"
    age := 30
    
    // String concatenation - type conversion required, clunky
    message := "User: " + name + ", Age: " + string(age) // Won't compile!
    
    // fmt handles types automatically
    formatted := fmt.Sprintf("User: %s, Age: %d", name, age)
    fmt.Println(formatted) // User: Alice, Age: 30
}

The type safety alone justifies using fmt. String concatenation requires manual conversion and quickly becomes unreadable with multiple variables.

The fmt package offers three primary printing functions, each with distinct behavior:

  • fmt.Print: Prints arguments with no separators or newlines
  • fmt.Println: Adds spaces between arguments and appends a newline
  • fmt.Printf: Formats according to a format string (no automatic newline)
package main

import "fmt"

func main() {
    name := "Bob"
    score := 95
    
    // Print - no spaces, no newline
    fmt.Print(name, score)
    fmt.Print("Next") // BobScoreNext (all on one line)
    
    fmt.Println() // Add newline to separate examples
    
    // Println - spaces between args, automatic newline
    fmt.Println(name, score) // Bob 95
    fmt.Println("Next")      // Next (on new line)
    
    // Printf - full control with format string
    fmt.Printf("%s scored %d%%\n", name, score) // Bob scored 95%
}

For production code, Printf is usually the right choice. It provides explicit control over output format and makes internationalization easier since format strings can be externalized.

Common Format Verbs

Format verbs are the heart of fmt’s power. They begin with % and specify how to represent a value:

package main

import "fmt"

func main() {
    // Basic types
    fmt.Printf("String: %s\n", "hello")           // String: hello
    fmt.Printf("Integer: %d\n", 42)               // Integer: 42
    fmt.Printf("Float: %f\n", 3.14159)            // Float: 3.141590
    fmt.Printf("Boolean: %t\n", true)             // Boolean: true
    
    // Width and precision
    fmt.Printf("Padded: %10d\n", 42)              // Padded:         42
    fmt.Printf("Precision: %.2f\n", 3.14159)      // Precision: 3.14
    
    // Default formatting with %v
    type User struct {
        Name string
        Age  int
    }
    user := User{"Charlie", 28}
    
    fmt.Printf("Default: %v\n", user)             // Default: {Charlie 28}
    fmt.Printf("Field names: %+v\n", user)        // Field names: {Name:Charlie Age:28}
    fmt.Printf("Go syntax: %#v\n", user)          // Go syntax: main.User{Name:"Charlie", Age:28}
    fmt.Printf("Type: %T\n", user)                // Type: main.User
}

The %v family deserves special attention. Use %v for default formatting, %+v when debugging structs (shows field names), and %#v for Go-syntax representation that you can paste back into code. The %T verb is invaluable for debugging type-related issues.

For numeric formatting, width and precision specifiers control output appearance:

package main

import "fmt"

func main() {
    // Financial data formatting
    price := 19.99
    quantity := 5
    total := price * float64(quantity)
    
    fmt.Printf("Item: $%6.2f x %2d = $%7.2f\n", price, quantity, total)
    // Item: $ 19.99 x  5 = $  99.95
    
    // Table-like output
    fmt.Printf("%-15s %10s %10s\n", "Product", "Price", "Stock")
    fmt.Printf("%-15s $%9.2f %10d\n", "Widget", 29.99, 150)
    fmt.Printf("%-15s $%9.2f %10d\n", "Gadget", 49.99, 87)
    // Product              Price      Stock
    // Widget           $    29.99        150
    // Gadget           $    49.99         87
}

String Formatting with Sprint Functions

The Sprint family creates formatted strings without printing them. This is essential for building error messages, log entries, or any string you need to store or pass around:

package main

import (
    "fmt"
    "errors"
)

func main() {
    // Sprintf - formatted string
    userID := 12345
    message := fmt.Sprintf("Processing user %d", userID)
    
    // Sprint - concatenates with no format string
    status := fmt.Sprint("Status: ", "active") // "Status: active"
    
    // Sprintln - adds spaces and newline (rarely used)
    log := fmt.Sprintln("Event:", "login", "User:", userID)
    
    fmt.Println(message)
    fmt.Println(status)
    fmt.Print(log) // Already has newline
}

// Practical use case: custom error messages
func ValidateAge(age int) error {
    if age < 0 {
        return errors.New(fmt.Sprintf("invalid age: %d (must be non-negative)", age))
    }
    if age > 150 {
        return errors.New(fmt.Sprintf("invalid age: %d (must be <= 150)", age))
    }
    return nil
}

Sprintf is particularly useful for constructing dynamic queries or messages where you need the string value rather than immediate output:

func buildQuery(table string, id int) string {
    // Note: This is for illustration only
    // Use parameterized queries in production!
    return fmt.Sprintf("SELECT * FROM %s WHERE id = %d", table, id)
}

Advanced Formatting Techniques

Go’s format verbs support sophisticated formatting including padding, alignment, and explicit argument indexing:

package main

import "fmt"

func main() {
    // Right padding (default)
    fmt.Printf("|%10s|\n", "right")    // |     right|
    
    // Left padding with minus
    fmt.Printf("|%-10s|\n", "left")    // |left      |
    
    // Zero padding for numbers
    fmt.Printf("%05d\n", 42)           // 00042
    
    // Explicit argument indexing
    fmt.Printf("%[2]s %[1]s\n", "world", "hello") // hello world
    fmt.Printf("%[1]d in decimal is %[1]x in hex\n", 255) 
    // 255 in decimal is ff in hex
    
    // Width from argument
    width := 10
    fmt.Printf("%*s\n", width, "dynamic") // Uses width variable
    
    // Precision from argument
    precision := 3
    fmt.Printf("%.*f\n", precision, 3.14159) // 3.142
}

Explicit argument indexing with %[n] is powerful for formatting the same value multiple ways or for internationalization where argument order might change:

func formatCurrency(amount float64, currency string) string {
    // Same amount shown in different formats
    return fmt.Sprintf(
        "Amount: %[1].2f %[2]s (approx. %[1].0f %[2]s)",
        amount, currency,
    )
}
// formatCurrency(19.99, "USD") -> "Amount: 19.99 USD (approx. 20 USD)"

Custom Type Formatting with Stringer Interface

Implementing fmt.Stringer gives you control over how your types appear in formatted output:

package main

import "fmt"

type User struct {
    ID        int
    Username  string
    Email     string
    IsActive  bool
}

// String implements fmt.Stringer
func (u User) String() string {
    status := "inactive"
    if u.IsActive {
        status = "active"
    }
    return fmt.Sprintf("User(%s: %s, %s)", u.Username, u.Email, status)
}

func main() {
    user := User{
        ID:       1,
        Username: "alice",
        Email:    "alice@example.com",
        IsActive: true,
    }
    
    // Without String() method:
    // {1 alice alice@example.com true}
    
    // With String() method:
    fmt.Println(user) // User(alice: alice@example.com, active)
    
    // Works with all format verbs that use String()
    fmt.Printf("Current user: %v\n", user)
    fmt.Printf("Users: %s and %s\n", user, User{2, "bob", "bob@example.com", false})
}

The Stringer interface is particularly valuable for types that appear in logs or error messages. It ensures consistent, readable representation across your application.

Best Practices and Performance Tips

While fmt is convenient, it’s not always the fastest option. For performance-critical string building, consider alternatives:

package main

import (
    "fmt"
    "strings"
    "testing"
)

func BenchmarkSprintf(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprintf("%s-%s-%s", "part1", "part2", "part3")
    }
}

func BenchmarkBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var builder strings.Builder
        builder.WriteString("part1")
        builder.WriteString("-")
        builder.WriteString("part2")
        builder.WriteString("-")
        builder.WriteString("part3")
        _ = builder.String()
    }
}

In benchmarks, strings.Builder typically outperforms fmt.Sprintf for simple concatenation. However, fmt.Sprintf remains more readable and is fast enough for most use cases.

Key best practices:

  1. Use fmt.Errorf for error wrapping: return fmt.Errorf("failed to process user %d: %w", userID, err) provides context while preserving error chains.

  2. Avoid fmt in hot paths: If profiling shows fmt calls consuming significant CPU, refactor to use strings.Builder or pre-allocated buffers.

  3. Prefer %v over type-specific verbs for interfaces: Unless you need specific formatting, %v works for any type and simplifies format strings.

  4. Implement Stringer for domain types: Any type that appears in logs or user-facing messages should have a clear string representation.

  5. Use %+v and %#v liberally during development: These verbs save countless debugging hours by showing struct internals clearly.

The fmt package is one of Go’s most essential tools. Master its format verbs, understand when to use each function family, and implement Stringer for your important types. These skills will serve you in every Go project you build.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.