JavaRush/Courses/Go SELF/bytes.Buffer and strings.Builder

bytes.Buffer and strings.Builder

26 level, 1 lecture
Open

1. Why “collecting output” is better than “printing from every function”

When you are just learning to program, you often want to do this: “compute inside the function — and right there call fmt.Println.” That is normal at the beginning: it is easier for your brain, and you see results immediately. But pretty soon the pain starts: you want to use the same function somewhere else, and it suddenly prints extra stuff, breaks formatting, or even writes “to the wrong place.” So we separate two tasks: compute/build the text and decide where to send that text.

Imagine your function is a chef. If the chef runs into the dining room every time and loudly dictates the recipe to customers, the restaurant will close quickly. The chef should cook, and the waiter should deliver. In Go, the “waiter” role is often played by io.Writer: you give the function “where to write,” and it writes there without knowing whether it is a console, a file, or an in-memory buffer.

Let’s compare a “bad” and a “good” approach with a tiny example:

package main

import "fmt"

func PrintHelloBad() {
	fmt.Println("hello") // print immediately, uncontrollably
}

func main() {
	PrintHelloBad()
}

Now a version that keeps control in the caller:

package main

import (
	"fmt"
	"io"
	"os"
)

func WriteHello(w io.Writer) {
	fmt.Fprintln(w, "hello") // write wherever we were given
}

func main() {
	WriteHello(os.Stdout) // right now this is the console
}

For now, the difference may feel philosophical, but you will soon get a practical bonus: you can “swap” os.Stdout for a buffer and verify the result.

2. bytes.Buffer: “memory like a file, just without files”

bytes.Buffer is one of the most convenient things in Go’s standard library for working with in-memory bytes. It can accumulate data: you write chunks into it, and it stores everything “glued together.” At the same time, it can also read those accumulated bytes as a stream, meaning bytes.Buffer looks like both an io.Writer and an io.Reader.

An important detail that pleasantly surprises beginners: the zero value of bytes.Buffer is ready to use. That means you do not need make, new, constructors, or magical initialization. Just var buf bytes.Buffer — and you can start writing.

A tiny example: accumulate text (via bytes) and print it as a string:

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer

	buf.WriteString("go")
	buf.WriteByte('!')

	fmt.Println(buf.String()) // go!
}

Now an important “streaming” characteristic: if we read from a bytes.Buffer, it consumes the data. This behavior is like a queue: read it — it is gone.

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	buf.WriteString("abcdef")

	tmp := make([]byte, 2)

	n, _ := buf.Read(tmp)
	fmt.Printf("%q\n", tmp[:n]) // "ab"

	fmt.Println(buf.String()) // cdef
}

This can be surprising: you thought “I will just take a look,” but the data disappears. In practice, that is normal: Read is reading a stream, not “peeking.” If you need to “peek,” use buf.Bytes() or buf.String() without Read.

3. strings.Builder: a fast string builder

strings.Builder is a “string specialist.” It is useful when your goal is to assemble a string from pieces without constant + and without extra temporary objects. Strings in Go are immutable, so a = a + b in a loop often creates a bunch of intermediate strings. strings.Builder solves this cleanly: it keeps an internal buffer and grows it as needed.

Just like bytes.Buffer, the strings.Builder zero value is ready to use. You can write var b strings.Builder and immediately call b.WriteString(...).

The simplest example:

package main

import (
	"fmt"
	"strings"
)

func main() {
	var b strings.Builder
	b.WriteString("hello")
	b.WriteString(" ")
	b.WriteString("go")

	fmt.Println(b.String()) // hello go
}

A subtle point that often comes up here: to use strings.Builder as an io.Writer, you typically pass it by pointer — &b. Not because “Go is complicated,” but because the methods on Builder are designed so that, for the io.Writer interface, a pointer to Builder is usually the type that fits.

package main

import (
	"fmt"
	"strings"
)

func main() {
	var b strings.Builder

	fmt.Fprintln(&b, "line 1")
	fmt.Fprintln(&b, "line 2")

	fmt.Print(b.String())
	// line 1
	// line 2
}

Another careful nuance: strings.Builder does not like being copied after you start using it. For a beginner, one rule is enough: “created a Builder — use it in one place and pass it as &b; do not make copies via assignment.”

4. How to choose between bytes.Buffer and strings.Builder

In practice, it is very easy to get stuck on the question “which one is correct?” The good news: in most learning tasks, both options are correct. More important is understanding what result you want and whether you need Read. bytes.Buffer is more universal (bytes + reading), while strings.Builder is simpler and more natural for assembling strings.

Let’s summarize this in a small table so your brain has something to latch onto:

What we compare bytes.Buffer strings.Builder
Main purpose accumulate bytes accumulate a string
Can be read as a stream (Read) yes no
Get a string
buf.String()
b.String()
Get bytes
buf.Bytes()
no (string only)
Zero value is ready yes yes
How to use with fmt.Fprint* usually &buf usually &b

A practical rule: if you are formatting human-readable text (a report, a table, a user message), use strings.Builder. If you work with data as bytes, or you need both “write and read,” use bytes.Buffer.

fmt.Fprint*: printing into any io.Writer

Before this, we mostly used fmt.Print, fmt.Println, fmt.Printf. They write to standard output (usually the console). But for “collecting output,” we need to be able to write not only to the console. This is where the fmt.Fprint* family comes in: they do the same thing, but their first parameter is an io.Writer.

It is very important to feel this concretely: we are not changing formatting; we are changing “where to write.”

Example: print to the console (as usual), but via Fprintln:

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Fprintln(os.Stdout, "hello") // hello
}

And now print not to the console, but to a buffer:

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	fmt.Fprintln(&buf, "hello")

	fmt.Println(buf.String()) // hello
}

This is where the “aha moment” usually happens: if a function accepts io.Writer, we can send its output anywhere — into memory, into a file, or even “to two places at once” (but not today).

5. Mini-app: rendering tasks into an io.Writer

So the examples are not just a pile of random strings, let’s continue one storyline: we will make a small “core” for a task list (a mini todo). We are not building a full CLI here, and we are not touching files or databases — the goal is to learn to produce output in a way that is easy to redirect and verify.

Let’s start with the model. A task is ID, Title, and a Done flag. No magic:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Now let’s make a function that prints one task into any io.Writer. Notice: the function does not know where it writes.

package main

import (
	"fmt"
	"io"
)

func WriteTask(w io.Writer, t Task) error {
	status := "TODO"
	if t.Done {
		status = "DONE"
	}
	_, err := fmt.Fprintf(w, "#%d [%s] %s\n", t.ID, status, t.Title)
	return err
}

A quick style note: fmt.Fprintf returns (n int, err error). We do not need the byte count (n) right now, but we do need the error, so we use _, err := ....

Now a function to output a list of tasks using WriteTask:

package main

import "io"

func WriteTasks(w io.Writer, tasks []Task) error {
	for _, t := range tasks {
		if err := WriteTask(w, t); err != nil {
			return err
		}
	}
	return nil
}

And an example of using it in main to see the result on screen:

package main

import (
	"os"
)

func main() {
	tasks := []Task{
		{ID: 1, Title: "learn io.Writer", Done: true},
		{ID: 2, Title: "stop printing everywhere", Done: false},
	}
	_ = WriteTasks(os.Stdout, tasks)
	// #1 [DONE] learn io.Writer
	// #2 [TODO] stop printing everywhere
}

Yes, we ignore the error via _ = ... only to keep the example small. In “real” code, it is better to handle err.

6. A report as a string: strings.Builder and WriteTasks

Now we have an excellent function WriteTasks(w, tasks). You can use it not only to print to the screen, but also to produce a string — for example, to return a result from a function or to prepare a user message. And this is where strings.Builder shines: it builds a string, and the io.Writer interface lets us reuse the render code without copy-paste.

Let’s make a function FormatTasks that returns a string (and an error, to stay honest):

package main

import "strings"

func FormatTasks(tasks []Task) (string, error) {
	var b strings.Builder

	if err := WriteTasks(&b, tasks); err != nil {
		return "", err
	}
	return b.String(), nil
}

Let’s check it with a tiny demo:

package main

import "fmt"

func main() {
	tasks := []Task{{ID: 1, Title: "write report", Done: false}}

	s, err := FormatTasks(tasks)
	fmt.Println(err) // <nil>
	fmt.Print(s)     // #1 [TODO] write report
}

Notice how nicely the architecture is structured: formatting is in one place, and directing output is in another. If later you want to print this same report somewhere else, you do not rewrite the logic — you just change the io.Writer.

7. Verifying output: bytes.Buffer instead of stdout

Verifying output is a classic beginner problem: “How do I make sure the function printed correctly without eyeballing it?” This is where buffers are a gift. We can send output into a bytes.Buffer and then compare the resulting string to the expected one. Even if you are not ready for full go test yet, the idea is the same.

First, a version “without a test framework” — just a check in code:

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	_ = WriteTask(&buf, Task{ID: 7, Title: "check output", Done: true})

	got := buf.String()
	want := "#7 [DONE] check output\n"

	fmt.Println(got == want) // true
}

Now a neat minimal unit test example. It is intentionally small: no table-driven cases or other “grown-up” things — just the essence. The file is usually named task_test.go:

package main

import (
	"bytes"
	"testing"
)

func TestWriteTask(t *testing.T) {
	var buf bytes.Buffer
	_ = WriteTask(&buf, Task{ID: 1, Title: "t", Done: false})

	if buf.String() != "#1 [TODO] t\n" {
		t.Fatalf("unexpected output: %q", buf.String())
	}
}

The meaning is the same: we do not touch the console, we do not intercept stdout, and we do not implement tricky captures. We simply passed a different io.Writer to the function. That is exactly why the design “a function writes into an io.Writer” is considered so practical: it is not only about flexible output, but also about testability.

Diagram: one renderer, different destinations

When you see fmt.Println inside every function again and again, it can feel like “that is how it should be.” But in real projects it quickly turns into noise: functions start doing too much. Buffers help separate roles: some parts of the code produce output, while others decide where to send it. This simplifies reuse and makes behavior predictable.

flowchart TD
    A[WriteTask / WriteTasks] -->|writes| W[io.Writer]

    W --> O[os.Stdout
show to user] W --> B[bytes.Buffer
verify in a test] W --> S[strings.Builder
get a string]

Key idea: the same function WriteTasks fits all three scenarios, because it depends not on a specific output destination, but on the io.Writer contract.

8. Common mistakes when working with bytes.Buffer and strings.Builder

Mistake No. 1: printing inside business logic instead of writing to an io.Writer.
Beginners often call fmt.Println inside a function that should “just compute” or “just build text.” As a result, the code cannot be reused properly: it always prints to the console. The fix is simple: if a function is responsible for output, let it accept w io.Writer. If a function is responsible for calculations, let it return values, and keep printing at the boundary of the program.

Mistake No. 2: passing strings.Builder not as &b, but as b.
Sometimes it feels like “it is just a variable, why bother.” But interfaces in Go depend on the method set, and often io.Writer is implemented specifically by a pointer to strings.Builder. If the compiler complains that strings.Builder does not implement io.Writer, you almost always forgot the ampersand: you need &b.

Mistake No. 3: reading from a bytes.Buffer and being surprised that the data “disappeared.”
If you call Read, the buffer behaves like a stream: what is read is considered consumed. After that, buf.String() shows what remains. If you need to “see what is inside,” use String()/Bytes() without Read, or work with a copy of the data.

Mistake No. 4: comparing “expected output” and “actual output” but forgetting about \n.
This is one of the most frequent bugs in output checks. fmt.Fprintln adds a newline, while fmt.Fprint does not. Visually, in a terminal, they can look similar, but string comparison fails. Get used to explicitly including \n in the expected string and, when needed, printing strings with %q to see hidden characters.

Mistake No. 5: copying a Builder/Buffer after you started using it.
In practice, copying these structs is rarely needed, and sometimes it can produce weird effects (for example, two values start “sharing” internals, or you get incorrect behavior). A simple starter rule: keep bytes.Buffer and strings.Builder in a single variable, pass a pointer (&buf, &b), and do not do assignments like b2 := b after writing into it.

1
Task
Go SELF,  26 level,  1 lecture
Locked
Greeting without +
Greeting without +
1
Task
Go SELF,  26 level,  1 lecture
Locked
Location card
Location card
1
Task
Go SELF,  26 level,  1 lecture
Locked
Receipt line
Receipt line
1
Task
Go SELF,  26 level,  1 lecture
Locked
Task list
Task list
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet