Right, let’s talk about strings. This package is the duct tape and WD-40 of your Go career. You’ll use it constantly, and if you think it’s just a bunch of simple helpers, you’re missing half its power and all its gotchas. It’s designed to work with immutable, UTF-8 encoded strings, and once you internalize that, its functions stop being magic and start being obvious tools.

Contains, HasPrefix, HasSuffix: The Quick Checks

These are your first line of defense. Need to know if a string has a thing, starts with a thing, or ends with a thing? Here you go. They do exactly what they say on the tin.

logLine := "ERROR: disk on fire, please reload universe"

// Does it contain a substring?
fmt.Println(strings.Contains(logLine, "on fire"))  // true
fmt.Println(strings.Contains(logLine, "ice cream")) // false

// Does it start/end with a prefix/suffix?
fmt.Println(strings.HasPrefix(logLine, "ERROR"))    // true
fmt.Println(strings.HasSuffix(logLine, "universe")) // true, note the comma is part of the string!

The big “aha!” moment here is that these are byte-wise checks, not linguistic ones. strings.Contains("café", "é") is true because it’s looking for the byte sequence 0xc3 0xa9. This is fast and correct 99.9% of the time. The 0.1% is when you’re dealing with fancy Unicode equivalence, but if you’re doing that, you should already be knee-deep in the golang.org/x/text packages, not here.

Split and Join: Inverses and Best Friends

Split and Join are a beautiful, symbiotic pair. Split takes a string and chops it into a slice of strings ([]string) based on a separator. Join does the exact opposite: it takes a []string and glues it all together with a separator.

csvLine := "a,b,c,d,e,f"
parts := strings.Split(csvLine, ",")
fmt.Println(parts) // [a b c d e f]

// Now let's put it back together, but with a different delimiter because we're rebels.
newLine := strings.Join(parts, "|")
fmt.Println(newLine) // a|b|c|d|e|f

Here’s the first designer quirk I’ll call out: Split has an annoying family. strings.Split("a,b,c", ",") gives you ["a", "b", "c"]. But what about strings.Split("", ",")? Logically, splitting nothing should give you… nothing, right? An empty slice? It gives you a slice containing one element: an empty string [""]. This is a common source of off-by-one errors. Always remember: splitting an empty string returns [""].

For more control, use SplitN (to limit the number of splits) or Fields (to split on whitespace). And if your separator is empty (""), Split will break the string into individual UTF-8 characters (runes), which is actually useful for some things.

Replace: Find and Swap

Replace is your basic search-and-replace. The function signature is strings.Replace(s, old, new string, n int). The n argument is critical: it’s the number of replacements to make. Use -1 to replace all instances.

s := "foo foo foo"
fmt.Println(strings.Replace(s, "foo", "bar", 2))  // "bar bar foo"
fmt.Println(strings.Replace(s, "foo", "bar", -1)) // "bar bar bar"

Now, the second questionable choice: ReplaceAll was added in Go 1.12. It’s literally just Replace(s, old, new, -1). It exists because people found -1 unintuitive. I’m of two minds on this. On one hand, yes, a named function is clearer. On the other, it’s yet another thing to remember. Pick your poison; both work.

The strings.Builder: This is the Way

This is the most important part of the entire package. If you are building a string in a loop by doing s += "new part", stop it immediately. You are committing a cardinal sin of Go performance.

Why? Strings are immutable. Every time you use +=, the Go runtime has to:

  1. Allocate a new chunk of memory big enough for the entire new string.
  2. Copy the entire old string over.
  3. Copy the new part onto the end. This is an O(n²) operation. It’s disastrously inefficient for large builds.

strings.Builder is the solution. It’s a buffer that efficiently grows as you write to it (Write, WriteString, WriteByte), and you only create the final, immutable string when you call String().

// The BAD way (Don't do this)
var result string
for i := 0; i < 100; i++ {
    result += "x" // This is a crime. You will be caught.
}

// The GOOD way
var builder strings.Builder
for i := 0; i < 100; i++ {
    builder.WriteString("x") // Just appends to an internal slice. Fast.
}
result := builder.String() // Allocate the final string once.

Best practice: If you know even a rough estimate of the final size, use builder.Grow(n) upfront. This pre-allocates the internal buffer to n bytes, preventing multiple costly slice-growing operations during the write. It’s a free performance win.

names := []string{"Alice", "Bob", "Charlie"}
var builder strings.Builder
builder.Grow(50) // We're making a guess that the final string will be ~50 bytes.
for _, name := range names {
    builder.WriteString(name)
    builder.WriteString(", ")
}
team := builder.String()
team = strings.TrimSuffix(team, ", ") // Clean up the trailing comma-space.
fmt.Println(team) // Alice, Bob, Charlie

The Builder is your best friend for any non-trivial string construction. Use it religiously. Your program’s memory allocator will thank you.