JavaRush /Courses /Go SELF /strings and bytes: Trim/Split/Join and Builder/Buffer

strings and bytes: Trim/Split/Join and Builder/Buffer

Go SELF
Level 14, Lesson 3
Available

1. Introduction

When someone has just started programming, string operations seem very simple: “glued with +, printed, done.” But reality quickly ruins the party: you get whitespace at the edges of input, multiple separators in a row, empty pieces, and suddenly you need to build a large string in a loop in a way that doesn’t make your program spend half its time shuffling characters around. This is exactly where Go’s standard library starts working like a good teammate: the strings and bytes packages provide ready-made, readable solutions for typical tasks.

The main idea for today is very calm: if your data is a string, you almost always start with the strings package. If your data is a []byte (for example, you’ve already decided to work at the byte level or you’re building a byte buffer), then almost the same operations exist in the bytes package. And yes—these packages are intentionally similar so your head doesn’t overheat.

2. strings: TrimSpace, Split, Join

When you see input or data “from the outside world” (user, file, network), it’s almost never ideal. It can contain extra spaces, weird line breaks, commas “with a space,” and sometimes even “nothing, but with feeling”—that is, an empty string. In strings there’s a small set of functions that help bring it into a form that’s convenient to work with. Today we keep our focus on three: TrimSpace, Split, Join.

Let’s start with a simple scenario from our learning app. Imagine we want to store task “tags” as a comma-separated string, but people type them however they want: " go, strings, utf-8 ". We want to get a neat list of tags and then assemble a nice string back.

strings.TrimSpace: cleaning the edges

TrimSpace removes whitespace characters at the edges of the string: on the left and on the right. It’s important not to expect magic from it: it does not touch spaces inside the string. It’s like “shaking snow off your jacket,” not “vacuuming the apartment.”

package main

import (
	"fmt"
	"strings"
)

func main() {
	raw := "   go,  strings, utf-8   "
	clean := strings.TrimSpace(raw)

	fmt.Printf("%q\n", raw)   // "   go,  strings, utf-8   "
	fmt.Printf("%q\n", clean) // "go,  strings, utf-8"
}

Notice %q: it prints a string in quotes. This is useful even when the string looks “normal,” because in reality it may contain \n, \r, \t. We’ll cover diagnostics in detail separately; today we simply get used to the idea that %q is a good “flashlight.”

strings.Split: cutting a string into parts

Split divides a string by an exact separator (those are important words). If you said the separator is ",", it looks for exactly a comma. If your data contains ", " (comma + space), then that’s a different separator, and Split won’t “guess.”

package main

import (
	"fmt"
	"strings"
)

func main() {
	s := "go,strings,utf-8"
	parts := strings.Split(s, ",")

	fmt.Println(parts) // [go strings utf-8]
}

Here it’s useful to remember the behavior in “edge cases.” If the separator isn’t found, Split returns a slice with one element—the original string. If the separator occurs consecutively, empty elements appear. This isn’t a bug; it’s an honest reflection of the data. For example, the string "a,,b" produces ["a", "", "b"].

strings.Join: gluing the parts back together

Join does the reverse operation: it takes a []string and glues the elements together, inserting a separator between them. This is convenient when you’ve already processed the parts individually and want to assemble the final string.

package main

import (
	"fmt"
	"strings"
)

func main() {
	parts := []string{"go", "strings", "utf-8"}
	out := strings.Join(parts, ", ")

	fmt.Println(out) // go, strings, utf-8
}

A subtle but nice thought: the pattern Split → process parts → Join is usually very easy to read, especially if you give the intermediate variables honest names (rawTags, tagsParts, normalizedTags).

3. bytes: the same operations for []byte

If strings is “tools for string,” then bytes is “tools for []byte.” Switching to []byte is worth it not because “it’s cooler,” but because you really need to work at the byte level, or your data already comes in that form (for example, you read something as bytes, copy it, buffer it, accumulate it). The important part is that many operations mirror strings, and that saves your nerves.

Below is a small cheat-sheet table. It doesn’t aim to be a complete reference, but for today this exact set is enough.

Task For string For []byte
Remove whitespace at the edges
strings.TrimSpace(s)
bytes.TrimSpace(b)
Split by separator
strings.Split(s, sep)
bytes.Split(b, sep)
Join parts with a separator
strings.Join(parts, sep)
bytes.Join(parts, sep)

Let’s look at the byte version of splitting and joining. Note that in bytes.Split the separator is also a []byte, and the result is [][]byte (a slice of slices).

package main

import (
	"bytes"
	"fmt"
)

func main() {
	b := []byte("go,strings,utf-8")
	parts := bytes.Split(b, []byte(","))

	fmt.Println(parts) // [[103 111] [115 116 114 105 110 103 115] [117 116 102 45 56]]
}

This looks “scarier” because fmt.Println prints bytes as numbers. But the logic is the same: you got the parts, then you can process them, and then glue them back together:

package main

import (
	"bytes"
	"fmt"
)

func main() {
	parts := [][]byte{[]byte("go"), []byte("bytes")}
	out := bytes.Join(parts, []byte(" + "))

	fmt.Println(string(out)) // go + bytes
}

Notice: at the end we did string(out), because we want to print text nicely. This is a normal, usual change of data representation.

4. Building the result: strings.Builder and bytes.Buffer

Up to this point, we’ve done operations “in one go”: cut and rejoin. But often the task is different: we run a loop and gradually grow the result. For example, we build a human-readable report string, format a list of tags, or assemble a “message” from multiple lines. And this is where naive code starts looking like this: result = result + piece. It works, but it can be very memory-hungry, because strings are immutable and each + creates a new string.

To avoid endless re-gluing, Go provides accumulators. For strings—strings.Builder; for bytes—bytes.Buffer. You can think of them as a box where you put pieces, and at the end you close the lid and get the result.

strings.Builder: accumulating a string

strings.Builder is good when you need the final result specifically as a string. Its “zero value” is already ready to use: you can declare var b strings.Builder and start writing immediately.

package main

import (
	"fmt"
	"strings"
)

func main() {
	var b strings.Builder

	b.WriteString("Tags: ")
	b.WriteString("go")
	b.WriteString(", ")
	b.WriteString("strings")

	fmt.Println(b.String()) // Tags: go, strings
}

The important point is that we don’t do result += ...—we add chunks to the builder. At the end we call b.String() and get the final string.

bytes.Buffer: accumulating bytes

bytes.Buffer is similar in idea, but it’s oriented toward []byte. This is convenient if you’re building byte data or want to get bytes at the end (for example, to pass them along as []byte). We are not going into I/O and streams yet, so we treat Buffer simply as a “byte result builder.”

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer

	buf.WriteString("ID=")
	buf.WriteString("42")

	fmt.Println(buf.String()) // ID=42
}

Yes, bytes.Buffer also has String(). This is handy: you built bytes, but you can print them as a string.

An important point: what x.F(...) means

Before today, you often wrote calls like fmt.Println(...). There is a dot after fmt, but it’s not “object style” in the classic OOP sense; it’s access to a function inside a package: fmt is the package name, Println is the function name.

With Builder/Buffer we see a similar notation: b.WriteString("..."). But here the dot means something else: b is a value (a variable), and WriteString is an operation “attached” to the type of that value. Such an operation is called a method. That is, the notation x.F(...) is read as “call method F on value x.”

At a minimal level, it’s enough to remember two differences, and life gets easier. In fmt.Println(...) we call a function from a package. In b.WriteString(...) we call a method on the value b.

Let’s verify with a tiny example so the brain finally agrees:

package main

import (
	"fmt"
	"strings"
)

func main() {
	var b strings.Builder

	fmt.Printf("%T\n", b) // strings.Builder
	b.WriteString("Go")
	fmt.Println(b.String()) // Go
}

Here fmt.Printf is a function from the fmt package, while WriteString and String are builder methods. No magic—just two different situations with the same dot.

5. Example: normalizing tags

So this doesn’t stay a set of isolated tricks, let’s combine them into one small scenario from our learning app. Suppose we have “raw tag input” (for now just a string in code), and we want to do two things: get a neat []string of tags and assemble a nice string for printing.

Schematically it looks like this:

flowchart TD
    A["raw string '  go,  strings, utf-8  '"] --> B["TrimSpace"]
    B --> C["Split by ','"]
    C --> D["TrimSpace each part"]
    D --> E["Join with ', '"]
    E --> F["Builder: 'Tags: ' + joined"]

Normalization: TrimSpace + Split + loop + Join

We deliberately build a small “pipeline”: clean the edges of the whole string, split by commas, clean the edges of each piece, then join back together neatly.

package main

import (
	"fmt"
	"strings"
)

func main() {
	raw := "  go,  strings,  utf-8  "
	raw = strings.TrimSpace(raw)

	parts := strings.Split(raw, ",")
	for i := 0; i < len(parts); i++ {
		parts[i] = strings.TrimSpace(parts[i])
	}

	fmt.Println(strings.Join(parts, ", ")) // go, strings, utf-8
}

What matters here is that we use an index-based for, because we want to modify elements parts[i]. This is standard practice with slices: changing elements is simpler and clearer via indices.

Building the final string via strings.Builder

Now let’s say we want a string like: Tags: go, strings, utf-8. Yes, you can write fmt.Println("Tags:", strings.Join(...)), and for that case it’s fine. But Builder matters when the format is more complex or you build text in multiple steps. Let’s show it as a separate fragment of the same scenario.

package main

import (
	"fmt"
	"strings"
)

func main() {
	tags := []string{"go", "strings", "utf-8"}

	var b strings.Builder
	b.WriteString("Tags: ")
	b.WriteString(strings.Join(tags, ", "))

	fmt.Println(b.String()) // Tags: go, strings, utf-8
}

Builder is good here because the code reads like “assembling a phrase”: first we write the prefix, then we append the list.

Byte variant: bytes.Split/Join + bytes.Buffer

Sometimes it’s convenient to keep everything as bytes. For example, you received data as []byte and you don’t want to bounce conversions back and forth without a reason. Let’s show the mirrored variant. It looks a bit more “low-level,” but the logic is the same.

package main

import (
	"bytes"
	"fmt"
)

func main() {
	raw := []byte("  go,  bytes,  utf-8  ")
	raw = bytes.TrimSpace(raw)

	parts := bytes.Split(raw, []byte(","))
	for i := 0; i < len(parts); i++ {
		parts[i] = bytes.TrimSpace(parts[i])
	}

	var buf bytes.Buffer
	buf.WriteString("Tags: ")
	buf.Write(bytes.Join(parts, []byte(", ")))

	fmt.Println(buf.String()) // Tags: go, bytes, utf-8
}

Pay attention to the line buf.Write(...): bytes.Join returns []byte, and we write them into the buffer. At the end we print buf.String().

6. Common mistakes

Mistake #1: expecting TrimSpace to remove spaces “everywhere.”
Beginners often apply TrimSpace, see that the edges look nice, and subconsciously think: “well, now the string is clean.” Then they are surprised that "a b" remains "a b". This is normal behavior: TrimSpace is about the edges, not the inside. If you want to change the middle, that’s a different task and different tools.

Mistake #2: confusing “a separator as an idea” with “a separator as an exact substring.”
strings.Split(s, ",") splits only on commas. If your data uses comma+space and you split only on comma, the space will stay at the beginning of the next piece. This isn’t a Split mistake—it’s simply the data. A practical way to stay calm is to almost always do TrimSpace on each part after Split when the format allows extra spaces.

Mistake #3: being surprised by empty elements after Split.
A string like "a,,b" produces an empty element between two commas. People sometimes think “empty should be automatically thrown away.” But Split isn’t a fortune teller or an editor; it honestly shows the structure of the data. If you don’t want empty elements, that’s a separate decision: filtering the slice (we can do it with a loop, without extra magic).

Mistake #4: building a string in a loop via result += piece and not understanding why “everything suddenly got slow.”
On short strings the difference is barely visible, so it can feel like Builder is “for pedants.” But as data grows, constantly creating new strings becomes expensive. If you build a result in chunks in a loop, Builder/Buffer is not a “micro-optimization”—it’s simply the right tool.

Mistake #5: not understanding the difference between fmt.Println and b.WriteString.
Both notations use a dot, and that’s confusing. In fmt.Println the dot is access to a function in a package. In b.WriteString the dot is a method call on a value. If you separate these two cases in your head, standard library code becomes easier to read, and you stop perceiving methods as “something from the future.”

1
Task
Go SELF, level 14, lesson 3
Locked
Clean input
Clean input
1
Task
Go SELF, level 14, lesson 3
Locked
Color palette
Color palette
1
Task
Go SELF, level 14, lesson 3
Locked
Clean tags
Clean tags
1
Task
Go SELF, level 14, lesson 3
Locked
Byte report
Byte report
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION