Last Updated: May 17, 2026
Go gives you two ways to hold a sequence of bytes: a string, which is read-only and cheap to share, and a []byte, which is mutable and growable. They look similar from the outside, and you can convert between them with a single cast, but the memory layout and the cost of crossing between them are worth understanding. This chapter walks through what each type actually stores, why conversions almost always allocate, where the compiler quietly skips the copy, and how the bytes package and bytes.Buffer fit in alongside the strings package and strings.Builder.
A string and a []byte both store raw bytes. The difference is in the header that wraps those bytes and in what the runtime lets you do with them.
A string is a two-word header: a pointer to a read-only backing array, and a length. There's no capacity field, because a string can never grow. The bytes it points at are immutable. The compiler can put string literals into a read-only segment of the binary, share the backing memory between equal strings, and assume nothing will overwrite those bytes behind its back.
A []byte is a three-word header: a pointer to a backing array, a length, and a capacity. The bytes it points at are mutable. You can write b[0] = 'H', you can append to grow the slice, and the runtime may move the backing array when growth exceeds capacity.
The string prints as text. The byte slice prints as the underlying numbers (the ASCII codes for A, l, i, c, e). The bytes are the same in both cases. Only the type and what you're allowed to do with it differ.
Here is what the two headers look like in memory. A string points at a fixed run of bytes with no slack at the end. A byte slice points at a backing array that may have room to grow.
The two headers are independent. There is no built-in way to "view a string as a []byte without copying" or vice versa. The language does not let you reach into a string's backing array and mutate it, because doing so would break the assumption that strings are immutable. That single rule is the source of nearly everything that follows.
The compiler rejects the assignment. The error message is cannot assign to greeting[0] (neither addressable nor a map index expression). There is no run-time check; the rule is enforced before your program runs. A []byte does not have that restriction.
This is the one-line summary of the whole chapter: if you need to change bytes, you need a []byte. If you only need to read them and pass them around, a string is cheaper and safer.
The split between string and []byte shows up in real e-commerce code constantly. The web framework hands you the HTTP request body as a []byte. Your database driver returns customer names as string. The JSON encoder produces a []byte. The configuration file reader hands you string values. Crossing between them is part of daily work.
The split is deliberate. Strings are great when you want to pass a value around without worrying about who might modify it. A map[string]int keyed by product name is safe even if many goroutines look up keys, because nobody can mutate a string from underneath the map. Byte slices are great when you're producing output, parsing input, or working with binary data where the concept of "characters" doesn't even apply.
Appending a string to a []byte with append(b, s...) is allowed and idiomatic; the compiler treats the right-hand side as a sequence of bytes. We'll come back to allocation behaviour, but notice that the string here is never mutated. The []byte is the thing being built up. Mixing the two like this is normal.
I/O is the other place where []byte shows up by necessity. The standard library's reader and writer interfaces are defined in terms of []byte:
p is a []byte because the caller is asking the reader to fill the slice in place (or the writer to drain the slice in place). A string couldn't be filled in place; it's immutable. So any time you talk to the file system, network, compression library, or HTTP layer, you're moving bytes through []byte buffers.
The conversions string(b) and []byte(s) are written like type casts, but they're not free. Each one allocates a fresh backing array and copies every byte across. The cost is O(n) in time and O(n) in allocation.
The string is unchanged. The slice has its first byte flipped. That's only possible because the conversion did not share the backing memory. If it had, mutating b[0] would have rewritten the byte the string also pointed at, and the supposedly immutable string would have flipped its first character. The whole language rests on that not happening.
The conversion the other way, string(b), has the same shape and the same reason. The runtime allocates a fresh read-only backing array, copies the bytes from b into it, and returns the string header. After the conversion, mutating b does not change the string, because they no longer share memory.
The string captured the bytes that were in b at the moment of conversion. Anything you do to b after that point can't affect s. This is exactly the guarantee that makes string safe to share, and the price you pay for it is the copy.
Cost: Every string([]byte) and []byte(string) conversion allocates and copies O(n) bytes. In a hot loop, those conversions add up to noticeable garbage and CPU time. Pick one representation for the whole pipeline when you can, and convert only at the boundary.
The same diagram from before, but now drawn around the conversion. The result has its own backing array. The original is untouched.
The boxes on the right are a fresh allocation. The boxes on the left are unchanged. Mutating one cannot affect the other. The cost of the arrow in the middle is what you're paying every time you cross this boundary.
There are a few specific patterns where the compiler can prove the copy is unnecessary and skip it. You don't write different code to get the optimization; the compiler recognizes the pattern and emits the cheap version automatically. Knowing they exist keeps you from over-engineering around conversion cost.
The first is using string(b) directly as a map key:
The compiler can do this because the lifetime of the temporary string is limited to the map lookup, and the map only needs the bytes long enough to hash and compare them. The map never stores the temporary string anywhere, so the runtime can use the slice's bytes directly.
The second is comparison against a string:
Same idea: the temporary string only exists for the duration of ==, and the compiler can compare the slice's bytes against the literal directly.
The third common case is for range over string(b):
The compiler iterates over the slice's bytes directly, decoding runes as it goes, without producing a separate copy.
These optimizations are documented behaviour and have been in the Go compiler for years. The point is not that you should rely on them aggressively, but that you don't have to twist your code to avoid string(b) in the patterns above. The natural way of writing it is also the fast way.
Cost: The compiler elides the copy for m[string(b)], string(b) == "...", and for range string(b). Outside those exact shapes, assume string([]byte) allocates and copies.
The bytes package is to []byte what the strings package is to string. The APIs deliberately mirror each other so you can apply what you already know. If you've used strings.Contains(s, "foo"), you already know how to use bytes.Contains(b, []byte("foo")).
bytes.IndexByte is worth knowing about specifically. It looks for a single byte and is heavily optimized (often using SIMD on modern CPUs). When you're scanning a large []byte for a delimiter like , or \n, prefer IndexByte over Index with a one-element pattern.
Here is a small table mapping the most common strings functions to their bytes equivalents. The parameters change from string to []byte, but the contract is otherwise the same.
strings | bytes | Purpose |
|---|---|---|
strings.Contains | bytes.Contains | Does the haystack contain the needle? |
strings.HasPrefix | bytes.HasPrefix | Does it start with the given prefix? |
strings.HasSuffix | bytes.HasSuffix | Does it end with the given suffix? |
strings.Index | bytes.Index | First index of needle, or -1 |
strings.IndexByte | bytes.IndexByte | First index of a single byte, or -1 |
strings.Split | bytes.Split | Split on a separator |
strings.Join | bytes.Join | Join elements with a separator |
strings.ToLower | bytes.ToLower | Lowercase a copy |
strings.ToUpper | bytes.ToUpper | Uppercase a copy |
strings.TrimSpace | bytes.TrimSpace | Trim leading/trailing whitespace |
strings.Trim | bytes.Trim | Trim a given set of bytes |
strings.Replace | bytes.Replace | Replace first N occurrences |
strings.ReplaceAll | bytes.ReplaceAll | Replace every occurrence |
The shape is the same. Once you've internalized strings, the bytes package costs you nothing to learn.
Here's a small but realistic example: reading an export of customer rows as a []byte and walking it line by line without ever converting the whole thing to a string.
Notice the two string(...) conversions only happen at the print site. Throughout the parsing, we stay in []byte. The split, the index-of-comma, and the slicing are all cheap operations on the original backing array. We pay the copy cost only when we need a string for the actual output.
Cost: bytes.Split allocates a slice of sub-slices, but each sub-slice shares the original backing array, so the per-line cost is just a header. If you want to keep one of those sub-slices past the next read into the same buffer, you have to copy it; otherwise the next read overwrites the underlying bytes.
bytes.Buffer is a growable byte buffer that the standard library uses everywhere. The zero value is ready to use, and it implements both io.Reader and io.Writer, which means you can pass it to anything that expects either interface. That's the headline feature.
fmt.Fprintf writes into anything that satisfies io.Writer. Because *bytes.Buffer does, we can use it as the destination for formatted output without ever allocating intermediate strings. WriteString writes a string into the buffer's internal []byte directly, without round-tripping through []byte(s). The buffer takes care of growing the underlying array as needed.
The methods you'll use most often:
| Method | What it does |
|---|---|
Write(p []byte) | Append bytes from p to the buffer |
WriteString(s) | Append the bytes of s to the buffer |
WriteByte(c) | Append a single byte |
WriteRune(r) | Append a rune as its UTF-8 encoding |
Bytes() | Return the buffer's underlying []byte (no copy) |
String() | Return the buffer's contents as a string (copies) |
Len() | Number of unread bytes in the buffer |
Cap() | Capacity of the underlying []byte |
Reset() | Clear the buffer but keep the underlying array |
Grow(n) | Ensure capacity for n more bytes without reallocating |
Read(p []byte) | Consume bytes from the front of the buffer |
Bytes() is worth pausing on. It returns the buffer's backing slice without copying, which is fast but comes with a sharp edge: if you later write to the buffer or call Reset(), the slice you held onto may be overwritten or invalidated. The rule is "use it immediately, or copy it before the next buffer operation".
The second WriteString may have written into the same backing array view was pointing at (no reallocation needed if there was capacity), or it may have allocated a fresh backing array (if the write exceeded capacity). Either way, the safe rule is don't hold onto Bytes() across writes. If you need a stable copy, call []byte(buf.String()) or append([]byte(nil), buf.Bytes()...).
Cost: Bytes() is a slice header, not a copy. String() does copy. If you only need a stable view that outlives the next write, use String() or copy Bytes() explicitly.
A bytes.Buffer is also a real io.Reader. You can pre-fill it and hand it to anything that consumes from a reader, which is useful for testing or building request bodies.
bytes.NewBufferString wraps an existing string into a buffer you can read from. io.ReadAll consumes it until EOF. No goroutines, no files, no network, but the same io.Reader interface that the HTTP client and the file system speak.
The zero value is one of the best parts. You don't need a constructor for the common case:
No make, no new, no constructor. This is a deliberate design choice that runs through the standard library: types like sync.Mutex, bytes.Buffer, and strings.Builder all behave correctly from their zero value so you can declare and use them without ceremony.
Both strings.Builder and bytes.Buffer exist to solve "I want to build up a sequence of bytes piece by piece without reallocating on every concat." They're close cousins, and you can usually pick by asking one question: do you only ever need a string at the end?
If yes, use strings.Builder. It's optimized for that one job. It accumulates bytes internally and hands you a string via .String() without copying the buffer's contents into a new array (the builder type is designed to "release" its internal slice as a string at the end, which is one of the rare places the standard library is allowed to skip the copy because the builder will never write to the slice again).
If no, because you need to read the bytes back, hand them to an io.Reader-consuming function, or write to an io.Writer-accepting function, use bytes.Buffer.
Here are the two side by side on the same task.
Same output, different machinery underneath. The builder is the lighter option for "produce a string". The buffer is the more capable option for "produce something I'll then pass around as an io.Reader, io.Writer, or []byte".
The table below summarizes the differences worth keeping in your head.
| Aspect | strings.Builder | bytes.Buffer |
|---|---|---|
| Produces | string (via .String()) | []byte (via .Bytes()) or string (via .String()) |
WriteString | Yes | Yes |
Write ([]byte) | Yes | Yes |
WriteByte, WriteRune | Yes | Yes |
| Can read back | No | Yes (Read, ReadByte, Next) |
Implements io.Reader | No | Yes |
Implements io.Writer | Yes | Yes |
| Pre-size method | Grow(n) | Grow(n) |
| Zero-value usable | Yes | Yes |
| Copy-safe | No (must not be copied after first use) | No (must not be copied after first use) |
| Reset and reuse | Reset() | Reset() |
Both types refuse to be copied after first use. strings.Builder enforces this with a runtime check (strings: illegal use of non-zero Builder copied by value). bytes.Buffer doesn't check, but copying it gives you two buffers pointing at the same backing array, which leads to undefined-feeling behaviour. Use pointers (*bytes.Buffer, *strings.Builder) when passing them between functions.
Cost: Both types amortize append to O(1). The internal slice doubles on growth, just like a normal append. Pre-size with Grow(n) when you know the final size to avoid the doubling steps entirely.
A rough decision guide, in the order you'd actually ask the questions:
[]byte (or a bytes.Buffer if you'll be doing it incrementally).[]byte. The standard library reader and writer interfaces work in []byte.string. It's immutable, cheap to compare, and safe to share across goroutines.string from many parts? Use strings.Builder.io.Reader consumer will read? Use bytes.Buffer.[]byte and need to look up a value in a map[string]V? Just write m[string(b)]; the compiler elides the copy.A worked example tying these together: reading a customer export, parsing it line by line, and producing a transformed export. The input arrives as []byte (because that's what os.ReadFile returns), we work in []byte end to end (because we're doing one transformation per line), and we use a bytes.Buffer to accumulate the output (because the next stage will hand it off to an io.Writer).
Notice we never convert the input or the per-line slices to string. We only string() the final output once, at the print site. If the next stage of this pipeline were http.ResponseWriter.Write, we wouldn't even do that, because http.ResponseWriter.Write takes []byte directly.
There is one trade-off worth flagging. bytes.ToLower(email) allocates a new []byte. So does bytes.ToUpper(city). The buffer growth is amortized away by Grow, but the case-conversion allocations are real. For an export of a few thousand rows, you won't notice. For an export of tens of millions, you'd write the case conversion in-place into the buffer to avoid the per-line allocation. The point of this lesson is not to micro-optimize, but to recognize that picking []byte over string lets you do that kind of optimization when you need it. With string, you couldn't write in place; every transformation forces an allocation.
Cost: bytes.ToLower and bytes.ToUpper always allocate a new slice. The all-string equivalent, strings.ToLower, allocates a new string for every call. Neither version is in-place. If you have a large input and a hot loop, write the case conversion yourself into a pre-allocated []byte.
This is the single most common bug at the boundary. You convert a []byte to a string, mutate the []byte, and assume the string changed too. It doesn't. The conversion copied the bytes.
Reverse direction has the same property. You convert a string to a []byte, mutate the []byte, and the string is unchanged. The conversion copied the bytes.
This independence is the whole point of the conversion's cost. Once you've crossed the boundary, the two values are unrelated. Bugs happen when someone thinks the cost-free, share-the-bytes version is what's happening. It isn't, and it can't be, because that would break immutability.
string is an immutable, two-word header (pointer, length) over a read-only backing array. A []byte is a mutable, three-word header (pointer, length, capacity) over a writable, growable backing array.string(b) and []byte(s) allocate a fresh backing array and copy the bytes. The cost is O(n) every time. The copy is the price you pay for immutability of strings; without it, a []byte mutation could change a "constant" string.m[string(b)], string(b) == "...", and for range string(b). You don't write different code to get this; the optimization is automatic.bytes package mirrors the strings package. Once you know one API shape, you know both. bytes.IndexByte is the single-byte search to reach for over bytes.Index when you have a one-byte needle.bytes.Buffer is a growable byte buffer that implements io.Reader and io.Writer. The zero value is ready to use. Bytes() returns the backing slice without copying, so don't hold onto it across writes; String() copies.strings.Builder when you only need a final string. Use bytes.Buffer when you need to integrate with io.Reader or io.Writer, or when you need to read back what you wrote.[]byte after converting to a string does not change the string, and mutating a []byte derived from a string does not change the string. The conversion is the cut-off point.The next chapter, Regular Expressions, looks at Go's regexp package and how it works against both string and []byte inputs without forcing you to convert.