What Is the Difference Between string and []byte in Go

string is immutable text data, while []byte is a mutable slice of raw bytes used for binary data or modification.

When data needs to change

You are building a parser for a log file. You read the content, want to strip a trailing newline, and look up a value in a map. You grab the file as a string, try to delete the newline, and the compiler blocks you. You switch to []byte, modify the data, and pass it to a function that expects a string. The program runs, but your memory profiler shows a spike. You just paid the copy tax twice.

The difference between string and []byte defines how Go handles data flow. One promises stability. The other promises control. Picking the wrong type leads to compile errors, hidden allocations, or runtime panics.

The contract: immutable versus mutable

A string in Go is an immutable sequence of bytes. Once created, the bytes inside a string cannot change. A []byte is a mutable slice of bytes. You can read, write, and resize it.

Think of a string as a printed page. You can read it, pass it around, and hash it, but you cannot take a pen and scribble on the text. A byte slice is a whiteboard. You can write, erase, and change the content in place.

Both types share the same underlying structure in memory. A string header contains a pointer to the data and the length. A slice header contains a pointer, length, and capacity. The difference is the rule the compiler enforces. Strings guarantee that the data will never change. Byte slices allow modification.

Minimal example

Here is the distinction in action. You can read a string by index, but you cannot write to it. A byte slice lets you change the content directly.

package main

import "fmt"

func main() {
    // String is immutable. Indexing returns the byte value at that position.
    s := "Go"
    fmt.Println(s[0]) // prints 71 (ASCII for 'G')

    // Slice is mutable. You can assign to an index to change the data.
    b := []byte("Go")
    b[0] = 'J'
    fmt.Println(string(b)) // prints "Jo"
}

Strings are values. Slices are views.

The copy tax

Converting between string and []byte always copies the data. The compiler prevents aliasing between a string and a slice that points to the same memory.

If you convert a string to a []byte, Go allocates a new buffer and copies the bytes. If you convert a []byte to a string, Go allocates a new string header and copies the bytes. This copy is intentional. It preserves the immutability guarantee. If Go allowed a string and a slice to share memory, modifying the slice would silently corrupt the string, breaking the contract.

The copy is the price of immutability. Pay it at the boundary.

Memory layout and performance

Both types are small headers. On a 64-bit system, a string header is 16 bytes. A slice header is 24 bytes. When you pass a string to a function, you copy the header. The underlying data is not copied. This makes passing strings cheap. The data is shared read-only.

When you pass a []byte, you copy the slice header. The underlying array is shared. If the function modifies the slice content, the caller sees the change. This is why []byte is powerful for buffers and dangerous if you do not track ownership.

Passing a string is safe and fast. Passing a slice is fast but requires discipline.

Realistic usage: I/O and processing

Real code deals with I/O. Files, network sockets, and standard input return []byte. The standard library provides parallel packages: bytes for slices and strings for strings. Pick the package that matches your data type to avoid unnecessary copies.

Here is a pattern that stays in []byte during processing and converts only at the end.

package main

import (
    "bytes"
    "fmt"
)

func main() {
    // I/O operations return []byte. Process with bytes package to avoid copies.
    data := []byte("hello world\n")

    // bytes.TrimSuffix returns a subslice pointing to the same array.
    // No allocation occurs.
    cleaned := bytes.TrimSuffix(data, []byte("\n"))
    fmt.Println(string(cleaned)) // prints "hello world"

    // Building output incrementally? Use bytes.Buffer.
    // It manages the underlying slice and grows efficiently.
    var buf bytes.Buffer
    buf.Write(cleaned)
    buf.WriteString(" processed")
    result := buf.Bytes() // Returns []byte, no copy.
    fmt.Println(string(result)) // prints "hello world processed"
}

The bytes package mirrors the strings package but operates on slices. Use bytes.Buffer to build large outputs. String concatenation in a loop creates a new string every iteration, leading to quadratic allocation costs. bytes.Buffer avoids this by reusing the underlying slice.

Use bytes.Buffer for building. Use string concatenation only for small, fixed cases.

Pitfalls and compiler errors

The biggest trap is treating a string index as a character index. Go strings are UTF-8 encoded. Indexing a string returns a byte, not a Unicode code point. If your string contains multi-byte characters, s[0] might be just the first byte of a character.

If you try to assign to a string index, the compiler rejects it with cannot assign to s[0] (strings are immutable).

Another common error involves map keys. Slices cannot be map keys. The compiler rejects map[[]byte]string with invalid map key type []byte. This is because slice equality is not well-defined for hashing purposes due to capacity and potential aliasing. Strings are comparable. Two strings with the same content are equal. This makes strings the natural choice for keys in maps and sets.

Slices can't be map keys. Convert to string if you need a key.

UTF-8 and binary data

Go strings are not "text" in the abstract sense. They are bytes. The language assumes UTF-8, but the compiler does not enforce it. A string can contain invalid UTF-8. Functions in the strings package assume valid UTF-8. If you pass invalid UTF-8, behavior varies. Some functions treat the data as raw bytes. Others may panic or return unexpected results.

[]byte has no assumptions. It is raw data. This makes []byte the right choice for binary protocols, images, compressed data, or any payload where UTF-8 is not guaranteed.

The community convention is clear: use string for text, []byte for binary. If a function takes text, take a string. If it takes binary data, take a []byte. Do not take []byte just because you might modify it later. Take the type that matches the semantic meaning.

Indexing a string gives you bytes, not characters. Range to get runes.

Decision matrix

Use string when you need a map key or set element, because strings are comparable and slices are not.

Use string when the data represents text and crosses API boundaries, because the type signals immutability and prevents accidental modification.

Use []byte when reading from or writing to I/O, because files and network sockets operate on byte slices.

Use []byte when modifying content in place, because strings are immutable and every change requires a new allocation.

Use []byte when building large outputs incrementally, because bytes.Buffer or slice appends avoid the overhead of repeated string concatenation.

Where to go next