Go · lesson 8 of 25
Strings in Go
Go strings run for real: immutability, the strings package, Sprintf verbs, Builder vs += allocations, reversing by rune, Replacer, and regexp.
By SkillByExample · Updated
A Go string is an immutable sequence of UTF-8 bytes. Most work happens through the strings, strconv, fmt and unicode/utf8 packages. The programs below show the everyday functions and the two traps — bytes versus characters, and repeated concatenation.
Strings are immutable
Indexing reads a byte; assigning to one is a compile error.
package main
import "fmt"
func main() {
s := "hello"
s[0] = 'H'
fmt.Println(s)
}
$ go run immutable.go
# command-line-arguments
./immutable.go:7:2: cannot assign to s[0] (neither addressable nor a map index expression)
To change a string, build a new one. For a one-byte ASCII change, go through []byte:
package main
import "fmt"
func main() {
s := "hello"
b := []byte(s) // a copy: s is untouched
b[0] = 'H'
t := string(b) // another copy
fmt.Println(s, t)
fmt.Println(s+", "+t, len(s+t))
fmt.Println(`raw: \n stays as two characters`)
}
$ go run mutatecopy.go
hello Hello
hello, Hello 10
raw: \n stays as two characters
Converting between string and []byte copies the data each way, which is what keeps strings safe to share between goroutines. Backquoted raw strings ignore escape sequences — useful for regexps and Windows paths.
The strings package
strings covers searching, splitting, joining, trimming and case. Every function returns a new string.
package main
import (
"fmt"
"strings"
)
func main() {
s := " Go is expressive, concise, clean "
t := strings.TrimSpace(s)
fmt.Println(strings.Contains(t, "concise"), strings.HasPrefix(t, "Go"))
fmt.Println(strings.Index(t, "is"), strings.Count(t, "e"))
fmt.Printf("%q\n", strings.Split(t, ", "))
fmt.Printf("%q\n", strings.Fields(" a b\tc\n"))
fmt.Println(strings.Join([]string{"x", "y", "z"}, "-"))
fmt.Println(strings.ToUpper("go"), strings.Repeat("ab", 3))
fmt.Println(strings.ReplaceAll("a.b.c", ".", "/"))
fmt.Println(strings.EqualFold("GoLang", "golang"))
before, after, found := strings.Cut("key=value=x", "=")
fmt.Println(before, after, found)
}
$ go run pkg.go
true true
3 5
["Go is expressive" "concise" "clean"]
["a" "b" "c"]
x-y-z
GO ababab
a/b/c
true
key value=x true
Split cuts on an exact separator; Fields splits on any run of whitespace and drops empty pieces, which is usually what you want for user input. strings.Cut splits on the first separator only — the rest, including later = signs, stays in after. EqualFold is the case-insensitive comparison; it doesn’t allocate the way ToLower(a) == ToLower(b) does.
Formatting with fmt
Sprintf returns the formatted string; Printf writes it out. The verb picks the representation.
package main
import "fmt"
type User struct {
Name string
Age int
}
func main() {
u := User{"Alice", 30}
fmt.Printf("%v | %+v | %#v\n", u, u, u)
fmt.Printf("%T %q %x\n", u, "hi\n", "hi")
fmt.Printf("[%6.2f] [%-6d] [%06d]\n", 3.14159, 42, 42)
fmt.Printf("%t %c %U %08b\n", true, 'G', 'G', 5)
msg := fmt.Sprintf("%s is %d", u.Name, u.Age)
fmt.Println(msg, len(msg))
fmt.Printf("%d %s\n", "oops", 7)
}
$ go run sprintf.go
{Alice 30} | {Name:Alice Age:30} | main.User{Name:"Alice", Age:30}
main.User "hi\n" 6869
[ 3.14] [42 ] [000042]
true G U+0047 00000101
Alice is 30 11
%!d(string=oops) %!s(int=7)
%v is the default format, %+v adds field names and %#v prints Go syntax — the most useful verb when debugging. Width and precision go between % and the verb: %6.2f pads to six characters with two decimals, - left-aligns and 0 zero-pads. The last line uses the wrong verbs on purpose: instead of crashing, fmt prints %!d(string=oops), telling you which verb received which type. go vet would have caught it at build time.
strings.Builder vs +=
Each += on a string allocates a new string and copies everything so far. strings.Builder appends into one buffer.
package main
import (
"fmt"
"strings"
"testing"
)
func concat(n int) string {
s := ""
for range n {
s += "x"
}
return s
}
func build(n int) string {
var b strings.Builder
b.Grow(n) // optional: reserve the final size up front
for range n {
b.WriteString("x")
}
return b.String()
}
func main() {
fmt.Println(concat(5), build(5))
fmt.Println("+= allocs:", testing.AllocsPerRun(100, func() { concat(1000) }))
fmt.Println("Builder allocs:", testing.AllocsPerRun(100, func() { build(1000) }))
}
$ go run builder.go
xxxxx xxxxx
+= allocs: 999
Builder allocs: 1
testing.AllocsPerRun counts heap allocations per call, and unlike a timing it gives the same answer on every machine. Building a 1,000-character string with += allocated 999 times — a fresh string for essentially every append, each one copying all the bytes so far, so the total work grows with the square of the length. The Builder with Grow allocated once. Use + freely for joining a handful of strings, and a Builder — or strings.Join — inside loops.
Bytes vs runes
len and indexing work in bytes; []rune and range work in Unicode code points.
package main
import (
"fmt"
"unicode/utf8"
)
func reverseBytes(s string) string {
b := []byte(s)
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}
func reverseRunes(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func main() {
s := "café"
fmt.Println(len(s), utf8.RuneCountInString(s))
fmt.Printf("%q %q\n", reverseBytes(s), reverseRunes(s))
fmt.Println(s[:4], "|", string([]rune(s)[:3]))
}
$ go run reverse.go
5 4
"\xa9\xc3fac" "éfac"
caf� | caf
é is two bytes in UTF-8, so the byte-wise reverse splits it and produces invalid UTF-8 (%q shows the stray bytes as \xa9 and \xc3). Reversing runes keeps each character intact. Slicing s[:4] likewise cuts in bytes: it happens to end after the first byte of é, and the terminal shows that lone byte as �. To take the first N characters, slice a []rune.
Replacer and Reader
strings.NewReplacer performs several replacements in one pass; strings.NewReader turns a string into an io.Reader for any API that reads a stream.
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
esc := strings.NewReplacer("&", "&", "<", "<", ">", ">")
fmt.Println(esc.Replace("a < b && c > d"))
sc := bufio.NewScanner(strings.NewReader("line one\nline two\nline three"))
n := 0
for sc.Scan() {
n++
fmt.Println(n, sc.Text())
}
}
$ go run replacer.go
a < b && c > d
1 line one
2 line two
3 line three
The replacer looks at the input once, so & is not re-escaped inside < — chaining ReplaceAll calls in the wrong order would produce &lt;. strings.NewReader is how tests feed text to code that expects a file or network stream.
Regular expressions
regexp uses RE2 syntax: guaranteed linear-time matching, no lookaround or backreferences.
package main
import (
"fmt"
"regexp"
)
var datePat = regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})`)
func main() {
text := "released 2024-02-06, patched 2024-03-05"
fmt.Println(datePat.MatchString(text))
fmt.Println(datePat.FindAllString(text, -1))
m := datePat.FindStringSubmatch(text)
fmt.Println(m[1], m[2], m[3])
fmt.Println(datePat.ReplaceAllString(text, "$3/$2/$1"))
_, err := regexp.Compile(`(?=x)`)
fmt.Println(err)
}
$ go run regex.go
true
[2024-02-06 2024-03-05]
2024 02 06
released 06/02/2024, patched 05/03/2024
error parsing regexp: invalid or unsupported Perl syntax: `(?=`
MustCompile panics on a bad pattern, which suits a package-level variable compiled once at startup; Compile returns an error instead, shown on the last line for a lookahead that RE2 refuses. FindStringSubmatch returns the whole match at index 0 and each group after it, and $1-style references reorder groups in a replacement.
Practice
1. Write truncate(s string, n int) string that keeps at most n characters (not bytes) and appends … when it cut anything. Test it on "héllo wörld" with n = 5.
Solution
package main
import "fmt"
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
func main() {
fmt.Println(truncate("héllo wörld", 5), truncate("hi", 5))
}
$ go run p1.go
héllo… hi
Slicing the rune slice can’t split a character, whatever its byte width.
2. Count word frequencies in "the cat and the hat and the bat" with strings.Fields, and print the words sorted alphabetically with their counts.
Solution
package main
import (
"fmt"
"maps"
"slices"
"strings"
)
func main() {
counts := map[string]int{}
for _, w := range strings.Fields("the cat and the hat and the bat") {
counts[w]++
}
for _, w := range slices.Sorted(maps.Keys(counts)) {
fmt.Printf("%s=%d ", w, counts[w])
}
fmt.Println()
}
$ go run p2.go
and=2 bat=1 cat=1 hat=1 the=3
Sorting the keys makes the output stable; ranging over the map directly would print in a different order on different runs.
3. Build a comma-separated list of the numbers 1 to 5 with a strings.Builder, with no trailing comma.
Solution
package main
import (
"fmt"
"strconv"
"strings"
)
func main() {
var b strings.Builder
for i := 1; i <= 5; i++ {
if i > 1 {
b.WriteString(", ")
}
b.WriteString(strconv.Itoa(i))
}
fmt.Println(b.String())
}
$ go run p3.go
1, 2, 3, 4, 5
Writing the separator before every element except the first avoids trimming afterwards. fmt.Fprintf(&b, ...) also works, since *strings.Builder is an io.Writer.
4. Write a regexp that extracts every key=value pair from "user=ann id=42 role=admin" into a map.
Solution
package main
import (
"fmt"
"regexp"
)
var kv = regexp.MustCompile(`(\w+)=(\w+)`)
func main() {
m := map[string]string{}
for _, g := range kv.FindAllStringSubmatch("user=ann id=42 role=admin", -1) {
m[g[1]] = g[2]
}
fmt.Println(m)
}
$ go run p4.go
map[id:42 role:admin user:ann]
FindAllStringSubmatch returns one []string per match, each with the full match and its groups. fmt prints maps with sorted keys, so this output is stable.