Strings & the strings Package

Reviewed & published by Brayan K

A Go string is an immutable sequence of bytes, and the standard strings package gives you everything you need to search, split, join, replace, and efficiently build text.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Strings Are Immutable Byte Sequences

Under the hood a Go string is just a read-only run of bytes. You can index it (s[0] gives the byte value) and re-slice it (s[1:]), but you can never assign to a single position — that's a compile error. To "change" a string you create a brand-new one, often by concatenating slices. And len reports the number of bytes, which matters once you move beyond plain ASCII.

package main

import "fmt"

func main() {
    // A string is an immutable sequence of bytes.
    s := "hello"

    // You can READ a byte by index, but you CAN'T change one.
    fmt.Println(s[0])        // 104  (the byte value of 'h')
    fmt.Printf("%c\n", s[0]) // h    (printed as a character)

    // s[0] = 'H'   // ❌ compile error: cannot assign to s[0]

    // To "change" a string you build a NEW one.
    s2 := "H" + s[1:]
    fmt.Println(s2) // Hello

    // len reports the number of BYTES.
    fmt.Println("length:", len(s)) // 5
}

2️⃣ The strings Package Toolkit

The strings package is where the real work happens. Split turns a string into a []string on a separator and Join glues one back together. Add searching (Contains, HasPrefix, Count), case conversion (ToUpper), and replacement (Replace, ReplaceAll), and you can handle almost any text task without a third-party library.

package main

import (
    "fmt"
    "strings"
)

func main() {
    csv := "ada,grace,linus,ken"

    // Split breaks a string into a slice on a separator.
    parts := strings.Split(csv, ",")
    fmt.Println(parts)      // [ada grace linus ken]
    fmt.Println(len(parts)) // 4

    // Join glues a slice back together with a separator.
    fmt.Println(strings.Join(parts, " | "))

    // Common helpers:
    fmt.Println(strings.Contains(csv, "grace"))    // true
    fmt.Println(strings.ToUpper("go"))             // GO
    fmt.Println(strings.Replace(csv, ",", "-", 1)) // ada-grace,linus,ken
    fmt.Println(strings.ReplaceAll(csv, ",", "-"))
    fmt.Println(strings.HasPrefix(csv, "ada")) // true
    fmt.Println(strings.Count(csv, ","))       // 3
}

3️⃣ Concatenation Cost & strings.Builder

Because strings are immutable, every s = s + x allocates a whole new string and copies the old bytes. Inside a loop that's quadratic work and a pile of garbage. strings.Builder fixes this by growing a single internal buffer: you WriteString / WriteByte / Fprintf into it, then call String() once at the end. It's the idiomatic way to assemble text piece by piece.

package main

import (
    "fmt"
    "strings"
)

func main() {
    // Each + on a string allocates a NEW string and copies everything.
    // In a loop that's O(n^2) work. strings.Builder grows ONE buffer.
    var b strings.Builder
    for i := 1; i <= 5; i++ {
        fmt.Fprintf(&b, "row %d\n", i)
    }
    result := b.String()
    fmt.Print(result)

    // WriteString is the fast path for plain strings.
    var greet strings.Builder
    greet.WriteString("Hello, ")
    greet.WriteString("Gopher")
    greet.WriteByte('!')
    fmt.Println(greet.String())
    fmt.Println("built length:", greet.Len())
}

🎯 Your Turn

Fill in the two blanks: join a slice of tags with ", ", then print the result in uppercase.

package main

import (
    "fmt"
    "strings"
)

func main() {
    tags := []string{"go", "web", "fast"}

    // 1) Join the tags with ", " into one string.
    joined := strings.___(tags, ", ")   // 👉 Join
    fmt.Println(joined)

    // 2) Print the joined string in UPPERCASE.
    fmt.Println(strings.___(joined))    // 👉 ToUpper

    // ✅ Expected output:
    // go, web, fast
    // GO, WEB, FAST
}

Common Errors (and the fix)

🎯 Mini-Challenge: Numbered List

Use a strings.Builder to assemble a numbered list — one line per word in the form "1. apple" — then print it. Range over the slice to get both the index and the word.

package main

import (
    "fmt"
    "strings"
)

func main() {
    // 🎯 MINI-CHALLENGE: build a numbered list efficiently.
    // Using a strings.Builder, append one line per word in the form
    // "1. apple\n", "2. banana\n", ... then print the whole thing.
    //
    // ✅ Expected output:
    // 1. apple
    // 2. banana
    // 3. cherry

    words := []string{"apple", "banana", "cherry"}
    // your code here
}

🎉 Lesson Complete!

Practice quiz

What is a Go string under the hood?

  • A mutable []rune
  • A linked list of characters
  • An immutable sequence of bytes
  • A mutable []byte

Answer: An immutable sequence of bytes. A Go string is an immutable sequence of bytes.

What happens if you write s[0] = 'H' on a string?

  • Compile error — strings are immutable
  • It works
  • It silently does nothing
  • It panics at runtime

Answer: Compile error — strings are immutable. You cannot assign to a string index; it is a compile error because strings are immutable.

What does fmt.Println(s[0]) print for s := "hello"?

  • h
  • "h"
  • 0
  • 104

Answer: 104. Indexing yields a byte; the byte value of 'h' is 104. Use %c to see the letter.

What does len("héllo") return, where é is 2 bytes?

  • 5
  • 6
  • 4
  • 7

Answer: 6. len counts bytes, and é takes 2, so the total is 6.

What does strings.Split("a,b,c", ",") return?

  • A []string of length 3: [a b c]
  • "a,b,c"
  • 3
  • A []byte

Answer: A []string of length 3: [a b c]. Split breaks the string on the separator into a slice [a b c].

What does strings.Join(parts, sep) do?

  • Splits a string
  • Counts separators
  • Glues a slice of strings together with sep between them
  • Removes whitespace

Answer: Glues a slice of strings together with sep between them. Join is the inverse of Split: it concatenates the slice elements with sep.

How does strings.Replace differ from strings.ReplaceAll?

  • They are identical
  • Replace takes a count n (use -1 for all); ReplaceAll replaces every match
  • ReplaceAll only replaces the first match
  • Replace returns a slice

Answer: Replace takes a count n (use -1 for all); ReplaceAll replaces every match. Replace(s, old, new, n) replaces at most n; ReplaceAll is Replace with n = -1.

Why is repeated s = s + x in a loop slow?

  • It uses too many goroutines
  • + is not allowed on strings
  • It never terminates
  • Strings are immutable, so each + allocates a new string and copies everything (O(n^2))

Answer: Strings are immutable, so each + allocates a new string and copies everything (O(n^2)). Immutability means each concatenation reallocates and copies, giving quadratic work.

What is the idiomatic way to assemble a string from many pieces in a loop?

  • Repeated +
  • strings.Builder
  • fmt.Sprint each time
  • append to a []string then index

Answer: strings.Builder. strings.Builder grows a single buffer, making the work linear.

After building "Hello, Gopher!" in a strings.Builder, what does b.Len() return?

  • 13
  • 12
  • 14
  • 15

Answer: 14. "Hello, Gopher!" is 14 bytes, which is what Len() reports.

Continue this course

Frequently asked questions

Why can't I change a single character in a Go string?

Strings are immutable in Go — once created, their bytes never change. Indexing like s[0] reads a byte, but assigning s[0] = 'H' is a compile error. To 'edit' a string you build a new one (for example "H" + s[1:]), or convert it to a []byte or []rune, modify that, and convert back.

Does len() give the number of characters or bytes?

len() returns the number of bytes, not characters. For pure ASCII text the two are the same, but for multi-byte UTF-8 characters (like é or 世) one character can be 2-4 bytes, so len() will be larger than the character count. To count characters use utf8.RuneCountInString — that is covered in the next lesson on runes and bytes.

Why is concatenating strings in a loop slow?

Because strings are immutable, each s = s + x allocates a brand-new string and copies all the existing bytes plus the new ones. Doing that in a loop is O(n^2) work and creates lots of garbage. strings.Builder grows a single internal buffer and only allocates occasionally, making the same job linear and much faster.

What is the difference between Replace and ReplaceAll?

strings.Replace(s, old, new, n) replaces at most n occurrences; pass -1 to replace every occurrence. strings.ReplaceAll(s, old, new) is a convenience that replaces all of them — it is exactly Replace with n = -1. Use ReplaceAll when you mean 'every match' and Replace when you want to limit the count.

Should I use strings.Builder or just +?

For a couple of concatenations, + is perfectly fine and reads clearly. Reach for strings.Builder when you are assembling a string in a loop or from many pieces — that is where avoiding repeated allocations matters. You can also call Builder.Grow(n) up front when you know the rough final size to avoid intermediate regrows.