The log file trap
You are parsing a stream of server logs. Each line contains a timestamp, an IP address, and a status code. You need to filter out every line that mentions a specific error token. Your first instinct is to write a manual loop that steps through characters, or to reach for a regular expression engine. Both approaches work in theory. Both add unnecessary friction in practice. Go gives you a simpler path that runs faster and reads cleaner.
How Go strings actually work
Go strings are immutable sequences of bytes. They are not arrays of characters. They are not mutable buffers. When you write "hello" in Go, the compiler stores it as a pointer to a byte array plus a length. The bytes follow UTF-8 encoding, which means English letters take one byte each, but accented characters, emojis, or CJK glyphs can take two to four bytes.
Think of a Go string like a sealed envelope. You can read the contents, you can hand the envelope to someone else, but you cannot open it and rearrange the letters inside. If you need to change the text, you create a new envelope with the modified bytes. This design makes strings cheap to pass around. Copying a string just copies a pointer and an integer. The runtime never allocates new memory for the payload.
The strings package embraces this model. Every function takes a string, returns a new string, and leaves the original untouched. There is no hidden state. There is no mutable buffer hiding behind a method receiver. The package is pure functional by design, which makes it safe to use across goroutines without locks.
The baseline check
Here is the simplest way to verify that one string appears inside another:
package main
import (
"fmt"
"strings"
)
func main() {
// The target text we are searching through
text := "The quick brown fox jumps over the lazy dog"
// The pattern we want to locate
sub := "fox"
// strings.Contains returns true if sub exists as a contiguous byte sequence in text
if strings.Contains(text, sub) {
fmt.Printf("'%s' is found in the text.\n", sub)
} else {
fmt.Printf("'%s' is not found.\n", sub)
}
}
strings.Contains does exactly what the name says. It scans the target string for the substring and returns a boolean. It handles empty strings correctly. An empty substring always matches. A non-empty substring in an empty string always fails. The function never panics on zero-length inputs.
What happens under the hood
When you call strings.Contains, the runtime does not build a state machine. It does not backtrack. It performs a straightforward byte scan with early exit. The implementation checks the length of the substring first. If the substring is longer than the target, it returns false immediately. If the substring is a single byte, it falls back to a highly optimized memchr-style scan that the compiler often replaces with a single CPU instruction.
For longer substrings, the standard library uses a variant of the Boyer-Moore-Horspool algorithm. It skips ahead when it sees a mismatch, avoiding character-by-character comparisons. The algorithm operates on raw bytes, which is safe because UTF-8 has a strict property: no valid UTF-8 sequence contains a byte that looks like the start of another sequence unless it actually is one. This means byte-level scanning never accidentally splits a multi-byte character in half.
The function runs in O(n) time relative to the target string length. It allocates zero memory. It returns as soon as it finds the first match. If you are processing millions of lines, this difference between a linear scan and a regex engine becomes measurable.
Goroutines are cheap. Channels are not magic. String scanning is even cheaper when you let the standard library do the heavy lifting.
Case folding and Unicode reality
Byte matching is exact. "Go" does not match "go". If your data comes from user input, configuration files, or external APIs, case sensitivity often causes false negatives. The standard library provides strings.ContainsFold for this exact scenario.
package main
import (
"fmt"
"strings"
)
func main() {
// User input often mixes uppercase and lowercase letters
text := "Go is awesome"
// The query might be typed in any case
sub := "go"
// ContainsFold applies Unicode case folding before comparing bytes
if strings.ContainsFold(text, sub) {
fmt.Println("Match found (case-insensitive)")
}
}
ContainsFold does not just call ToLower on both sides. Lowercasing in Go is locale-independent and fast, but it does not handle all Unicode case equivalences. The Turkish dotted and dotless i is a famous example where simple lowercasing breaks matching. ContainsFold uses the Unicode case folding algorithm, which normalizes characters into a canonical form before comparison. It is slightly slower than Contains because it must process each character through the folding table, but it remains linear and allocation-free.
Public names start with a capital letter. Private start lowercase. The strings package follows this rule strictly, which is why Contains and ContainsFold are exported while their internal helpers remain hidden. Trust the naming convention. It tells you exactly what the function exposes to your code.
When bytes meet runes
Sometimes you are not looking for a multi-character substring. You are checking for a single Unicode code point. A rune in Go is a 32-bit integer representing a Unicode code point. If you need to verify that a string contains a specific rune, strings.ContainsRune is the right tool.
package main
import (
"fmt"
"strings"
)
func main() {
// We are checking for a single Unicode code point
text := "hello"
// Note the single quotes: this is a rune literal, not a string
target := 'e'
// ContainsRune iterates over valid UTF-8 sequences and compares code points
if strings.ContainsRune(text, target) {
fmt.Println("Contains 'e'")
}
}
The distinction matters. strings.Contains("hello", "e") works, but it treats "e" as a one-byte string. strings.ContainsRune("hello", 'e') treats 'e' as a rune. The rune version is clearer when you are working with character classification, validation, or parsing. It also avoids accidental byte slicing mistakes.
If you pass the wrong type to these functions, the compiler stops you immediately. Passing a byte slice instead of a string triggers cannot use []byte value as string value in argument. Passing a string where a rune is expected triggers cannot use "e" (untyped string constant) as rune value in argument. The type system catches these mismatches before runtime.
Pitfalls and compiler guardrails
The most common mistake is reaching for regexp when a simple scan suffices. Regular expressions compile a finite state machine. They allocate memory for the compiled pattern. They backtrack on complex inputs. Using regexp.MustCompile for a static substring check adds startup cost and runtime overhead that you do not need. The compiler will not stop you from importing regexp, but the performance profile will.
Another trap is confusing byte indices with character positions. strings.Contains returns a boolean, so it avoids the index problem entirely. If you switch to strings.Index, remember that it returns a byte offset, not a rune count. Slicing a string at a non-UTF-8 boundary produces invalid text and can break downstream parsers.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. String functions do not start goroutines, so they are safe to call from any context without worrying about concurrency bugs.
The worst goroutine bug is the one that never logs. String scanning leaves no goroutines behind, which makes it one of the safest operations in the standard library.
Pick the right tool
Use strings.Contains when you need a fast, exact byte-level match and case sensitivity is acceptable. Use strings.ContainsFold when your input comes from uncontrolled sources and you need Unicode-aware case insensitivity. Use strings.ContainsRune when you are validating or filtering individual code points rather than multi-character sequences. Use regexp when you need pattern matching, anchors, or complex tokenization that linear scanning cannot express. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.