strings.Contains(s, substr) returns a bool and is case-sensitive. That last part is where nearly every bug in this area comes from, because there is no ContainsIgnoreCase in the standard library.
Go has no method on the string type for this. Coming from a language where you write s.contains("x"), the first surprise is that you reach for a package function instead. That is a two-minute adjustment.
The longer-lived surprise is what the strings package gives you: four related functions that answer subtly different questions, and no case-insensitive variant at all.
Table of contents
- The basic function
- The other three, and what they are actually for
- Case-insensitive matching, done properly
- Prefix, suffix, and position
- Choosing between Contains, Index, and regexp
- Where string checks tend to live
- How this fits the rest of the stack
- FAQ
The basic function
Import strings and call it:
package main
import (
"fmt"
"strings"
)
func main() {
s := "deploy the service"
fmt.Println(strings.Contains(s, "service")) // true
fmt.Println(strings.Contains(s, "Service")) // false -- case matters
fmt.Println(strings.Contains(s, "")) // true -- always
}
The empty-string case is worth internalising: every string contains the empty string, including the empty string. If your substring comes from user input or a config value, an unset value silently matches everything. Guard it.
There is no allocation here and no regular expression engine involved. Contains is a thin wrapper over Index, so it is fast and there is no reason to reach for regexp when you only need a literal substring test.
The other three, and what they are actually for
These get skipped in most tutorials and each replaces code people write by hand:
// Does s contain ANY of these characters? Not the sequence -- any one of them.
strings.ContainsAny("deploy.sh", "./") // true
strings.ContainsAny("deploy", "./") // false
// Does s contain this single rune? Cheaper than Contains for one character.
strings.ContainsRune("café", 'é') // true
// Does s contain any rune satisfying this predicate? (Go 1.21+)
strings.ContainsFunc("abc123", unicode.IsDigit) // true
ContainsAny is the one most often reimplemented as a loop. Validating that an identifier has no path separators, or that a filename has no shell metacharacters, is one call rather than five.
ContainsFunc is the general case and pairs with the unicode package predicates — IsDigit, IsSpace, IsUpper, IsPunct. Checking whether a password contains a digit is one line, and it handles non-ASCII digits correctly, which a hand-rolled >= '0' && <= '9' does not.
Case-insensitive matching, done properly
There is no strings.ContainsIgnoreCase. The usual answer is to lowercase both sides:
func containsFold(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
This works for ASCII and allocates two new strings per call. In a hot loop over many strings, hoist the substring’s lowercase form out rather than recomputing it every iteration.
It is also not strictly correct for all Unicode. Case folding is not the same as lowercasing — the Turkish dotless i and the German sharp s are the standard counterexamples. For a single comparison of whole strings, strings.EqualFold does proper folding:
strings.EqualFold("Deploy", "DEPLOY") // true -- but whole-string only
There is no folding substring search in the standard library. If you genuinely need it across arbitrary Unicode, use a case-insensitive regular expression with (?i), and accept it is slower:
re := regexp.MustCompile(`(?i)service`) // compile once, outside the loop
re.MatchString("Deploy the SERVICE") // true
For the overwhelming majority of code — matching log lines, checking a header, filtering an ASCII identifier — the lowercase-both-sides version is correct and considerably faster. Reach for the regex only when the input is genuinely multilingual.
Prefix, suffix, and position
Contains answers whether the substring appears anywhere. Often the real question is narrower, and the narrower function is both faster and clearer about intent:
strings.HasPrefix(path, "/api/") // starts with
strings.HasSuffix(name, ".json") // ends with
strings.Index(s, "needle") // position, or -1
strings.LastIndex(s, "/") // last position, or -1
strings.Count(s, "err") // how many times
Use HasPrefix for route matching rather than Contains. strings.Contains(path, "/admin") matches /public/not-admin-really, which is the shape of a genuine authorisation bug and one that has shipped more than once.
When you need the position, call Index directly rather than Contains followed by Index — Contains is literally Index(s, substr) >= 0, so the pair does the scan twice.
Choosing between Contains, Index, and regexp
A short decision list:
- Need a yes/no for a literal substring:
Contains. - Need the position:
Index, and check for-1. - Anchored at start or end:
HasPrefixorHasSuffix. - Any of a set of characters:
ContainsAny. - A character class:
ContainsFuncwith aunicodepredicate. - Whole-string case-insensitive equality:
EqualFold. - A genuine pattern — alternation, wildcards, capture groups:
regexp, compiled once at package level.
The most common performance mistake in this area is calling regexp.MustCompile inside a function that runs per request. Compilation is expensive; matching is not. Compile at package scope and reuse the compiled value.
Where string checks tend to live
In practice this code sits in the boring, load-bearing parts of a service: routing, input validation, log filtering, deciding whether a webhook payload is one you care about. All of it runs on every request.
That makes the allocation-free options worth preferring by default — Contains, HasPrefix, ContainsAny and Index allocate nothing, while the lowercase-both-sides pattern allocates two strings per call. On a hot path, hoisting or restructuring to avoid that is a real saving.
Go services doing this work deploy on RunxBuild from a repository with a build log and a live route, with runtime logs and metrics in the same place, so you can see whether a request path is actually hot before optimising it.
How this fits the rest of the stack
strings.Contains is the answer most of the time, and the rest of the family — ContainsAny, ContainsRune, ContainsFunc, HasPrefix, EqualFold — covers the cases where it is the wrong tool. Remember that it is case-sensitive, that every string contains the empty string, and that Contains is a bad way to match a route prefix. When the service around this code needs a database or an API upstream, the RunxBuild hosting calculator shows those pieces as separate line items.
Useful related references:
- GitLab Export Project: What the Export File Contains and What It Silently Drops
- Dynamic URL: Why It Is Usually a Marketing-Team Phrase, and the Three Engineering Questions It Actually Contains
- Python re.sub(): Replace With a Function, Not Just a String
- Services on RunxBuild
FAQ
How do I check if a string contains a substring in Go?
Use strings.Contains(s, substr), which returns a bool. Go has no method on the string type for this, so it is a package function rather than s.contains(...). It allocates nothing and is a thin wrapper over strings.Index.
Is strings.Contains case-sensitive?
Yes, and there is no case-insensitive variant in the standard library. Lowercase both sides with strings.ToLower for ASCII input, or use a (?i) regular expression when you need proper Unicode case folding in a substring search.
What is the difference between Contains and ContainsAny?
Contains looks for a sequence of characters in order. ContainsAny returns true if any single character from the given set appears anywhere. ContainsAny("deploy.sh", "./") is true because of the dot, not because the string contains ”./”.
Should I use Contains or a regular expression?
Use Contains for literal substrings — it is far faster and allocates nothing. Reach for regexp only when you need alternation, wildcards or capture groups, and compile the pattern once at package level rather than inside a per-request function.
Why does strings.Contains return true for an empty substring?
Every string contains the empty string, by definition, including the empty string itself. This matters when the substring comes from configuration or user input, since an unset value will match everything. Guard against an empty needle explicitly.