Skip to main content

Go Strings: Creation, Manipulation, and Formatting

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Go strings are immutable byte sequences. Every “modification” — replacing a character, converting case, trimming whitespace — creates a new string. This immutability makes strings safe to pass between goroutines and simple to reason about, but it means concatenating strings in a loop is O(n²) in allocations unless you use strings.Builder.

This guide covers the strings package API, efficient string building, and fmt formatting. For Unicode and byte/rune specifics see Go bytes runes and Unicode.

String Literals

Go has two kinds of string literals:

// Interpreted literal: escape sequences are processed
s1 := "Hello\tWorld\n"  // contains a tab and newline

// Raw literal: no escape processing, can span multiple lines
s2 := `Hello\tWorld`    // contains a literal backslash-t
s3 := `line 1
line 2
line 3`

Raw literals (backtick-delimited) are ideal for regular expressions, SQL queries, JSON templates, and multiline text where you’d otherwise need many escape sequences. The string spans exactly what’s between the backticks, including newlines.

The strings Package

The strings package covers the vast majority of string operations. The functions are composable — return values are regular strings that you pass to the next function:

s := "  Hello, World!  "

// Whitespace handling
strings.TrimSpace(s)           // "Hello, World!"
strings.Trim(s, " ")           // "Hello, World!"
strings.TrimLeft(s, " ")       // "Hello, World!  "
strings.TrimRight(s, " !")     // "  Hello, World"

// Searching
strings.Contains(s, "World")   // true
strings.HasPrefix(s, "  Hello") // true
strings.HasSuffix(s, "!  ")    // true
strings.Index(s, "World")      // 9
strings.Count(s, "l")          // 3

// Transformation
strings.ToLower(s)             // "  hello, world!  "
strings.ToUpper(s)             // "  HELLO, WORLD!  "
strings.Replace(s, "World", "Go", 1)   // replaces first occurrence
strings.ReplaceAll(s, "l", "L")        // replaces all
strings.Title(s)               // deprecated in Go 1.18, use golang.org/x/text

Splitting and Joining

csv := "apple,banana,cherry,date"

parts := strings.Split(csv, ",")
// ["apple", "banana", "cherry", "date"]

// Split with limit: last element contains the remainder
first2 := strings.SplitN(csv, ",", 2)
// ["apple", "banana,cherry,date"]

// SplitAfter keeps the delimiter with each element
strings.SplitAfter(csv, ",")
// ["apple,", "banana,", "cherry,", "date"]

// Fields splits on any whitespace run — better than Split(s, " ")
words := strings.Fields("  hello   world  ")
// ["hello", "world"]

// Join is the inverse of Split
strings.Join(parts, " | ")  // "apple | banana | cherry | date"

strings.Fields is the right choice when splitting user input or tokens where whitespace is inconsistent.

Go 1.18+ Additions

// Cut splits on the FIRST occurrence of sep — cleaner than SplitN for "key=value" parsing
key, value, found := strings.Cut("Content-Type: application/json", ": ")
// key="Content-Type", value="application/json", found=true

_, _, found = strings.Cut("no-separator", ": ")
// found=false — no panic, just returns the input unchanged

// CutPrefix / CutSuffix (Go 1.20) — like TrimPrefix but returns whether it matched
after, found := strings.CutPrefix("/api/v1/users", "/api")
// after="/v1/users", found=true

strings.Cut replaced the common strings.SplitN(s, sep, 2) pattern. It’s cleaner and the three-return-value signature makes the “separator not found” case explicit.

Efficient String Building

String concatenation with + in a loop creates a new allocation every iteration — the old string is copied into a larger buffer, which is then discarded. For N strings, this is O(N²) allocations.

strings.Builder pre-allocates and grows its internal buffer by doubling, like a dynamic array:

// ❌ O(n²) — each += copies the growing string
var result string
for _, word := range words {
    result += word + " "
}

// ✅ O(n) — Builder amortizes allocations
var b strings.Builder
b.Grow(estimatedSize)  // optional: pre-allocate to avoid any reallocation
for _, word := range words {
    b.WriteString(word)
    b.WriteByte(' ')
}
result := b.String()

b.Grow(n) is worth calling when you can estimate the final size — it reserves exactly that much capacity upfront, eliminating all reallocations during WriteString calls.

For small numbers of concatenations (2–5), + is fine — the compiler may optimize simple cases. Use strings.Builder when you’re looping.

fmt.Fprintf(&b, format, args...) works with strings.Builder since it implements io.Writer. This is convenient for mixing static and formatted content:

var b strings.Builder
for i, user := range users {
    fmt.Fprintf(&b, "%d. %s (%s)\n", i+1, user.Name, user.Email)
}
report := b.String()

fmt Formatting Verbs

fmt.Sprintf builds a string from a format string and arguments. The format verbs control how each argument is rendered:

// String and byte types
fmt.Sprintf("%s", "hello")       // hello
fmt.Sprintf("%q", "hello")       // "hello"  (quoted, with escapes)
fmt.Sprintf("%x", "hi")          // 6869     (hex encoding of bytes)

// Integers
fmt.Sprintf("%d", 42)            // 42
fmt.Sprintf("%05d", 42)          // 00042   (zero-padded to width 5)
fmt.Sprintf("%-5d|", 42)         // "42   |"  (left-aligned in width 5)
fmt.Sprintf("%x", 255)           // ff      (lowercase hex)
fmt.Sprintf("%X", 255)           // FF      (uppercase hex)
fmt.Sprintf("%b", 5)             // 101     (binary)
fmt.Sprintf("%08b", 5)           // 00000101 (zero-padded binary)

// Floats
fmt.Sprintf("%f", 3.14159)       // 3.141590
fmt.Sprintf("%.2f", 3.14159)     // 3.14    (2 decimal places)
fmt.Sprintf("%10.2f", 3.14)      // "      3.14" (width 10, 2 decimal places)
fmt.Sprintf("%e", 12345.678)     // 1.234568e+04

// General
fmt.Sprintf("%v", someStruct)    // default format
fmt.Sprintf("%+v", someStruct)   // include field names for structs
fmt.Sprintf("%#v", someStruct)   // Go syntax representation
fmt.Sprintf("%T", someStruct)    // type name

%v works on anything — it delegates to the type’s String() method if it has one (satisfies fmt.Stringer), or uses a sensible default otherwise.

%+v and %#v are especially useful during debugging:

type User struct{ Name string; Age int }
u := User{Name: "Alice", Age: 30}

fmt.Sprintf("%v", u)   // {Alice 30}
fmt.Sprintf("%+v", u)  // {Name:Alice Age:30}
fmt.Sprintf("%#v", u)  // main.User{Name:"Alice", Age:30}

String Comparison

// Case-sensitive equality — just use ==
s1 == s2

// Case-insensitive equality
strings.EqualFold("Hello", "HELLO")  // true — handles Unicode correctly

// Lexicographic ordering
s1 < s2   // compares byte by byte
strings.Compare(s1, s2)  // -1, 0, or 1 — rarely needed vs < == >

strings.EqualFold handles Unicode case folding — it correctly handles characters like ß (German sharp-s) which case-folds to ss in uppercase.

Common Pitfalls

Indexing a string gives bytes, not characters. s[0] is uint8, not rune. For multi-byte characters, byte indexing gives you parts of a character. Use []rune(s)[0] or range for character access.

len(s) counts bytes, not characters. A string containing emoji or CJK characters will have len much larger than its visible length. Use utf8.RuneCountInString(s) for character count.

Substring slicing s[i:j] uses byte indices. Computing character-based indices requires converting to []rune first.

strings.Title is deprecated. It doesn’t handle Unicode correctly. Use golang.org/x/text/cases for proper title casing.

Summary

  • Raw literals (backtick) avoid escape sequences — use them for regex, SQL, multiline text
  • The strings package covers searching, transforming, splitting, and joining — check it before writing custom logic
  • strings.Cut (Go 1.18+) is the right tool for splitting on the first occurrence of a separator
  • Use strings.Builder with Grow for string construction in loops — avoids O(n²) allocation behavior
  • fmt.Sprintf verbs: %s for strings, %d for ints, %.2f for floats, %v/%+v/%#v for debugging, %T for type name
  • strings.EqualFold for case-insensitive comparison — handles Unicode correctly unlike strings.ToLower(a) == strings.ToLower(b)

Resources

Comments

👍 Was this article helpful?