- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen this function in a code review. It validates an email,
or parses a log line, or strips a prefix. It calls
regexp.MustCompile on the way in, uses the result once, and
returns. It passes tests. It ships. And then it runs a few million
times an hour on a hot path, and every single call rebuilds the
same automaton from scratch.
The Go regexp package is fast and predictable. It also has two
sharp edges that cost most codebases real CPU: where you compile,
and whether you should be using a regex at all. There is also one
guarantee that makes Go's regexp safer than almost every other
language's.
Compile once, not once per call
regexp.Compile parses the pattern string and builds a matching
program. That work is not free, and it does not depend on the input
you match against. So doing it inside a function that runs on every
request is pure waste.
Here is the version that shows up everywhere:
func isSlug(s string) bool {
re := regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
return re.MatchString(s)
}
Every call parses the pattern, allocates the program, matches once,
and throws the compiled regex away. Move it to package scope and the
compilation happens exactly once, at program start:
var slugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
func isSlug(s string) bool {
return slugRe.MatchString(s)
}
MustCompile is built for this. It panics instead of returning an
error, which is what you want at package-init time: a bad pattern is
a programming bug, and you want it to blow up the moment the binary
starts, not on the first request that hits the code path. Reserve
plain Compile for patterns that come from config or user input,
where a parse failure is data, not a bug.
Why the reused *regexp.Regexp is safe across goroutines
The reason package-level compilation works so cleanly in Go is a
property the docs state plainly: a compiled *regexp.Regexp is safe
for concurrent use by multiple goroutines. You do not need a mutex,
a sync.Pool, or a copy per worker.
var ipRe = regexp.MustCompile(
`^(\d{1,3}\.){3}\d{1,3}$`,
)
func handle(w http.ResponseWriter, r *http.Request) {
// Called from many goroutines at once. Fine.
if !ipRe.MatchString(r.RemoteAddr) {
http.Error(w, "bad addr", http.StatusBadRequest)
return
}
// ...
}
The matcher keeps its per-match state on the stack or in small
pooled scratch buffers it manages internally, not on the shared
Regexp value. That is why one global regex serving thousands of
concurrent requests is the idiomatic pattern, not a smell.
The guarantee: RE2 does not backtrack
Go's regexp does not use a backtracking engine. It implements the
RE2 semantics designed by Russ Cox, and the practical consequence is
a hard promise: matching runs in linear time in the length of the
input, no matter what the pattern looks like.
That matters because backtracking engines have a failure mode called
catastrophic backtracking, better known as ReDoS. In languages whose
regex engine backtracks, a pattern like (a+)+$ against a long
string of a followed by a b can take exponential time. It has
taken down production systems. Cloudflare's 2019 global outage traced
to a single regex in their WAF that backtracked catastrophically
(post-mortem).
Try to write the same trap in Go and it just does not fire:
var evil = regexp.MustCompile(`(a+)+$`)
func main() {
s := strings.Repeat("a", 100_000) + "b"
start := time.Now()
evil.MatchString(s) // returns fast, linear time
fmt.Println(time.Since(start))
}
There is a real trade-off behind that promise. RE2 drops
backreferences and lookaround, because those features are what force
backtracking in the first place. If you have ever tried to port a
Perl or PCRE pattern to Go and found that \1 or (?=...) does not
compile, that is the reason. The Go answer is to restructure the
match, split the work into multiple passes, or handle the correlation
in code. You give up two features and get an engine that cannot be
turned into a denial-of-service vector by a crafted input.
MatchString on a package var vs regexp.MatchString
The regexp package exposes top-level helpers like
regexp.MatchString(pattern, s). They are convenient and they are a
trap on any hot path, because they compile the pattern on every
call:
// Compiles the pattern every single time.
ok, _ := regexp.MatchString(`^\d+$`, s)
That is the same waste as the first example, wearing a shorter name.
Use the top-level helpers for one-shot scripts and tests. In library
and service code, compile to a package var and call the method. The
API shape nudges you the right way once you know to look: a method on
a precompiled *regexp.Regexp is the reusable path.
When a hand-written scan beats the regex
A regex is the right tool for genuinely variable structure. It is
the wrong tool for a check the strings package already does in a
few instructions. Reaching for regexp when a plain string function
would do is where a lot of avoidable cost hides.
Look at these three patterns you see in real code:
var prefixRe = regexp.MustCompile(`^https://`)
var digitsRe = regexp.MustCompile(`^\d+$`)
var hasComma = regexp.MustCompile(`,`)
Every one of them has a faster, clearer replacement in strings:
strings.HasPrefix(s, "https://")
strings.ContainsRune(s, ',')
And the all-digits check is a short loop that allocates nothing and
reads like exactly what it is:
func allDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
No compiled program, no automaton, no scratch buffers. On a check
that runs on every line of a large file, the hand-written scan is
both faster and easier to read than the regex it replaces.
The line to draw: if you are matching a fixed substring, a prefix, a
suffix, or a single character class, the strings package or a small
loop wins. Save regexp for patterns with real alternation,
repetition, or capture groups, where writing the scanner by hand
would be more code and more bugs than the regex.
Measure it, do not guess
Benchmarks settle the argument. Go's testing package makes the
comparison a few lines, and the point here is the shape of the
experiment, not a number to quote:
var digitsRe = regexp.MustCompile(`^\d+$`)
func BenchmarkRegex(b *testing.B) {
s := "1234567890"
b.ReportAllocs()
for b.Loop() {
_ = digitsRe.MatchString(s)
}
}
func BenchmarkLoop(b *testing.B) {
s := "1234567890"
b.ReportAllocs()
for b.Loop() {
_ = allDigits(s)
}
}
b.Loop() is the Go 1.24 benchmark form that replaces the old
for i := 0; i < b.N; i++ loop and keeps the compiler from
optimizing the work away. Run it with go test -bench=. -benchmem
on your own inputs. The regex will not be free, and the loop will
not allocate. Whether the gap matters depends entirely on how often
the code runs, which is exactly the thing you should measure instead
of assume.
The short version
Three rules cover most of the cost:
- Compile at package scope with
MustCompile. One automaton for the life of the process, shared safely across goroutines. - Trust the RE2 guarantee. Linear time, no ReDoS, at the price of backreferences and lookaround you rarely need.
- Skip the regex when
stringsor a short loop does the job. Match variable structure withregexp, match fixed structure with the cheaper tool.
None of this is exotic. It is the difference between a regexp that
sits quietly in your binary and one that shows up at the top of a
CPU profile because it recompiles itself a million times a day.
Go's regexp rewards knowing what the engine does under the call: a
precompiled program, a linear-time matcher, a set of trade-offs made
on purpose. The Complete Guide to Go Programming digs into that
runtime layer — how the stdlib builds and reuses this kind of state,
and why the RE2 design ended up in the standard library. Hexagonal
Architecture in Go is the companion for keeping matching logic at
the right boundary, so a validation regex lives in one place instead
of scattered across every handler.

Top comments (0)