JavaRush /Courses /Go SELF /Conversions of []byte and []rune and when they are appr...

Conversions of []byte and []rune and when they are appropriate

Go SELF
Level 14, Lesson 2
Available

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.

1
Task
Go SELF, level 14, lesson 2
Locked
Underscore Decoder
Underscore Decoder
1
Task
Go SELF, level 14, lesson 2
Locked
Angle brackets
Angle brackets
1
Task
Go SELF, level 14, lesson 2
Locked
First rune
First rune
1
Task
Go SELF, level 14, lesson 2
Locked
Mirror Text
Mirror Text
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION