Automate the Boring Stuff with Go, Ch. 7: Strings
Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.
Updated for Go 1.27 in October 2026. What changed: the three projects are rewritten from scratch (clipboard email finder, password checker, quiz builder) in the shape every later project uses, with tests beside them in the repo; plus a section on strings.Cut, strings.Builder and runes.
Think of all the times you’ve used cmd + f or ctrl + f to search for a phrase on a webpage or in a huge document.
Programs can do the same, and better: rather than matching a word character for character, they can search for the pattern of a string.
Suppose you’ve been handed a 1 GB document and your task is to find every email address in it. Searching for name@company.com will find some, but not name@company.two.com, and so on. You know an email has an @ with something in front and something behind, usually ending in .com, but listing every combination would take all day and the work is due by five.
Enter regular expressions, or regex. It is a piece of knowledge you will need more often than you expect.
In this chapter you’ll see how to find patterns without regex and with it. Sometimes you really do just need one particular string, and that is the easier place to start. The code is in the repo under ch7.
Finding strings and substrings without regex
First, what substring means, since the word comes up a lot: a substring is a smaller part of a string. "continent" is a substring of "continental".
Search
The standard library’s strings package has search built in, and it is the right first tool:
package main
import (
"fmt"
"strings"
)
func main() {
doc := `
In the fast-paced world of corporate business, the pursuit of optimal efficiency is paramount.
Companies must constantly strive to fine-tune their operational processes, fostering an environment of maximum productivity.
This text aims to delve into the intricacies of optimizing synergistic business operations, highlighting the various
mundane aspects of this essential endeavor.To begin, it is essential to emphasize the importance of aligning organizational
goals with strategic planning. Companies need to set clear objectives and devise comprehensive strategies to achieve them.
By ensuring that these objectives are communicated effectively and consistently across all departments, companies can
expect to see improved coordination and coherence in their operations. Once the strategic framework a fundamental but
tedious undertaking that requires careful planning, continuous process improvement, meticulous monitoring, effective time
management, and resource allocation. While these aspects of corporate business may not be the most captivating, they are
integral to the success of any organization. A relentless commitment to these seemingly banal activities is what sets the
stage for a successful, efficient, and sustainable corporate operation.`
if strings.Contains(doc, "aspects") {
fmt.Println("Yes, 'aspects' is in that corporate word salad")
}
if !strings.Contains(doc, "orange") {
fmt.Println("silly Gopher there is no 'orange' in that corporate word salad")
}
fmt.Printf("how many times is the word 'companies' used? \nans: %d", strings.Count(doc, "Companies"))
}
strings.Contains answers yes or no; strings.Count counts. Both are case-sensitive, which is why the count asks for Companies with a capital C.
Replace
Often you search for a word because you want to replace it:
package main
import (
"fmt"
"strings"
)
func main() {
doc := `
In the fast-paced world of corporate business, the pursuit of optimal efficiency is paramount.
Companies must constantly strive to fine-tune their operational processes, fostering an environment of maximum productivity.
This text aims to delve into the intricacies of optimizing synergistic business operations, highlighting the various
mundane aspects of this essential endeavor.To begin, it is essential to emphasize the importance of aligning organizational
goals with strategic planning. Companies need to set clear objectives and devise comprehensive strategies to achieve them.
By ensuring that these objectives are communicated effectively and consistently across all departments, companies can
expect to see improved coordination and coherence in their operations. Once the strategic framework a fundamental but
tedious undertaking that requires careful planning, continuous process improvement, meticulous monitoring, effective time
management, and resource allocation. While these aspects of corporate business may not be the most captivating, they are
integral to the success of any organization. A relentless commitment to these seemingly banal activities is what sets the
stage for a successful, efficient, and sustainable corporate operation.`
newDoc := strings.Replace(doc, "Companies", "Organization", 3)
if !strings.Contains(doc, "Organization") {
fmt.Println("Organization is not present. Doing replacement now in newDoc...")
}
if strings.Contains(newDoc, "Organization") {
fmt.Println("Replacement successful.")
}
}
The last argument of strings.Replace is how many replacements to make; -1 means all of them, and strings.ReplaceAll is the shorter way to say that.
Cut, Builder, and runes
Three more tools from the strings family that you will use constantly once you know them:
// three string tools you will use constantly: Cut, Builder, and runes
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
func main() {
// strings.Cut splits at the first separator and tells you whether it was there
key, value, found := strings.Cut("timeout=30s", "=")
fmt.Println(key, value, found)
// strings.Builder assembles a big string without copying it on every +=
var b strings.Builder
for i := range 3 {
fmt.Fprintf(&b, "line %d\n", i+1)
}
fmt.Print(b.String())
// a string is bytes; a rune is one character. They differ as soon as you leave ASCII.
s := "héllo"
fmt.Println(len(s), utf8.RuneCountInString(s)) // 6 bytes, 5 characters
for i, r := range s { // range walks runes, not bytes
fmt.Printf("%d:%c ", i, r)
}
fmt.Println()
}
strings.Cut is the clean way to split a key=value pair. strings.Builder is how you assemble a large string piece by piece; += in a loop copies the whole string every time and gets slow. And the last part is the one that bites everyone once: len of a string is its size in bytes, while é is two bytes and one character. When you care about characters, count runes and range over the string, which walks it character by character.
Now that the basics are covered, let’s move on to regex.
Regex
Go ships the regexp package, so there is no third-party dependency. Regex patterns are hard for most developers to build from memory, so here is the tool I keep open while writing one: RegExr gives you real-time feedback as you type a pattern, plus a reference of common ones.
Using regex in Go takes two lines: re := regexp.MustCompile(pattern) to build the pattern, then one of re’s methods to search.
First match
Suppose we want the first foo in a sentence about the various footballs of the world:
// first match: where does "foo" first appear?
package main
import (
"fmt"
"regexp"
)
func main() {
re := regexp.MustCompile(`foo.?`)
fmt.Printf("%q\n", re.FindString("People like to tailgate for the American sport football, not to be confused with football.")) // "foot"
fmt.Printf("%q\n", re.FindString("this string doesn't have the substring. Bummer...")) // ""
}
The pattern foo.? means “foo, then optionally any one character”. It is like asking where foo first appears.
Location
Now suppose we want to know where the treasure is. The method changes, the idea does not:
// location: where in the string is the match?
package main
import (
"fmt"
"regexp"
)
func main() {
re := regexp.MustCompile(`treasure`)
fmt.Println(re.FindStringIndex("Some maps lead to riches and some don't. But rotate this one, squint, and X marks the treasure.")) // [start end]
fmt.Println(re.FindStringIndex("there be none here, try another map") == nil) // true
}
FindStringIndex returns the start and end positions, or nil when there is no match. It is the subtleties of regex that make it tricky: one character in the pattern changes everything, so test as you go.
All matches
Instead of one treasure, maybe you need every match in a document:
// all matches: every place the pattern appears
package main
import (
"fmt"
"regexp"
)
func main() {
re := regexp.MustCompile(`a.`)
fmt.Printf("%q\n", re.FindAllString("An apple in the big apple is known to cause lots of apathy for some reason", -1))
fmt.Printf("%q\n", re.FindAllString("paranormal", 2)) // at most 2 matches
fmt.Printf("%q\n", re.FindAllString("graal", -1))
fmt.Printf("%q\n", re.FindAllString("none", -1)) // []
}
The second argument is the maximum number of matches; -1 means all of them.
Projects
Now that we’ve seen the pieces, let’s build projects that show real-world uses of regex rather than practice examples. All three follow the same shape, which every project in the rest of the book keeps: main calls run, run returns an error instead of exiting, the interesting work is in a function with a name, and a _test.go file next to it proves that function does what the chapter says.
1. Email finder from the clipboard
Copy a chunk of text, run the program, and it prints every email address in it and puts the list back on your clipboard, one per line. The pattern of finding emails in prose is handy because you’ll often receive data you need to group by a common feature.
Note: this project has a third-party dependency, the clipboard package. You remember how that works: inside the repo it is already in go.mod; in your own module, go get github.com/atotto/clipboard.
// email-finder reads whatever is on the clipboard, prints every email address it
// finds, and puts the list back on the clipboard, one address per line.
//
// go run ./ch7/projects/email-finder
package main
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
"github.com/atotto/clipboard"
)
// emailPattern is deliberately simple: a word-like local part, an @, a domain with at
// least one dot. It is for finding addresses in prose, not for validating them.
var emailPattern = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
// FindEmails returns the distinct email addresses in text, in order of first appearance.
func FindEmails(text string) []string {
seen := map[string]bool{}
var out []string
for _, m := range emailPattern.FindAllString(text, -1) {
m = strings.ToLower(m)
if seen[m] {
continue
}
seen[m] = true
out = append(out, m)
}
return out
}
func run() error {
text, err := clipboard.ReadAll()
if err != nil {
return fmt.Errorf("read clipboard: %w", err)
}
emails := FindEmails(text)
if len(emails) == 0 {
return errors.New("no email addresses on the clipboard")
}
joined := strings.Join(emails, "\n")
fmt.Println(joined)
if err := clipboard.WriteAll(joined); err != nil {
return fmt.Errorf("write clipboard: %w", err)
}
fmt.Fprintf(os.Stderr, "copied %d address(es) to the clipboard\n", len(emails))
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
FindEmails lower-cases and de-duplicates, so Ada@example.com and ada@example.com come out once. The regex is deliberately a finder, not a validator: email validation is a famous rabbit hole, and a pattern that catches addresses in text is all the job needs.
2. Password complexity checker
Simple passwords are easier for criminals to guess, so applications enforce a complexity policy. Ours: at least 11 characters, with an upper-case letter, a lower-case letter and a digit.
// password-checker tells you whether a password meets a simple complexity policy:
// at least 11 characters, with an upper-case letter, a lower-case letter and a digit.
//
// go run ./ch7/projects/password-checker 'Tr0ub4dor&3xtra'
package main
import (
"fmt"
"os"
"regexp"
"unicode/utf8"
)
const minLength = 11
var (
hasUpper = regexp.MustCompile(`[A-Z]`)
hasLower = regexp.MustCompile(`[a-z]`)
hasDigit = regexp.MustCompile(`[0-9]`)
)
// Problems returns every rule the password breaks. An empty result means it passes.
func Problems(password string) []string {
var problems []string
if utf8.RuneCountInString(password) < minLength {
problems = append(problems, fmt.Sprintf("shorter than %d characters", minLength))
}
if !hasUpper.MatchString(password) {
problems = append(problems, "no upper-case letter")
}
if !hasLower.MatchString(password) {
problems = append(problems, "no lower-case letter")
}
if !hasDigit.MatchString(password) {
problems = append(problems, "no digit")
}
return problems
}
func run(args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: password-checker <password>")
}
problems := Problems(args[0])
if len(problems) == 0 {
fmt.Println("Password OK")
return nil
}
fmt.Println("Bad Password")
for _, p := range problems {
fmt.Println(" -", p)
}
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
Run it with a password as the argument:
$ go run ./ch7/projects/password-checker 'CorrectHorse9'
Password OK
$ go run ./ch7/projects/password-checker 'short1A'
Bad Password
- shorter than 11 characters
Notice the length check counts runes, not bytes, for the reason from the runes section: Ünïcödé-Länge-1 is 15 characters, not 20 bytes.
3. Quiz builder
This project is the first in our queue of mundane work tasks automated; your teacher friends will love you. It builds multiple-choice quizzes about U.S. state capitals: several forms, each with the questions and the answer options in a different random order, each with its own answer key. Change the capitals table and you have a quiz generator for anything.
// quiz-builder writes multiple-choice quizzes about U.S. state capitals, each with the
// questions and the answer options in a different random order, plus an answer key.
// Change the capitals table and you have a quiz generator for anything.
//
// go run ./ch7/projects/quiz-builder -n 3 -out out
package main
import (
"flag"
"fmt"
"math/rand/v2"
"os"
"path/filepath"
"slices"
"strings"
)
var capitals = map[string]string{
"Alabama": "Montgomery", "Alaska": "Juneau", "Arizona": "Phoenix", "Arkansas": "Little Rock",
"California": "Sacramento", "Colorado": "Denver", "Connecticut": "Hartford", "Delaware": "Dover",
"Florida": "Tallahassee", "Georgia": "Atlanta", "Hawaii": "Honolulu", "Idaho": "Boise",
"Illinois": "Springfield", "Indiana": "Indianapolis", "Iowa": "Des Moines", "Kansas": "Topeka",
"Kentucky": "Frankfort", "Louisiana": "Baton Rouge", "Maine": "Augusta", "Maryland": "Annapolis",
"Massachusetts": "Boston", "Michigan": "Lansing", "Minnesota": "Saint Paul", "Mississippi": "Jackson",
"Missouri": "Jefferson City", "Montana": "Helena", "Nebraska": "Lincoln", "Nevada": "Carson City",
"New Hampshire": "Concord", "New Jersey": "Trenton", "New Mexico": "Santa Fe", "New York": "Albany",
"North Carolina": "Raleigh", "North Dakota": "Bismarck", "Ohio": "Columbus", "Oklahoma": "Oklahoma City",
"Oregon": "Salem", "Pennsylvania": "Harrisburg", "Rhode Island": "Providence", "South Carolina": "Columbia",
"South Dakota": "Pierre", "Tennessee": "Nashville", "Texas": "Austin", "Utah": "Salt Lake City",
"Vermont": "Montpelier", "Virginia": "Richmond", "Washington": "Olympia", "West Virginia": "Charleston",
"Wisconsin": "Madison", "Wyoming": "Cheyenne",
}
// Question is one quiz item: the prompt, four options and the index of the right one.
type Question struct {
State string
Options [4]string
Answer int
}
// BuildQuiz returns every entry of answers as a question, in random order, with the
// correct capital and three wrong ones shuffled among the options.
func BuildQuiz(r *rand.Rand, answers map[string]string) []Question {
states := slices.Sorted(mapsKeys(answers))
r.Shuffle(len(states), func(i, j int) { states[i], states[j] = states[j], states[i] })
allCapitals := make([]string, 0, len(answers))
for _, c := range answers {
allCapitals = append(allCapitals, c)
}
slices.Sort(allCapitals)
quiz := make([]Question, 0, len(states))
for _, state := range states {
correct := answers[state]
wrong := make([]string, 0, 3)
for len(wrong) < 3 {
c := allCapitals[r.IntN(len(allCapitals))]
if c != correct && !slices.Contains(wrong, c) {
wrong = append(wrong, c)
}
}
q := Question{State: state, Answer: r.IntN(4)}
w := 0
for i := range q.Options {
if i == q.Answer {
q.Options[i] = correct
} else {
q.Options[i] = wrong[w]
w++
}
}
quiz = append(quiz, q)
}
return quiz
}
func mapsKeys(m map[string]string) func(yield func(string) bool) {
return func(yield func(string) bool) {
for k := range m {
if !yield(k) {
return
}
}
}
}
// Render returns the quiz text and the answer key text for quiz number n.
func Render(n int, quiz []Question) (paper, key string) {
var p, k strings.Builder
fmt.Fprintf(&p, "Name:\n\nDate:\n\nPeriod:\n\n%40s State Capitals Quiz (Form %d)\n\n", "", n)
fmt.Fprintf(&k, "State Capitals Quiz (Form %d): answer key\n\n", n)
for i, q := range quiz {
fmt.Fprintf(&p, "%d. What is the capital of %s?\n", i+1, q.State)
for j, opt := range q.Options {
fmt.Fprintf(&p, " %c. %s\n", 'A'+j, opt)
}
p.WriteString("\n")
fmt.Fprintf(&k, "%d. %c\n", i+1, 'A'+q.Answer)
}
return p.String(), k.String()
}
func run(args []string) error {
flags := flag.NewFlagSet("quiz-builder", flag.ContinueOnError)
count := flags.Int("n", 3, "how many quiz forms to write")
outDir := flags.String("out", "out", "directory for the quiz files")
seed := flags.Uint64("seed", 0, "random seed (0 = different every run)")
if err := flags.Parse(args); err != nil {
return err
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", *outDir, err)
}
if *seed == 0 {
*seed = rand.Uint64()
}
r := rand.New(rand.NewPCG(*seed, *seed))
for n := 1; n <= *count; n++ {
paper, key := Render(n, BuildQuiz(r, capitals))
quizPath := filepath.Join(*outDir, fmt.Sprintf("capitals_quiz_%d.txt", n))
keyPath := filepath.Join(*outDir, fmt.Sprintf("capitals_quiz_%d_answers.txt", n))
if err := os.WriteFile(quizPath, []byte(paper), 0o644); err != nil {
return fmt.Errorf("write %s: %w", quizPath, err)
}
if err := os.WriteFile(keyPath, []byte(key), 0o644); err != nil {
return fmt.Errorf("write %s: %w", keyPath, err)
}
fmt.Println("wrote", quizPath, "and", keyPath)
}
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch7/projects/quiz-builder -n 3 -out out
wrote out/capitals_quiz_1.txt and out/capitals_quiz_1_answers.txt
...
A few things worth a second look. BuildQuiz takes the random source as a parameter, so a test can pass a seeded one and check the output exactly; the -seed flag gives you the same quizzes again when you want them. The flag package turns -n 3 -out out into variables for you, including a -h help message you did not have to write. And slices.Sorted(mapsKeys(answers)) fixes the order of the states before shuffling, because a map’s own order is random and we want the shuffle, not the map, to decide.
Conclusion
Now you know how to find things more precisely than cmd + f ever could, and you have seen the shape the rest of the book’s projects will take.