Type Conversion
Go is a statically typed language: every value has exactly one fixed type, and the compiler never silently converts between types the way C or JavaScript sometimes does. If you have an int and need a float64, or a string and need a slice of bytes, you must convert it yourself, explicitly, in the source code. This lesson covers exactly how type conversion works in Go, the rules that decide what can be converted to what, and the subtle mistakes that trip up even experienced Go programmers.
Overview: How Type Conversion Works in Go
A type conversion in Go has the form T(v): it takes a value v and produces a new value of type T. This is different from a type assertion (v.(T)), which is used to extract a concrete type out of an interface value and is checked at runtime — type assertions are covered in their own lesson. Type conversion, by contrast, is checked entirely at compile time: the compiler looks at the source type and the destination type and either allows the conversion or rejects your program before it ever runs.
Every type in Go has an underlying type. Built-in types like int, float64, and string are their own underlying type. A defined (named) type, such as type Celsius float64, has float64 as its underlying type but is treated as a distinct type by the compiler — you cannot add a Celsius and a float64 directly, and you cannot assign one to the other without an explicit conversion. This is intentional: it stops you from accidentally mixing values that are numerically compatible but conceptually different, such as a temperature and a plain float, or a user ID and a plain int.
Go allows a conversion in a fixed set of cases: between any two numeric types, between numeric types and certain string forms, between string and []byte or []rune, and between any two types that share the same underlying type (including converting a defined type back to its underlying type or to another defined type built on the same underlying type). Conversions that don’t fit one of these rules — converting a struct to an unrelated struct, for example — are compile errors.
Under the hood, what a conversion actually does depends on the pair of types involved. Converting between numeric types changes the physical bit representation: an int to a float64 re-encodes a two’s-complement integer as an IEEE-754 floating-point number; a float64 to an int truncates the fractional part and re-encodes what’s left as two’s-complement. Converting a defined type to its underlying type (or vice versa) changes nothing in memory at all — it’s purely a compile-time relabeling, and costs nothing at runtime. Converting a string to a []byte copies the string’s bytes into a new, mutable byte slice, because strings in Go are immutable and byte slices are not. Converting a string to a []rune goes further: it decodes the string’s UTF-8 byte sequence into a slice of int32 Unicode code points, which is why the rune count of a string can be smaller than its byte count once you use non-ASCII characters.
Syntax
T(value)
T— the destination type, such asfloat64,string,[]byte, or a defined type likeCelsius.value— the expression being converted. Its type must be convertible toTunder Go’s conversion rules.- The result is a brand-new value of type
T; the original variable and its type are untouched.
| From | To | What happens |
|---|---|---|
| Numeric type | Another numeric type | Widening is lossless; narrowing truncates or wraps bits |
string |
[]byte |
Copies the UTF-8 bytes into a new mutable slice |
string |
[]rune |
Decodes UTF-8 into Unicode code points (int32) |
| Integer | string |
Produces a one-character string of that Unicode code point (rarely what you want — see Common Mistakes) |
| Defined type | Its underlying type (or vice versa) | No data change; compile-time relabeling only |
Examples
Example 1: Converting Between Numeric Types
package main
import "fmt"
func main() {
var i int = 42
var f float64 = float64(i)
var u uint = uint(f)
fmt.Println(i, f, u)
var f2 float64 = 3.99
var i2 int = int(f2)
fmt.Println(i2)
}
Output:
42 42 42
3
Converting i (an int) to float64 and then to uint is lossless here because 42 fits comfortably in every one of those types. The second conversion is more interesting: int(3.99) does not round to 4, it truncates toward zero, discarding the fractional part entirely and leaving 3. This is a common source of off-by-one bugs for programmers coming from languages that round on numeric conversion.
Example 2: Strings, Bytes, and Runes
package main
import (
"fmt"
"strconv"
)
func main() {
s := "Go\u2665"
b := []byte(s)
r := []rune(s)
fmt.Println(len(s), len(b), len(r))
n := 65
fmt.Println(string(rune(n)))
numStr := "123"
num, err := strconv.Atoi(numStr)
if err != nil {
fmt.Println("conversion error:", err)
return
}
fmt.Println(num + 1)
back := strconv.Itoa(num)
fmt.Println(back + "!")
}
Output:
5 5 3
A
124
123!
The string "Go\u2665" (“Go\u2665” is Go’s heart symbol) is 5 bytes long: G and o are one byte each, and the heart character takes 3 bytes in UTF-8. []byte(s) preserves that byte count. []rune(s) decodes the UTF-8 sequence into 3 Unicode code points instead, since the heart is a single rune despite being multiple bytes — this is exactly why you should range over strings (which iterates runes) rather than index them directly when working with non-ASCII text. Next, string(rune(65)) converts the code point 65 into the single-character string "A", which is different from formatting the number 65 as text. To turn numeric text into a number (and back), you use the strconv package, not a plain conversion: strconv.Atoi parses a decimal string into an int (returning an error for invalid input), and strconv.Itoa does the reverse.
Example 3: Converting Between Named (Defined) Types
package main
import "fmt"
type Celsius float64
type Fahrenheit float64
func (c Celsius) ToFahrenheit() Fahrenheit {
return Fahrenheit(c*9/5 + 32)
}
func main() {
boiling := Celsius(100)
fmt.Printf("%.1f\u00b0C is %.1f\u00b0F\n", float64(boiling), float64(boiling.ToFahrenheit()))
var temp Celsius = 20
var raw float64 = float64(temp)
fmt.Println(raw)
}
Output:
100.0°C is 212.0°F
20
Celsius and Fahrenheit both have float64 as their underlying type, but they are distinct types: the compiler would reject c + 32 if 32 couldn’t be treated as an untyped constant, and it would reject mixing a raw Celsius value with a plain float64 variable without a conversion. Converting Celsius(100) to build the value, and float64(boiling) to read it back out, costs nothing at runtime — both types have identical memory layout, so the conversion is just a compile-time change of label.
How It Works Step by Step
- The compiler checks, at compile time, whether the source type and destination type form a legal conversion pair. If not, your program fails to build — there is no runtime fallback or implicit coercion.
- For numeric-to-numeric conversions, the runtime re-encodes the bit pattern: integer types use two’s-complement representation, floating-point types use IEEE-754. Widening (e.g.
int8toint64) is always safe. - Narrowing conversions (e.g.
int64toint8) discard the high-order bits, which can silently wrap the value around — Go does not raise an error or panic for this, so it’s on you to guard against it when the range matters. - Converting a float to an integer type truncates toward zero, not rounds —
int(1.9)is1andint(-1.9)is-1. - String-related conversions (
[]byte,[]rune) allocate a new slice and copy or decode the data, because strings are immutable in Go and byte/rune slices are not. - Conversions between a defined type and its underlying type involve no data transformation at all — they exist purely so the compiler can enforce that you handled the type distinction deliberately.
Common Mistakes
Mistake 1: Using a conversion where you meant to format a number as text
Writing string(n) for an int n does not produce the decimal digits of n — it produces a one-character string containing the Unicode code point n. This compiles without error but almost never does what a beginner expects.
// Wrong: intended to turn 65 into the text "65"
n := 65
s := string(n)
fmt.Println(s) // prints "A", the character with code point 65 - not "65"
The fix is to use strconv.Itoa (integer to ASCII), which is built specifically for formatting numbers as decimal text:
package main
import (
"fmt"
"strconv"
)
func main() {
n := 65
s := strconv.Itoa(n)
fmt.Println(s)
}
Output:
65
Mistake 2: Narrowing an integer without checking its range first
Go lets you convert any integer type to a smaller one with a plain conversion. If the value doesn’t fit, Go does not panic or return an error — it silently wraps the value using two’s-complement truncation, which usually produces a nonsensical result.
package main
import "fmt"
func main() {
var big int64 = 300
var small int8 = int8(big)
fmt.Println(small)
}
Output:
44
int8 can only hold -128 to 127, so 300 wraps around to 44 instead of being rejected. If the value might be out of range, check it explicitly before converting, or return an error:
package main
import (
"fmt"
"math"
)
func safeToInt8(v int64) (int8, error) {
if v < math.MinInt8 || v > math.MaxInt8 {
return 0, fmt.Errorf("value %d out of range for int8", v)
}
return int8(v), nil
}
func main() {
small, err := safeToInt8(300)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(small)
}
Output:
error: value 300 out of range for int8
Best Practices
- Use
strconv.Atoi/strconv.Itoa(orstrconv.ParseFloat,strconv.FormatFloat, etc.) to convert between numbers and their decimal text representation — never a plainT(v)conversion for that purpose. - Remember that float-to-int conversion truncates, not rounds; use
math.Roundfirst if you actually want rounding behavior. - Be deliberate about narrowing conversions between integer sizes — validate the range yourself if the value comes from user input, a file, or the network, since Go will not do it for you.
- Prefer
[]rune(s)over indexing or slicing a string by byte position whenever the string might contain non-ASCII characters, so you operate on whole characters rather than partial UTF-8 bytes. - Reach for defined types (like
type UserID intortype Celsius float64) when you want the compiler to stop you from mixing conceptually different values that happen to share a numeric representation. - Keep conversions and error handling close together: when a conversion can fail (like parsing text), always check the returned error rather than ignoring it.
Practice Exercises
- Write a function
func average(nums []int) float64that computes the average of a slice of integers as a floating-point value. Think carefully about where the conversion tofloat64needs to happen so you don’t perform integer division by accident. - Write a function that takes a
stringand returns the count of Unicode characters (not bytes) it contains, using a conversion covered in this lesson. Test it with a string that includes at least one multi-byte character. - Define two named types,
type Meters float64andtype Feet float64, and write a method that converts aMetersvalue toFeet(1 meter = 3.28084 feet). Then write a smallmainthat converts 10 meters to feet and prints the result.
Summary
- Type conversion in Go uses the syntax
T(value)and is checked entirely at compile time — it is different from a runtime type assertion (v.(T)). - Conversions are allowed between numeric types, between
stringand[]byte/[]rune, and between any two types sharing the same underlying type. - Widening numeric conversions are safe; narrowing conversions can silently truncate or wrap without any runtime error.
- Float-to-int conversion truncates toward zero — it does not round.
- Converting an integer directly to
stringproduces a single Unicode character, not decimal text — usestrconvfor text formatting and parsing. - Named (defined) types add compile-time safety by preventing accidental mixing of conceptually different values that share an underlying representation, at zero runtime cost.
