How to Convert String to Byte Slice in Go

In Go, converting a string to a byte slice is straightforward because strings are internally represented as UTF-8 encoded byte sequences.

The copy you didn't know you made

You read a configuration file. You grab a header value. You need to pass it to a C library that expects a raw memory pointer. Or maybe you are writing a custom serializer and need to mutate the payload before sending it over the wire. In Go, the bridge between a string and a mutable sequence of bytes is a single type conversion. It looks trivial. It hides a deliberate design choice about memory safety and immutability.

Strings are frozen byte arrays

Go strings are not character arrays. They are immutable sequences of bytes, guaranteed to be valid UTF-8. Under the hood, a string is a small struct containing a pointer to a byte array and a length. The compiler treats that pointer as read-only. You cannot change the bytes inside a string. If you need to change them, you convert the string to a []byte. That conversion does not hand you a view into the original string. It allocates a new slice and copies every byte. The original string stays frozen. The new slice is yours to mutate.

This design prevents accidental corruption. If multiple parts of your program hold references to the same string, changing one would break the others. Go avoids that trap by making the copy explicit. The language forces you to acknowledge that you are creating a new piece of memory. You pay for the copy once, and you get predictable behavior everywhere else.

Strings are cheap to pass around. The header is just two machine words. Passing a string by value copies the pointer and length, not the backing data. The community convention follows this reality: never pass a *string. Adding a pointer adds indirection without saving memory. Trust the value semantics. Let the compiler handle the small header copy.

The one-line conversion

Here is the standard conversion. It takes a string, casts it to a byte slice, and shows that mutation stays isolated.

package main

import "fmt"

func main() {
    // Start with an immutable string literal
    original := "Hello, Go!"

    // Cast to []byte allocates a new slice and copies the data
    mutable := []byte(original)

    // Modify the first byte in the new slice
    mutable[0] = 'h'

    // The slice changed, the string did not
    fmt.Printf("Slice: %s\n", string(mutable))
    fmt.Printf("String: %s\n", original)
}

Walk through the runtime behavior. The compiler sees []byte(original) and emits code to allocate a slice header on the stack, allocate a backing array, and copy the bytes from the string's backing store. The len field matches the byte count. When you assign 'h' to mutable[0], you are writing to the copied array. The original string's backing array remains untouched. Converting back with string(mutable) repeats the process: allocate, copy, freeze.

The compiler performs escape analysis on this allocation. If the slice does not outlive the function, the backing array lives on the stack. If you return the slice or store it in a global variable, it escapes to the heap. You do not need to manage this manually. The compiler decides based on lifetime. The copy happens regardless.

Strings are frozen. Slices are mutable. The copy is the feature, not the bug.

When bytes and runes diverge

The copy behavior matters most when you deal with non-ASCII text. UTF-8 encodes characters using one to four bytes. The letter é takes two bytes. The emoji 🚀 takes four. Go's len() function counts bytes, not characters. If you treat a byte slice as a character array, you will split multi-byte sequences in half and produce garbage output.

Here is how you handle a string with extended characters while tracking both byte and character boundaries.

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    // A string containing a multi-byte character
    text := "Café"

    // Convert to bytes for low-level processing
    data := []byte(text)

    // len counts bytes, not visible characters
    fmt.Printf("Bytes: %d\n", len(data))

    // utf8.RuneCountInString counts actual Unicode code points
    fmt.Printf("Runes: %d\n", utf8.RuneCountInString(text))

    // Iterate safely over runes instead of raw bytes
    for i, r := range text {
        fmt.Printf("Index %d: rune %c (%d bytes)\n", i, r, utf8.RuneLen(r))
    }
}

The for i, r := range text loop is the Go idiom for character iteration. The compiler automatically decodes UTF-8 sequences, giving you the byte index and the decoded rune. You rarely need to manually step through a []byte unless you are writing a parser, a compression algorithm, or an encryption routine. For everyday text processing, stick to strings and use the unicode/utf8 package when you need validation or counting.

There is a boundary convention you should respect. Go strings are guaranteed to be valid UTF-8 at the language level. If you read raw bytes from a network socket or a file and convert them to a string with string(rawBytes), the compiler will not check the encoding. You get a string containing invalid UTF-8. Functions that expect valid UTF-8 might panic or return unexpected results. Validate encoding only when you cross the boundary between raw bytes and Go strings. Use utf8.ValidString() or utf8.Valid() at the ingress point. Do not validate on every internal call.

Measure your character counts with runes. Count your buffer sizes with bytes. Keep the two concepts separate.

Memory, allocation, and the compiler

The conversion is safe, but it carries a memory cost. Every []byte(s) call triggers an allocation. If you run that conversion inside a tight loop processing millions of lines, the garbage collector will wake up constantly. The compiler will not stop you. It will not emit a warning for unnecessary allocations. You have to measure it.

If you try to modify a string directly, the compiler rejects the program with cannot assign to s[0]. Strings are read-only by design. If you try to pass a string to a function expecting a []byte, the compiler complains with cannot use s (variable of type string) as []byte value in argument. Go does not perform implicit conversions between the two types. The cast must be explicit.

There is a performance trap when you convert back and forth repeatedly. string([]byte(s)) does two allocations and two copies. If you are building a response body, accumulate bytes in a bytes.Buffer or a pre-allocated []byte slice, then convert once at the end. The strings.Builder type exists for the opposite direction: accumulate string fragments without repeated allocations, then call String() once. Pick the accumulator that matches your input type.

Advanced code sometimes reaches for unsafe.String() or unsafe.SliceData() to avoid the copy. These functions create a string or slice that shares memory with the original backing array. You bypass the allocator entirely. You also bypass the immutability guarantee. If the original data changes, your string changes. If the original data is garbage collected, your string points to freed memory. The compiler cannot protect you here. Reserve unsafe for zero-copy bridges to C libraries or performance-critical hot paths where you have profiled the allocation cost and verified the lifetime manually.

Profile before you optimize. The copy is cheap for small strings. It becomes expensive only at scale.

Picking the right tool

Use []byte(s) when you need to mutate the data or pass it to a function that requires a mutable buffer. Use string(b) when you need to freeze a byte slice for display, logging, or storage in a map key. Use for i, r := range s when you need to iterate over visible characters instead of raw bytes. Use utf8.RuneCountInString(s) when you need an accurate character count for UI layout or validation limits. Use strings.Builder when you are concatenating many string fragments and want to avoid repeated allocations. Use bytes.Buffer when you are concatenating many byte slices and want to avoid repeated allocations. Use unsafe.String() only when you are writing zero-copy bridges to C libraries and accept the risk of memory corruption.

Trust the copy. Measure the loop. Keep your boundaries clean.

Where to go next