Strings in Go
Work with Go strings using the strings package, fmt.Sprintf, strings.Builder, understand runes vs bytes, and use regular expressions.
String Basics
Strings in Go are immutable sequences of bytes encoded in UTF-8. Immutability means string values can be safely shared between goroutines and used as map keys without defensive copying. UTF-8 encoding means Go programs handle international text correctly by default, though it also means byte length and character count can differ for non-ASCII strings. Raw string literals (backticks) are useful for file paths, regular expressions, and multiline text where backslash escaping would make the string hard to read.
s := "Hello, 世界"
raw := `C:\Users\alice\no\escaping\needed`
multiline := `first line
second line
third line`
fmt.Println(len(s)) // 13 — byte count, not character count
fmt.Println(len([]rune(s))) // 9 — character (rune) count
fmt.Println(s[0]) // 72 — byte value of 'H'
fmt.Println(string(s[0])) // "H"
The strings Package
The strings package covers the vast majority of string manipulation needs: searching, splitting, trimming, replacing, and case conversion. These are all pure functions that return new strings — they never modify the input, consistent with Go strings being immutable.
import "strings"
s := " Hello, Go World! "
// Case conversion
fmt.Println(strings.ToUpper(s)) // " HELLO, GO WORLD! "
fmt.Println(strings.ToLower(s)) // " hello, go world! "
// Trim — remove leading and trailing characters
fmt.Println(strings.TrimSpace(s)) // "Hello, Go World!"
fmt.Println(strings.Trim("!!hello!!", "!")) // "hello"
fmt.Println(strings.TrimLeft("!!hello", "!")) // "hello"
fmt.Println(strings.TrimRight("hello!!", "!")) // "hello"
fmt.Println(strings.TrimPrefix("Hello, Go", "Hello, ")) // "Go"
fmt.Println(strings.TrimSuffix("file.go", ".go")) // "file"
// Search — check for substrings and find positions
fmt.Println(strings.Contains("seafood", "foo")) // true
fmt.Println(strings.HasPrefix("Gopher", "Go")) // true
fmt.Println(strings.HasSuffix("main.go", ".go")) // true
fmt.Println(strings.Index("hello", "ll")) // 2
fmt.Println(strings.LastIndex("go go go", "go")) // 6
fmt.Println(strings.Count("cheese", "e")) // 3
// Split and Join — split a string into parts, or join parts back together
parts := strings.Split("a,b,c,d", ",")
fmt.Println(parts) // [a b c d]
fmt.Println(strings.Join(parts, " | ")) // "a | b | c | d"
fields := strings.Fields(" foo bar baz ")
fmt.Println(fields) // [foo bar baz] — splits on any whitespace, discards empty parts
// Replace — substitute substrings
fmt.Println(strings.Replace("oink oink oink", "oink", "moo", 2)) // "moo moo oink"
fmt.Println(strings.ReplaceAll("oink oink", "oink", "moo")) // "moo moo"
// Repeat — repeat a string n times
fmt.Println(strings.Repeat("ab", 3)) // "ababab"
fmt.Sprintf for String Formatting
fmt.Sprintf produces a formatted string using a format string with verbs — placeholders that specify how each argument should be rendered. It is the Go equivalent of printf in C, but type-safe. You will use it constantly for building strings that combine static text with dynamic values.
name := "Alice"
age := 30
score := 98.5
// Basic formatting — %s for strings, %d for integers, %f for floats
s := fmt.Sprintf("Name: %s, Age: %d, Score: %.1f", name, age, score)
fmt.Println(s) // Name: Alice, Age: 30, Score: 98.5
// Common format verbs
fmt.Sprintf("%v", anyValue) // default format — works for any type
fmt.Sprintf("%+v", myStruct) // struct with field names included
fmt.Sprintf("%#v", myStruct) // Go syntax representation (useful for debugging)
fmt.Sprintf("%T", myValue) // type name of the value
fmt.Sprintf("%d", 42) // integer
fmt.Sprintf("%05d", 42) // zero-padded to 5 digits: "00042"
fmt.Sprintf("%8.2f", 3.14159) // width 8, 2 decimal places: " 3.14"
fmt.Sprintf("%-8s|", "left") // left-aligned in 8-char field: "left |"
fmt.Sprintf("%x", 255) // hexadecimal: "ff"
fmt.Sprintf("%08b", 42) // binary, zero-padded: "00101010"
fmt.Sprintf("%q", "hello\n") // quoted string: "\"hello\\n\""
// Implement the fmt.Stringer interface to control how your type is printed
type Point struct{ X, Y int }
func (p Point) String() string {
return fmt.Sprintf("(%d, %d)", p.X, p.Y)
}
p := Point{3, 4}
fmt.Println(p) // (3, 4) — uses the String() method automatically
fmt.Sprintf("%v", p) // (3, 4)
strings.Builder — Efficient String Construction
String concatenation with + is simple but inefficient in loops: every + creates a new string allocation and copies all existing data into it. For a loop running 1000 times, you get 1000 allocations. strings.Builder solves this by maintaining a single growing buffer and only producing the final string once when you call String(). Use it whenever you are building a string incrementally.
var b strings.Builder
// fmt.Fprintf writes formatted output directly into the builder — no intermediate string
for i := 0; i < 5; i++ {
fmt.Fprintf(&b, "item %d\n", i)
}
result := b.String()
fmt.Print(result)
// item 0
// item 1
// item 2
// item 3
// item 4
// Reset reuses the builder's buffer — avoids an allocation for the next build
b.Reset()
b.WriteString("Hello")
b.WriteRune(',')
b.WriteString(" World")
fmt.Println(b.String()) // Hello, World
For joining known slices, strings.Join is cleaner. Use Builder when building a string iteratively or conditionally.
Runes vs Bytes
This distinction is critical when working with non-ASCII text. Indexing a string with s[i] gives you the byte at position i, not the character. For ASCII-only strings this is fine, but for Unicode text (Chinese, Arabic, emoji, accented characters) a single character can occupy 2–4 bytes, so byte indexing produces garbled results. Use []rune conversion or range iteration when you need to work with individual characters.
s := "Héllo" // 'é' is 2 bytes in UTF-8 (bytes 0xC3 0xA9)
// Indexing gives bytes — wrong for multi-byte characters
fmt.Println(s[1]) // 195 — first byte of 'é', not the character 'é'
fmt.Println(string(s[1])) // garbled output — 195 is not a valid single-byte character
// Convert to []rune for character-level operations
runes := []rune(s)
fmt.Println(string(runes[1])) // é — correct, index 1 is the second character
// Reverse a string correctly using rune conversion
func reverseString(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
fmt.Println(reverseString("Hello, 世界")) // 界世 ,olleH
// range iterates by rune automatically — always correct for Unicode
for i, ch := range "Hello, 世界" {
fmt.Printf("byte index %d: %c\n", i, ch)
}
strings.Reader and strings.NewReplacer
strings.NewReplacer is more efficient than calling strings.Replace multiple times — it scans the string once and applies all replacements in a single pass. It is particularly useful for HTML escaping, template variable substitution, and similar multi-replacement tasks. strings.NewReader turns a string into an io.Reader, which lets you pass string data to any function that accepts a reader — useful for testing code that reads from files or network connections.
// strings.NewReplacer — applies multiple replacements in a single pass
r := strings.NewReplacer(
"<", "<",
">", ">",
"&", "&",
)
safe := r.Replace("<script>alert('xss')</script>")
fmt.Println(safe) // <script>alert('xss')</script>
// strings.Reader implements io.Reader — lets you use a string wherever a reader is expected
reader := strings.NewReader("Hello from a reader")
buf := make([]byte, 5)
n, _ := reader.Read(buf)
fmt.Println(string(buf[:n])) // Hello
Regular Expressions
The regexp package provides full regular expression support. The key performance rule is to compile your regex once and reuse it — regexp.Compile is expensive; matching is fast. Use MustCompile when the pattern is a literal string (it panics on invalid patterns, which is appropriate since a bad literal pattern is a programming error you want to catch immediately).
import "regexp"
// Compile once at package level or in an init block — reuse for every match
re := regexp.MustCompile(`\b\d{3}-\d{4}\b`)
// Match — does the string contain this pattern?
fmt.Println(re.MatchString("Call 555-1234 now")) // true
fmt.Println(re.MatchString("no number here")) // false
// Find — extract the matching substring
fmt.Println(re.FindString("Call 555-1234 now")) // "555-1234"
fmt.Println(re.FindAllString("555-1234 and 999-8765", -1)) // [555-1234 999-8765]
// Capture groups — extract specific parts of a match
emailRe := regexp.MustCompile(`(\w+)@(\w+)\.(\w+)`)
match := emailRe.FindStringSubmatch("[email protected]")
// match[0] = "[email protected]", match[1] = "user", match[2] = "example", match[3] = "com"
// Replace — substitute matching substrings
result := re.ReplaceAllString("Call 555-1234", "XXX-XXXX")
fmt.Println(result) // Call XXX-XXXX
// Replace with a function — transform each match dynamically
result2 := re.ReplaceAllStringFunc("Call 555-1234", func(s string) string {
return "[REDACTED]"
})
fmt.Println(result2) // Call [REDACTED]
Practical Example — CSV Field Parser
This example combines strings.Split, strings.TrimSpace, and strings.Builder to parse and reformat CSV-like data. It shows how the strings package functions compose naturally to handle a real-world text processing task.
package main
import (
"fmt"
"strings"
)
// parseCSVLine splits a line on commas and trims whitespace from each field
func parseCSVLine(line string) []string {
fields := strings.Split(line, ",")
result := make([]string, len(fields))
for i, f := range fields {
result[i] = strings.TrimSpace(f)
}
return result
}
func main() {
lines := []string{
"Alice, 30, Engineer",
"Bob, 25, Designer",
"Charlie, 35, Manager",
}
// Use Builder to assemble the output without repeated + allocations
var b strings.Builder
b.WriteString("Name | Age | Role\n")
b.WriteString(strings.Repeat("-", 30) + "\n")
for _, line := range lines {
fields := parseCSVLine(line)
fmt.Fprintf(&b, "%-10s | %-3s | %s\n", fields[0], fields[1], fields[2])
}
fmt.Print(b.String())
}
Output:
Name | Age | Role
------------------------------
Alice | 30 | Engineer
Bob | 25 | Designer
Charlie | 35 | Manager