1. Why []byte and []rune exist
When someone first learns that a string can be converted to a slice, a tempting thought appears: “oh, now I can finally change a string by index!”. And then Go calmly replies: “you can, but not directly”. Strings in Go are immutable: this helps performance, safety, and your future peace of mind.
So any “string modification” is done by creating a new string. And to conveniently change the contents, we usually temporarily convert the string into a more suitable form:
- []byte — when we want to work with bytes (often this is about ASCII characters, tabs, newlines, protocols, “raw” I/O, binary data).
- []rune — when we want to work with Unicode characters (i.e., “human-style”: letters, including Cyrillic, Chinese ideographs, etc.).
The key reason this matters is that string indexing s[i] works on bytes and returns a byte (essentially a uint8), not a “character”.
2. Data model and copying during conversion
Before writing code, it helps to build a mental picture. Imagine that a Go string is a box of bytes, and UTF‑8 is the set of rules by which some characters occupy multiple bytes.
Conversion diagram
Here is a diagram in Mermaid format:
flowchart LR
S["string (UTF-8 bytes)"]
B["[]byte (bytes)"]
R["[]rune (characters / code points)"]
S -->|"[]byte(s)"| B
S -->|"[]rune(s)"| R
B -->|"string(b)"| S2["string (interpret bytes as UTF-8)"]
R -->|"string(r)"| S3["string (encode runes as UTF-8)"]
note1["Each conversion creates a new value\n(data copy)"]
S -.-> note1
B -.-> note1
R -.-> note1
Important: conversions create new values. This is not a “view” of the same data; it is a new slice/string.
Why changes to []byte(s) do not change the original string
Beginners often want to think like this: “if I do b := []byte(s), then b kind of points to the string, and if I change b, then s will change too”. That is not true.
The correct mental model is: the conversion creates a new value, and the string stays the same.
package main
import "fmt"
func main() {
s := "go"
b := []byte(s)
b[0] = 'G'
fmt.Println(s) // go
fmt.Println(string(b)) // Go
}
This is also one reason strings in Go can be passed around freely: they are immutable, and nobody can corrupt them “behind your back”.
3. Working with bytes: []byte(s)
If you treat a string as a sequence of bytes (for example, you want to replace "\t" with a space or remove "\r"), then []byte is your friend. Bytes are easy to change “in place”: b[i] = ..., you can use append, and you can slice.
Let’s start with a micro-diagnosis of types and values. Notice that byte is the range 0..255, i.e., essentially uint8.
package main
import "fmt"
func main() {
s := "Go"
b := []byte(s)
fmt.Printf("%T %v\n", b[0], b[0]) // uint8 71
fmt.Printf("%T %v\n", b[1], b[1]) // uint8 111
}
Here 71 and 111 are byte codes ('G' and 'o' in ASCII). As long as the string is ASCII-only, everything looks very “intuitive”, but it is a trap: in UTF‑8, far from all characters fit into a single byte.
The “modify a string” pattern via []byte
The most common practical scenario: a string came from input, it contains “technical” characters, and we want to normalize it. Let’s take a small step toward our learning mini-app (let’s call it textlab): it will take a string and make it look cleaner.
For example, replace all tabs "\t" with a single space. This is a byte-level operation: a tab is one byte, and a space is one byte.
package main
import "fmt"
func replaceTabsASCII(s string) string {
b := []byte(s)
for i := 0; i < len(b); i++ {
if b[i] == '\t' {
b[i] = ' '
}
}
return string(b)
}
func main() {
fmt.Println(replaceTabsASCII("A\tB\tC")) // A B C
}
What is important to notice here?
First, we did not try to do s[i] = ... — you cannot do that; strings are immutable.
Second, string(b) creates a new string from the byte slice.
Third, this approach is correct as long as we change exactly “technical” ASCII bytes ("\t", "\n", "\r", space). For letters in Cyrillic (or any other non-ASCII text), doing it this way is dangerous: a character can be multi-byte, and it is very easy to “hit the middle of a character”.
“Build bytes”, then do string(b)
Sometimes the input string is large, and you want to assemble a new result from pieces. You can do that via append into a []byte. This is still a “manual” style without special builders—we will use them later, but for now it is useful to feel the mechanics.
Let textlab add a prefix and suffix to a string:
package main
import "fmt"
func wrapWithBrackets(s string) string {
var b []byte
b = append(b, '[')
b = append(b, s...) // the string expands into bytes
b = append(b, ']')
return string(b)
}
func main() {
fmt.Println(wrapWithBrackets("go")) // [go]
fmt.Println(wrapWithBrackets("世界")) // [世界]
}
Here is the important point: append(b, s...) adds UTF‑8 bytes. That is fine: we are not breaking UTF‑8 because we add the whole string as-is, byte-for-byte. We are not trying to replace anything “inside” characters.
4. Working with runes: []rune(s)
Why []byte is dangerous for “letters”
A very typical situation: you want to make a string “pretty”—for example, capitalize the first letter. In English, a byte trick sometimes works, but on real-world text (Cyrillic, Japanese, emoji) it breaks quickly.
To feel the problem, remember: character literals in single quotes have type rune by default (an alias for int32). And byte is uint8, so it cannot hold large values.
Here is an example that shows why “put a non-ASCII character into byte” is a bad idea (this code does not compile, and that is normal):
package main
func main() {
// var x byte = '世' // not allowed: '世' does not fit into byte (0..255)
}
So if you need to work with “letters/characters”, you need the rune level.
[]rune(s) and safe “per-character” operations
[]rune(s) does an important thing: it takes a UTF‑8 string and decodes it into a sequence of “characters” (more precisely, Unicode code points). This makes life much easier when you need “the first character”, “the last character”, “the third character”, or replace one character with another without risking UTF‑8 corruption.
A mini example: replace the first character with another—specifically as a character, not as a byte.
package main
import "fmt"
func main() {
s := "世界"
r := []rune(s)
r[0] = '你'
fmt.Println(string(r)) // 你界
}
Here r[0] is the first rune, i.e., the first character, even if it took multiple bytes in UTF‑8.
A textlab pattern: replace the first character
Let’s make a function for our mini-app: “if the string is not empty, replace the first character with the specified one”. This is a toy function, but it perfectly reinforces the idea: work with text via runes.
package main
import "fmt"
func replaceFirstRune(s string, newFirst rune) string {
r := []rune(s)
if len(r) == 0 {
return s
}
r[0] = newFirst
return string(r)
}
func main() {
fmt.Println(replaceFirstRune("hello", 'H')) // Hello
fmt.Println(replaceFirstRune("世界", '你')) // 你界
}
Note the check len(r) == 0. This is not “paranoia”; it is normal carefulness: an empty string is an absolutely normal case in real programs.
5. Combining approaches in textlab
Sometimes students try to memorize “which tasks need []byte and which need []rune” like a spell. It is much more useful to understand the meaning: you choose the data representation to match the operation.
How to choose a representation
| What you are doing | What is more convenient | Why |
|---|---|---|
| Removing/replacing "\r", "\n", "\t", trimming spaces at the edges/inside (as bytes) | []byte | These are control ASCII bytes; they are easy and safe to manipulate at the byte level |
| Adding pieces to a string, assembling a result from fragments | []byte (or later special builders) | It is convenient to accumulate bytes and convert to a string once |
| Wanting “first/last character”, letter replacement, working with Cyrillic/Unicode | []rune | You need characters, not UTF‑8 bytes |
| Wanting “length in characters” (simplified) | []rune or range | len(s) gives bytes, not characters |
No magic here: if the operation is “character-level”, use runes; if it is “technical byte-level”, use bytes.
Mini version of textlab
Now let’s assemble everything into a small but complete example: the program reads a string (for now, directly in code), replaces tabs with spaces (byte-level normalization), and then replaces the first character with a specified one (character-level operation). This is a good example of the fact that sometimes both representations make sense in one pipeline.
package main
import "fmt"
func replaceTabsASCII(s string) string {
b := []byte(s)
for i := 0; i < len(b); i++ {
if b[i] == '\t' {
b[i] = ' '
}
}
return string(b)
}
func replaceFirstRune(s string, newFirst rune) string {
r := []rune(s)
if len(r) == 0 {
return s
}
r[0] = newFirst
return string(r)
}
func main() {
s := "\t世界\tgo"
s = replaceTabsASCII(s)
s = replaceFirstRune(s, '>')
fmt.Println(s) // >世界 go
}
Here you can see a “grown-up code” trick: we do not choose “one correct type forever”. We choose the type at each step so that the specific operation is simple and safe.
6. Common mistakes
Error #1: trying to change a string by index (s[i] = ...).
Strings in Go are immutable, and the compiler will not let you do that. The correct path is to create a new value: either by concatenating substrings, or via []byte/[]rune and converting back to string.
Error #2: using []byte for “character-level” operations on Unicode text.
On ASCII it sometimes works by accident, which makes the bug more insidious: it looks like “everything is fine”. But as soon as Cyrillic or any multi-byte character appears, byte indexes stop matching character positions, and you can corrupt UTF‑8. If you think in “letters/characters”, use []rune.
Error #3: expecting that changing b := []byte(s) will change the original string s.
The conversion creates a new value: the string stays the same. So after changing bytes or runes, you must explicitly build a new string via string(b) or string(r). If you forget this step in code, you will be modifying “the wrong thing” and wonder why the result does not change.
Error #4: trying to put a non-ASCII character into byte.
byte is uint8 (0..255). Many Unicode characters are greater than 255, so expressions like var x byte = '世' do not compile. This is not Go being picky; it is protection from silent errors. For characters, use rune, which by default is essentially int32.
GO TO FULL VERSION