58. `regexp`

📖 Reading · 11 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Go's regular expressions use RE2, which guarantees linear-time matching — no catastrophic backtracking, ever. The price is that a few features you may know from Perl or JavaScript (backreferences, lookahead) simply don't exist. That's a trade Go makes on purpose.

Compile once, use many times

package main

import (
    "fmt"
    "regexp"
)

func main() {
    re, err := regexp.Compile(`\d+`)
    if err != nil {
        fmt.Println("bad pattern:", err)
        return
    }

    fmt.Println(re.MatchString("abc 123"))
    fmt.Println(re.FindString("order 42 shipped"))
    fmt.Println(re.FindAllString("1 apple, 22 pears, 333 figs", -1))
}

Compile returns (*Regexp, error) — a pattern is data, and data can be malformed.

Note the backtick string: regexes are full of backslashes, and a raw string literal means you write \d instead of "\\d".

MustCompile for fixed patterns

package main

import (
    "fmt"
    "regexp"
)

var emailRE = regexp.MustCompile(`^[\w.+-]+@[\w-]+\.[\w.]+$`)

func main() {
    for _, s := range []string{"ada@example.com", "not-an-email", "grace@sub.example.co.uk"} {
        fmt.Printf("%-25s %t\n", s, emailRE.MatchString(s))
    }
}

MustCompile panics instead of returning an error — the Must convention from module 7. Use it for hard-coded patterns in package-level variables, because a bad literal regex is a bug that should stop the program at startup.

Compile once, at package level. Compiling inside a function that runs per request is a classic performance mistake: it's expensive, and the result is identical every time.

Finding, replacing, splitting

package main

import (
    "fmt"
    "regexp"
)

func main() {
    text := "Contact: ada@example.com or grace@example.org for details."
    re := regexp.MustCompile(`[\w.]+@[\w.]+\.\w+`)

    fmt.Println(re.FindString(text))
    fmt.Println(re.FindAllString(text, -1))
    fmt.Println(re.FindAllString(text, 1))
    fmt.Println(re.FindStringIndex(text))

    fmt.Println(re.ReplaceAllString(text, "[redacted]"))

    spaces := regexp.MustCompile(`\s+`)
    fmt.Println(spaces.Split("split   on    any  whitespace", -1))

    fmt.Println(re.NumSubexp(), len(re.FindAllString(text, -1)))
}

The -1 argument means "all matches"; a positive number caps them. That pattern is consistent across every FindAll... function.

The naming is systematic once you see it: Find + All? + String? + Submatch? + Index?. FindAllStringSubmatchIndex is a real function, and you can now read what it does.

Capture groups

Parentheses capture, and Submatch gives you what they caught:

package main

import (
    "fmt"
    "regexp"
)

func main() {
    re := regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})`)

    m := re.FindStringSubmatch("released on 2024-03-15 officially")
    fmt.Println(len(m))
    fmt.Println("whole match:", m[0])
    fmt.Println("year:", m[1], "month:", m[2], "day:", m[3])

    all := re.FindAllStringSubmatch("2024-03-15 and 2025-01-01", -1)
    for _, match := range all {
        fmt.Printf("%s -> year %s\n", match[0], match[1])
    }
}

m[0] is always the entire match; m[1], m[2]... are the groups in order. A non-participating group gives you an empty string, so check len(m) before indexing.

Named groups

Positional indexes get unreadable fast. Name them:

package main

import (
    "fmt"
    "regexp"
)

var logRE = regexp.MustCompile(`^(?P<level>\w+)\s+(?P<time>[\d:]+)\s+(?P<msg>.+)$`)

func parse(line string) map[string]string {
    m := logRE.FindStringSubmatch(line)
    if m == nil {
        return nil
    }
    out := make(map[string]string)
    for i, name := range logRE.SubexpNames() {
        if i > 0 && name != "" {
            out[name] = m[i]
        }
    }
    return out
}

func main() {
    fields := parse("ERROR 14:30:05 database connection failed")
    fmt.Println(fields["level"])
    fmt.Println(fields["time"])
    fmt.Println(fields["msg"])

    fmt.Println(parse("not a log line") == nil)
}

(?P<name>...) is Go's syntax for a named group, and SubexpNames() returns the names by index (with an empty string at position 0 for the whole match). That loop turning a match into a map is boilerplate worth keeping around.

Replacement with references

package main

import (
    "fmt"
    "regexp"
    "strings"
)

func main() {
    re := regexp.MustCompile(`(\w+)@(\w+)\.com`)
    text := "mail ada@example.com and grace@other.com"

    fmt.Println(re.ReplaceAllString(text, "$1 AT $2 dot com"))
    fmt.Println(re.ReplaceAllString(text, "${1}_user"))

    upper := re.ReplaceAllStringFunc(text, func(m string) string {
        return strings.ToUpper(m)
    })
    fmt.Println(upper)
}

$1 refers to the first group. Use ${1} when a letter follows immediately, or $1_user would be read as a group named 1_user and expand to nothing — a small trap with a confusing failure mode.

ReplaceAllStringFunc gives you the matched text and lets you compute the replacement, which covers everything $n can't.

What RE2 doesn't have

// These are NOT supported by Go's regexp:
(\w+)\s+\1        // backreferences
(?=foo)           // lookahead
(?<=foo)          // lookbehind
(?!foo)           // negative lookahead

They're excluded because they're what make regex engines exponential. RE2 matches in time linear in the input length, always — so a hostile input can't hang your server, which is a real attack against naive regex use.

When you need those features, restructure: match a broader pattern and filter the results in Go code. It's usually clearer anyway.

When not to use a regex

package main

import (
    "fmt"
    "regexp"
    "strings"
)

func main() {
    s := "hello world"

    fmt.Println(regexp.MustCompile(`^hello`).MatchString(s))
    fmt.Println(strings.HasPrefix(s, "hello"))

    fmt.Println(regexp.MustCompile(`world`).MatchString(s))
    fmt.Println(strings.Contains(s, "world"))

    fmt.Println(regexp.MustCompile(`\s`).Split(s, -1))
    fmt.Println(strings.Fields(s))
}

Each pair does the same thing, and the strings version is an order of magnitude faster and far easier to read. Reach for strings first — Contains, HasPrefix, HasSuffix, Split, Fields, ReplaceAll cover most real needs.

And for structured formats — HTML, JSON, CSV, email addresses to spec — use a parser, not a regex. encoding/json, encoding/csv and net/mail exist for exactly this.

Regexes earn their place on genuinely irregular text: log lines, free-form user input, ad-hoc extraction.

Your turn

Extract all the numbers from the string and print them:

[42 7 100]
package main

import (
    "fmt"
    "regexp"
)

func main() {
    text := "order 42 has 7 items worth 100 dollars"
    // find all runs of digits and print the slice
}
package main

import (
    "fmt"
    "regexp"
)

func main() {
    text := "order 42 has 7 items worth 100 dollars"

    re := regexp.MustCompile(`\d+`)
    fmt.Println(re.FindAllString(text, -1))
}

That's the standard-library tour. Next: proving your code works.