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 as float64, string, []byte, or a defined type like Celsius.
  • value — the expression being converted. Its type must be convertible to T under 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

  1. 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.
  2. 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. int8 to int64) is always safe.
  3. Narrowing conversions (e.g. int64 to int8) 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.
  4. Converting a float to an integer type truncates toward zero, not rounds — int(1.9) is 1 and int(-1.9) is -1.
  5. 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.
  6. 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 (or strconv.ParseFloat, strconv.FormatFloat, etc.) to convert between numbers and their decimal text representation — never a plain T(v) conversion for that purpose.
  • Remember that float-to-int conversion truncates, not rounds; use math.Round first 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 int or type 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) float64 that computes the average of a slice of integers as a floating-point value. Think carefully about where the conversion to float64 needs to happen so you don’t perform integer division by accident.
  • Write a function that takes a string and 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 float64 and type Feet float64, and write a method that converts a Meters value to Feet (1 meter = 3.28084 feet). Then write a small main that 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 string and []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 string produces a single Unicode character, not decimal text — use strconv for 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.