Regular Expressions

Updated

September 13, 2026

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.go

Output:

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.MustCompile at package level. regexp.Compile when the pattern itself might be invalid at runtime.
  • Anchor with ^ and $ when you want a full-string match.
  • Prefer strings.Contains / strings.HasPrefix for simple checks — they are faster and clearer.
  • FindStringSubmatch for 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

  1. In ticket_pattern.go, change \d+ to \d{1,5} (one to five digits). Test T-123456 — it should now return false.
  2. In extract_ids.go, change the limit from -1 to 2. Print the result — only the first two IDs appear.
  3. In parse_line.go, extend the pattern to also capture an optional note: table (\d+), ticket (\d+)(?:, (.+))?. Print m[3] for both a line with a note and one without.
  4. In redact.go, change the replacement to "[$1]". Confirm that the original word appears inside brackets.