Complete Guide to the strings Package in Go

The Go strings package offers essential functions for manipulating and searching Unicode text efficiently.

Parsing strings without the pain

You receive a string from an API response. It looks like " user@example.com,admin,active ". You need the email address, the role, and a boolean flag for active status. Your first instinct is to write a loop, track indices, and manually slice the string. That approach works in theory. In practice, it introduces off-by-one errors, breaks on edge cases, and ignores UTF-8 encoding rules.

Go provides the strings package to handle this work. The package offers a complete set of functions for searching, splitting, joining, and transforming strings. These functions are optimized, handle Unicode correctly, and follow consistent conventions. Using them keeps your code readable and prevents subtle bugs.

Strings are immutable byte sequences

A Go string is an immutable sequence of bytes. The bytes are UTF-8 encoded. Immutability means you cannot change the content of a string after it is created. You can create a new string based on an existing one, but the original string never changes.

Think of a string like a photograph. You can crop the photo, print a new version, or combine it with another photo. You cannot erase part of the original photo or change the ink. This design makes strings safe to share across goroutines. Multiple goroutines can read the same string without locks because no goroutine can modify it.

The length of a string is the number of bytes, not the number of characters. len("café") returns 5, not 4, because the accented 'e' requires two bytes in UTF-8. This distinction matters when you index into a string. Indexing returns a byte, not a character. If you access s[3] on "café", you get the second byte of the 'é', which is not a valid character on its own.

package main

import (
	"fmt"
	"strings"
)

// AnalyzeString demonstrates basic inspection and splitting.
func AnalyzeString(raw string) {
	// Contains checks for a substring without allocating memory.
	hasAdmin := strings.Contains(raw, "admin")
	fmt.Println("Has admin:", hasAdmin)

	// Split creates a new slice of strings.
	// Each element points to the original string's backing array.
	parts := strings.Split(raw, ",")
	fmt.Println("Parts:", parts)

	// Join combines a slice back into a single string with a separator.
	joined := strings.Join(parts, " | ")
	fmt.Println("Joined:", joined)
}

func main() {
	AnalyzeString("user,admin,active")
}

Strings are cheap to pass by value. The string header is just a pointer and a length. Passing a string to a function copies the header, not the data. This makes strings efficient arguments. Do not pass a *string. The pointer adds indirection without saving memory.

How splitting and searching work under the hood

When you call strings.Split, Go allocates a new slice of strings. Each element in the slice points to a range within the original string's backing array. Go does not copy the characters. It only copies the string headers. This is efficient for CPU usage, but it has a memory implication.

The original string must stay alive as long as any substring exists. If you split a large string and keep only one small piece, the garbage collector cannot free the large string. The small piece holds a reference to the large backing array. This pattern causes memory leaks in long-running services.

Use strings.Cut when you only need the first occurrence of a delimiter. Cut returns the part before the delimiter, the part after, and a boolean indicating if the delimiter was found. It avoids creating a full slice and is more explicit about intent.

// ExtractEmail parses an email from a "name <email>" format.
func ExtractEmail(header string) string {
	// Cut splits on the first '<' and returns both parts.
	// This is more efficient than Split when you only need the first match.
	before, after, found := strings.Cut(header, "<")
	if !found {
		// Return the original if no delimiter exists.
		return header
	}
	// Trim the closing '>' and whitespace.
	email := strings.TrimSuffix(after, ">")
	return strings.TrimSpace(email)
}

Search functions like strings.Contains and strings.Index scan the bytes efficiently. Contains returns a boolean. Index returns the byte offset of the first occurrence, or -1 if not found. The -1 convention is consistent across the package. Functions that search for a substring return -1 on failure. Check the return value against -1 to determine success. This avoids allocating a boolean or a slice when you only need position information.

Strings are immutable. Splitting creates references, not copies. Watch for memory retention when keeping substrings of large strings.

Realistic usage: normalizing user input

User input is messy. Tags, search queries, and configuration values often contain extra whitespace, mixed casing, and empty entries. A robust parser trims, normalizes, and filters the data. The strings package provides functions for each step.

strings.TrimSpace removes leading and trailing whitespace. strings.ToLower converts to lowercase using Unicode rules. strings.Split breaks the string into parts. A loop filters empty results. Pre-allocating the result slice avoids reallocations during the append operations.

// NormalizeTags processes a raw tag string into a clean slice.
// It trims whitespace, lowercases, and removes empty entries.
func NormalizeTags(raw string) []string {
	// Split by comma to get individual tag candidates.
	parts := strings.Split(raw, ",")
	
	// Pre-allocate the result slice to avoid reallocations.
	// Capacity matches the input parts; actual length will be smaller or equal.
	result := make([]string, 0, len(parts))
	
	for _, p := range parts {
		// TrimSpace removes spaces around the tag.
		// ToLower handles Unicode characters correctly.
		tag := strings.ToLower(strings.TrimSpace(p))
		
		// Skip empty strings resulting from consecutive delimiters.
		if tag != "" {
			result = append(result, tag)
		}
	}
	
	return result
}

Convention: Functions in strings are pure. They have no side effects. They do not modify global state or the input arguments. This purity makes them safe to use in concurrent code. You can call strings.ToUpper on a shared string from multiple goroutines without a mutex. The standard library guarantees this behavior.

Convention: gofmt formats code consistently. The strings package follows Go formatting rules. Your code should too. Run gofmt on save. Do not argue about indentation or spacing. Let the tool decide.

Pitfalls and compiler errors

Indexing a string gives you bytes, not characters. If your string contains non-ASCII characters, indexing breaks character boundaries. The compiler allows indexing, but your logic will fail. Use []rune(s) to convert to a slice of Unicode code points if you need character-level access. Converting to runes allocates memory, so do it only when necessary.

// SafeIndex demonstrates rune-aware indexing.
func SafeIndex(s string) {
	// Direct indexing returns bytes.
	// On "café", s[3] is the second byte of 'é', not a character.
	fmt.Printf("Byte at 3: %d\n", s[3])

	// Convert to runes for character access.
	// This allocates a new slice of runes.
	runes := []rune(s)
	fmt.Printf("Rune at 3: %c\n", runes[3])
}

Attempting to modify a string index causes a compile error. The compiler rejects the program with cannot assign to s[0]. Strings are read-only. If you need to modify content, convert to a byte slice or use strings.Builder.

strings.Split has edge cases that catch developers off guard. Splitting an empty string returns a slice with one empty string, not an empty slice. strings.Split("", ",") returns [""]. Splitting a string with consecutive delimiters produces empty strings in the result. strings.Split("a,,b", ",") returns ["a", "", "b"]. Use strings.Fields if you want to split by whitespace and drop empty strings automatically.

Loop concatenation is slow. Writing s += "x" in a loop creates a new string on every iteration. The cost grows quadratically with the number of iterations. Use strings.Builder for constructing strings in loops. Builder maintains a mutable buffer and allocates efficiently.

// BuildReport constructs a multi-line report efficiently.
func BuildReport(items []string) string {
	// Builder avoids O(N^2) allocation of loop concatenation.
	var b strings.Builder
	
	// Grow reserves capacity upfront to minimize reallocations.
	b.Grow(len(items) * 20)
	
	for _, item := range items {
		// WriteString appends to the buffer without copying.
		b.WriteString(item)
		b.WriteString("\n")
	}
	
	// String returns the final immutable string.
	return b.String()
}

The worst string bug is the one that silently corrupts data due to UTF-8 misinterpretation. Always verify behavior with non-ASCII input.

When to use strings functions

Use strings.Contains when you only need to check for existence and don't need the position. Use strings.Index when you need the byte offset of a substring or -1 if absent. Use strings.Cut when you need to split on the first occurrence and get both parts. Use strings.Split when you need to split on every occurrence of a delimiter. Use strings.Fields when you want to split by whitespace and ignore empty runs. Use strings.Builder when constructing a string from many parts in a loop. Use fmt.Sprintf when you need formatted output with mixed types, not just string concatenation. Use regexp when the pattern involves repetition, alternation, or complex matching rules. Use bytes package when your data is already a byte slice and you want to avoid conversion overhead.

Strings are cheap to pass, expensive to mutate. Trust the standard library. Profile before you rewrite.

Where to go next