Regular Expressions
Regular Expressions
regexp compiles a pattern once and matches it against many strings. The boring default is: compile at init time with regexp.MustCompile, match once with FindString or FindStringSubmatch, and never use regexp when strings.Contains or strings.HasPrefix would do. Regexps are powerful and slow relative to plain string checks.
Mental model
Go regexps follow RE2 syntax (no backtracking, no lookahead). regexp.Compile(pattern) returns (*Regexp, error). regexp.MustCompile(pattern) panics on a bad pattern — only use it at package level or in init, where a bad pattern is a programmer error, not a runtime error.
MatchString tells you whether the pattern appears anywhere in the string. FindString returns the first match. FindAllString returns all matches. FindStringSubmatch returns the whole match plus capture groups. ReplaceAllString substitutes all matches with a replacement string.
A capturing group is (...). A non-capturing group is (?:...). A named group is (?P<name>...).
Worked examples
Case 1: Compile and MatchString
Save as ticket_pattern.go. Validate that a ticket reference looks like T- followed by one or more digits.
// ticket_pattern.go
package main
import (
"fmt"
"regexp"
)
var ticketRef = regexp.MustCompile(`^T-\d+$`)
func main() {
cases := []string{"T-7", "T-42", "ticket7", "T-", "T-7x"}
for _, c := range cases {
fmt.Printf("%-8s %v\n", c, ticketRef.MatchString(c))
}
}Run:
go run ticket_pattern.goOutput:
T-7 true
T-42 true
ticket7 false
T- false
T-7x false
^ anchors to the start; $ to the end. Without them, T-7x would match because T-7 is found inside it. \d+ is one or more decimal digit. The raw string literal `…` avoids double-escaping backslashes.
Case 2: FindString and FindAllString
Save as extract_ids.go. A kitchen log has ticket IDs embedded in free text. Pull them all out.
// extract_ids.go
package main
import (
"fmt"
"regexp"
)
var idPat = regexp.MustCompile(`T-\d+`)
func main() {
log := "fired T-7 at 12:00, T-8 delayed, retry T-7 at 12:05"
first := idPat.FindString(log)
fmt.Println("first:", first)
all := idPat.FindAllString(log, -1) // -1 = no limit
fmt.Println("all:", all)
}Run:
go run extract_ids.goOutput:
first: T-7
all: [T-7 T-8 T-7]
-1 as the count means return all. Pass n to stop after n matches. FindAllString returns nil if there are no matches — test with len(all) == 0, not a nil check directly.
Case 3: FindStringSubmatch — capture groups
Save as parse_line.go. Each order line has a table number and a ticket number. Capture both.
// parse_line.go
package main
import (
"fmt"
"regexp"
)
var linePat = regexp.MustCompile(`table (\d+), ticket (\d+)`)
func main() {
line := "table 12, ticket 7"
m := linePat.FindStringSubmatch(line)
if m == nil {
fmt.Println("no match")
return
}
// m[0] = whole match, m[1] = first group, m[2] = second group
fmt.Println("whole :", m[0])
fmt.Println("table :", m[1])
fmt.Println("ticket :", m[2])
}Run:
go run parse_line.goOutput:
whole : table 12, ticket 7
table : 12
ticket : 7
Capture groups are 1-indexed in the result slice. m[0] is always the full match. If a group is optional and did not match, its slot is "".
Case 4: Named groups
Save as named_groups.go. Named groups make the indexing self-documenting.
// named_groups.go
package main
import (
"fmt"
"regexp"
)
var orderPat = regexp.MustCompile(`(?P<table>\d+)/(?P<ticket>\d+)`)
func main() {
ref := "3/7"
m := orderPat.FindStringSubmatch(ref)
if m == nil {
fmt.Println("no match")
return
}
names := orderPat.SubexpNames() // ["", "table", "ticket"]
result := make(map[string]string)
for i, name := range names {
if i != 0 && name != "" {
result[name] = m[i]
}
}
fmt.Println("table:", result["table"])
fmt.Println("ticket:", result["ticket"])
}Run:
go run named_groups.goOutput:
table: 3
ticket: 7
SubexpNames returns the group names in the same order as the submatch slice. The loop pattern above is the standard way to build a map[string]string from named groups. Go does not have a built-in named-group map helper.
Case 5: ReplaceAllString
Save as redact.go. A printed receipt should mask ticket notes that contain allergen warnings.
// redact.go
package main
import (
"fmt"
"regexp"
)
var allergenPat = regexp.MustCompile(`(?i)(allerg\w+|intoleran\w+)`)
func main() {
note := "no onions; ALLERGY: nuts; intolerance to gluten"
masked := allergenPat.ReplaceAllString(note, "[REDACTED]")
fmt.Println(note)
fmt.Println(masked)
}Run:
go run redact.goOutput:
no onions; ALLERGY: nuts; intolerance to gluten
no onions; [REDACTED]: nuts; [REDACTED] to gluten
(?i) at the start makes the whole pattern case-insensitive. \w+ is one or more word characters. The replacement string "[REDACTED]" is literal; use $1 to refer back to a captured group.
The trap
Save as compile_loop.go. Compiling the same pattern inside a loop is the common performance mistake.
// compile_loop.go
package main
import (
"fmt"
"regexp"
)
func badMatch(s string) bool {
// compiles on every call — do not do this
r := regexp.MustCompile(`T-\d+`)
return r.MatchString(s)
}
var goodPat = regexp.MustCompile(`T-\d+`) // compiled once
func goodMatch(s string) bool {
return goodPat.MatchString(s)
}
func main() {
tickets := []string{"T-7", "T-8", "T-9"}
for _, t := range tickets {
fmt.Println(t, goodMatch(t))
}
_ = badMatch // shown for contrast only
}Run:
go run compile_loop.goOutput:
T-7 true
T-8 true
T-9 true
MustCompile at package level means the pattern is compiled exactly once when the binary loads. Compile inside a function allocates and parses the automaton on every call. In a tight loop or a request handler, that is the whole cost of the regexp, repeated pointlessly.
The boring rule
regexp.MustCompileat package level.regexp.Compilewhen the pattern itself might be invalid at runtime.- Anchor with
^and$when you want a full-string match. - Prefer
strings.Contains/strings.HasPrefixfor simple checks — they are faster and clearer. FindStringSubmatchfor groups.m[0]is the whole match;m[1]onward are groups.- Named groups with
(?P<name>...)make indexed submatch slices readable. - Never compile inside a loop or a handler.
Try this
- In
ticket_pattern.go, change\d+to\d{1,5}(one to five digits). TestT-123456— it should now return false. - In
extract_ids.go, change the limit from-1to2. Print the result — only the first two IDs appear. - In
parse_line.go, extend the pattern to also capture an optional note:table (\d+), ticket (\d+)(?:, (.+))?. Printm[3]for both a line with a note and one without. - In
redact.go, change the replacement to"[$1]". Confirm that the original word appears inside brackets.