String Conversions in Go
How to convert strings to bytes, runes, numbers and back using Go's built-in syntax and the strconv package, with common pitfalls and best practices.
Go programs regularly move data between strings, numeric types, and raw byte sequences—reading a file, formatting a number for display, or parsing a configuration value. All of these transitions require explicit string conversions, because Go never silently turns a string into an int or a float64 into a []byte.
This document covers every string conversion mechanism you’ll encounter: the built-in conversion syntax T(x) for strings, byte slices, and rune slices, and the strconv package for numbers and booleans. By the end you’ll be able to choose the right tool without accidentally turning 65 into the character 'A'.
Converting Between Strings and Byte Slices
A string in Go is an immutable sequence of bytes. A []byte is a mutable slice of bytes. Converting between them copies the data, producing a new backing array each time. The syntax is the same as any other type conversion:
s := "hello"
b := []byte(s) // string → byte slice
s2 := string(b) // byte slice → string
Why would you convert back and forth? Strings are read-only, so you cannot change a character in place. To modify text—replace a substring, strip whitespace, apply an encoding—you convert to []byte, operate on the mutable slice, and convert back. Many standard library functions that work with raw bytes (like io.Writer.Write) also accept []byte but not string, so you must convert.
Performance note:
Each conversion allocates fresh memory and copies every byte. Looping over thousands of conversions in a tight loop can create noticeable garbage. In those cases you might keep data as []byte for as long as possible and convert to a string only at the final boundary.
When you print the resulting string, it reproduces exactly the same sequence of bytes—no encoding change happens:
b := []byte{72, 101, 108, 108, 111}
s := string(b)
fmt.Println(s) // Hello
The same holds in reverse; a UTF-8 string becomes a byte slice where each element is one byte of the UTF-8 encoding, not necessarily one character.
Converting Between Strings and Rune Slices
A rune in Go represents a Unicode code point, stored as an int32. A string is a sequence of bytes, and when those bytes are valid UTF-8, the string logically represents a sequence of runes. Converting between them decodes or encodes the UTF-8.
s := "a日"
rs := []rune(s) // string → rune slice
s2 := string(rs) // rune slice → string
The slice rs will contain two elements: 97 (the rune for 'a') and 26085 (the rune for '日'). The conversion reads the UTF-8 bytes, decodes them, and stores full 32-bit code points.
You need this conversion when you must handle characters individually—counting the number of “characters” a human sees, iterating over text regardless of byte length, or manipulating emoji. Directly indexing a string (s[i]) gives you a single byte, not a character. Converting to []rune lets you index by logical character.
Size difference:
A []rune is almost always larger than the original string in memory, because most English characters occupy one byte in UTF-8 but four bytes as an int32. If you only need to iterate over runes without storing them, use a for range loop over the string instead of converting the whole thing.
Integer-to-String Conversion: What string(65) Actually Does
The built-in syntax string(x) where x is an integer type produces a string containing a single Unicode code point, not a decimal representation. This is the single most misunderstood conversion in Go.
fmt.Println(string(65)) // A
fmt.Println(string(-1)) // � (U+FFFD replacement character)
string(65) interprets 65 as the code point for the letter 'A' and encodes it as a UTF-8 byte sequence. If the integer is not a valid code point—negative or out of the Unicode range—the result is the Unicode replacement character \ufffd.
Common pitfall:
Developers coming from languages where string(65) yields "65" are surprised to see "A" instead. If you want the decimal representation, use strconv.Itoa or strconv.FormatInt. Never rely on string(n) to produce a numeric string.
To turn an integer into its decimal string representation, you must use the strconv package:
import "strconv"
s := strconv.Itoa(65) // "65"
Itoa is shorthand for FormatInt(int64(65), 10) and always produces the base-10 representation. For unsigned integers or non-decimal bases, use FormatUint.
Converting Numbers to Strings with strconv
The strconv package is the designated tool for formatting numeric values as human-readable strings. Every function takes the numeric value and formatting parameters, returns a string, and—unlike the integer-to-string conversion—represents the number’s value, not a Unicode code point.
Integers
s := strconv.FormatInt(-42, 10) // "-42"
s = strconv.FormatInt(255, 16) // "ff"
s = strconv.FormatUint(15, 2) // "1111"
The second argument is the base, which must be between 2 and 36. Itoa is a convenience wrapper for the most common case: FormatInt(int64(i), 10).
Floating-point numbers
s := strconv.FormatFloat(3.14159, 'f', 2, 64) // "3.14"
s = strconv.FormatFloat(1e9, 'e', -1, 64) // "1e+09"
s = strconv.FormatFloat(1e9, 'g', -1, 64) // "1e+09"
The fmt byte controls the format: 'f' for decimal with no exponent, 'e' for scientific notation, 'g' for the shorter of the two. The third argument is precision (number of digits after the decimal point for 'f'; total significant digits for 'e' and 'g'; -1 uses the smallest number of digits necessary). The last argument, bitSize, should be 32 if you are formatting a float32 and 64 for float64; it ensures the output accurately represents the original precision.
Booleans
s := strconv.FormatBool(true) // "true"
s = strconv.FormatBool(false) // "false"
Safe formatting:
FormatFloat, FormatInt, and FormatBool never fail. They always return a string, so you can use them directly in string concatenation or logging without checking errors.
Parsing Strings into Numeric Types
The reverse direction—turning a string into a number—always carries the risk that the string is not a valid number. Every parsing function in strconv returns a value and an error, and you must handle that error.
Parsing integers
i, err := strconv.Atoi("42")
if err != nil {
// handle malformed input
}
fmt.Println(i) // 42
Atoi is a shortcut for ParseInt(s, 10, 0). ParseInt gives you more control:
i64, err := strconv.ParseInt("ff", 16, 64) // base 16, fit in int64
The bitSize argument (0, 8, 16, 32, 64) tells the parser the target integer width. If the parsed value doesn't fit in the specified size, ParseInt returns an error and the nearest boundary value. For Atoi, a bitSize of 0 means “use the architecture-dependent int size.”
Parsing floats
f, err := strconv.ParseFloat("3.14", 64) // returns float64
ParseFloat always returns a float64. If you need a float32, convert afterward: float32(f). The second argument is the bit size used only for range validation; 64 accepts the full float64 range, 32 rejects numbers that exceed the float32 range. The function accepts both decimal and scientific notation.
Parsing booleans
b, err := strconv.ParseBool("true") // true
b, err = strconv.ParseBool("1") // true
b, err = strconv.ParseBool("TRUE") // true
b, err = strconv.ParseBool("f") // false
b, err = strconv.ParseBool("yes") // error
ParseBool accepts 1, t, T, true, TRUE, True, and the equivalent falsy strings. Anything else returns an error.
Don't ignore errors:
Discarding the error value—for example, val, _ := strconv.Atoi(s)—hides malformed input. If s is "12abc", Atoi returns 0 (the zero value) and an error. Without checking, you would silently treat invalid data as zero, which is a common source of subtle bugs in configuration parsing and API handlers.
Custom Types and the String() Method
When you define your own type, you might want string(myVal) to produce a custom representation. Go does not allow you to override the built-in conversion behavior.
type myint int
func (m myint) String() string {
return fmt.Sprintf("custom:%d", m)
}
func main() {
var v myint = 10
fmt.Println(v) // custom:10 (fmt respects the Stringer interface)
fmt.Println(string(v)) // "\n" (not what you expect)
}
The conversion string(v) treats v as an integer and interprets it as a Unicode code point—just like any other integer. The String() method is only invoked by packages that explicitly check for the fmt.Stringer interface, such as fmt and log.
If you need a string representation of a custom type, call the method directly or write a dedicated function; do not rely on the built-in conversion.
Common Mistakes in String Conversions
Several errors show up repeatedly in real Go codebases. Each one stems from a misunderstanding of what a particular conversion does.
Using string(n) when you need a decimal representation:
string(200) yields "È", not "200". Use strconv.Itoa or strconv.FormatInt when you want the numeric string.
Assuming []rune conversion is cheap:
Converting a 10‑kilobyte string with multi-byte characters to []rune allocates a slice of int32 values that may be significantly larger than the original string. If you only need to iterate over runes, use for _, r := range s without converting.
Ignoring parse errors:
Even if you’ve “already validated” the input, parsing functions can fail due to overflow, unexpected whitespace, or encoding issues. Always inspect the error.
Confusing string and []byte in interface expectations:
An io.Writer wants []byte, not string. A function that concatenates many strings with + is slower than writing to a bytes.Buffer and then converting the buffer to a string once. Recognizing where the conversion boundary lives is part of writing efficient Go.
Summary
String conversions in Go split into two clean categories. The built-in T(x) syntax moves between string, []byte, and []rune, copying data and respecting UTF-8 encoding. It also converts an integer to a single Unicode code point—which is rarely what you want for numeric formatting. The strconv package handles the value-based conversions: formatting numbers and booleans into strings, and parsing strings into numeric and boolean values, always with explicit error returns.
The most important takeaway is this: when you see string(anInteger), you are not asking for the decimal digits. You are asking for the character that code point represents. For decimal strings, reach for strconv. Keep that distinction clear, and you’ll avoid the mistake that trips up nearly every Go newcomer.