Skip to content
gohacks.dev
Basics

Converting Between Types in Go Without Any Surprises

Go never converts types for you. Learn the T(x) syntax, the strconv functions for text, why string(65) gives A, and what truncates silently.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

Go is stubborn about types, and nowhere more than conversions. You cannot add an int to an int64, pass a float64 where an int is wanted, or treat a []byte as a string, without saying so. Newcomers read this as bureaucracy. After a few months you read it as the reason a whole category of "why did that value change" bugs does not exist. This how-to covers the conversions you will write every week, shows the errors in their real wording, and flags the two that compile fine and do the wrong thing. It is one of the working skills in the Go basics path, and it pairs with integer overflow, because most silent conversion problems are overflow in disguise.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The T(x) form and why Go insists on it

A conversion is written as the target type applied to a value: float64(count), int32(n), []byte(s). If the compiler allows the conversion, the expression has that type. If it does not, you get a compile error. Here is the error you meet in the first week:

main.goGo
package main

import "fmt"

func main() {
	count := 7
	price := 2.5
	fmt.Println(float64(count) * price)

	total := count * price
	fmt.Println(total)
}
Text
# example.test/lab
./main.go:10:11: invalid operation: count * price (mismatched types int and float64)

The first Println compiles because count was converted. The line computing total is a mismatched-types error: Go will not guess whether you wanted integer or floating point arithmetic, and the answer changes the result. The explicitness is the feature. Every place a value changes type is visible in the source, and a reviewer can ask whether it should.

Untyped constants are the exception, because a constant like 2 has no type until it is used. price * 2 compiles with price as a float64 since 2 can be a float. That rule is explained in typed and untyped constants.

Number to number

Converting between numeric types always compiles and sometimes loses information. The rules are in the specification's conversions section: integer to integer truncates or sign-extends, float to integer drops the fractional part toward zero, and integer to float rounds to the nearest representable value.

main.goGo
package main

import (
	"fmt"
	"math"
)

func main() {
	n := 1000
	fmt.Println(int8(n), uint8(n), int16(n))

	neg := -1
	fmt.Println(uint8(neg), uint32(neg))

	f := 2.9
	fmt.Println(int(f), int(-f), int(math.Round(f)))

	big := 1 << 53
	fmt.Println(float64(big+1) == float64(big))
}
Text
-24 232 1000
255 4294967295
2 -2 3
true

Each result follows from the bit patterns. 1000 does not fit in a byte, so only the low eight bits survive. A negative number converted to an unsigned type reinterprets the same bits as a huge positive. A float loses its fraction toward zero, so -2.9 becomes -2 and not -3. The last line shows an integer that a float64 cannot store exactly, since its 53-bit mantissa cannot tell 2^53 from 2^53 + 1. None of this raises an error. If a number comes from outside your program, compare it with math.MaxInt8 or the right limit first, and the reasoning is in integer overflow in Go.

Numbers and text belong to strconv

This is the one that costs people an afternoon. Converting an integer to string with the conversion syntax does not produce digits, it produces the character at that code point:

main.goGo
package main

import (
	"fmt"
	"strconv"
)

func main() {
	n := 65
	fmt.Println(string(rune(n)))
	fmt.Println(strconv.Itoa(n))
	fmt.Println(fmt.Sprint(n), fmt.Sprintf("%05d", n))
	fmt.Println(strconv.FormatInt(255, 2), strconv.FormatInt(255, 16))
}
Text
A
65
65 00065
11111111 ff

string(rune(65)) is A, because 65 is the code point for that letter. strconv.Itoa(65) is 65. Modern Go lets go vet warn on the bare string(n) form with an integer, and the message is worth knowing so you can search for it:

Text
./main.go:7:7: conversion from int to string yields a string of one rune, not a string of digits

That check came from the exact program s := string(n) with n := 65, and the program still compiled and printed A. It is a vet finding, not a compile error. Run go vet ./... in CI so it is caught. For padding or other formats, fmt.Sprintf is the more flexible tool and strconv is the faster one.

Parsing text into numbers

The other direction can fail, so the functions return an error, and ignoring it is the most common beginner bug in this area. strconv.Atoi is the shortcut for base 10 into an int; ParseInt and ParseUint take a base and a bit size; ParseFloat and ParseBool do what they say.

main.goGo
package main

import (
	"fmt"
	"strconv"
)

func main() {
	for _, in := range []string{"42", "12a", "", "99999999999999999999", " 7"} {
		n, err := strconv.Atoi(in)
		fmt.Printf("%q -> %d, %v\n", in, n, err)
	}

	v, err := strconv.ParseInt("ff", 16, 64)
	fmt.Println(v, err)

	small, err := strconv.ParseInt("300", 10, 8)
	fmt.Println(small, err)

	f, err := strconv.ParseFloat("1e3", 64)
	fmt.Println(f, err)

	b, err := strconv.ParseBool("T")
	fmt.Println(b, err)
}
Text
"42" -> 42, <nil>
"12a" -> 0, strconv.Atoi: parsing "12a": invalid syntax
"" -> 0, strconv.Atoi: parsing "": invalid syntax
"99999999999999999999" -> 9223372036854775807, strconv.Atoi: parsing "99999999999999999999": value out of range
" 7" -> 0, strconv.Atoi: parsing " 7": invalid syntax
255 <nil>
127 strconv.ParseInt: parsing "300": value out of range
1000 <nil>
true <nil>

Read the failures, because their text is predictable. Invalid input says invalid syntax. A value too large for the type says value out of range, and notice that when ParseInt is asked for 8 bits and the number is 300, it returns the largest value that fits, 127, along with the error. Whitespace is a syntax error, so trim user input first. An empty string is a syntax error as well. The returned error is a *strconv.NumError whose fields Func, Num and Err can be inspected with errors.As, and errors.Is(err, strconv.ErrRange) tells you the out-of-range case without matching strings. Errors Is and As goes through that pattern.

Strings, bytes and runes

The three text forms convert to each other with the plain syntax, and each conversion copies:

  • []byte(s) and string(b) move between text and a mutable buffer
  • []rune(s) and string(r) move between text and code points

The costs and the reasons are in strings as bytes and runes, bytes and Unicode. A rule that serves well: stay in string for text, switch to []byte when you need to modify or write to an io.Writer, and switch to []rune only when you need to index by character.

Named types and underlying types

If two types have the same underlying type, you can convert between them with the plain syntax, ignoring struct tags. This is how you attach methods to a basic type without losing the ability to feed it into code that expects the basic one:

main.goGo
package main

import (
	"fmt"
	"time"
)

type Celsius float64
type Fahrenheit float64

func (c Celsius) ToF() Fahrenheit { return Fahrenheit(c*9/5 + 32) }

func main() {
	boiling := Celsius(100)
	fmt.Println(boiling.ToF(), float64(boiling))

	seconds := 90
	d := time.Duration(seconds) * time.Second
	fmt.Println(d)
}
Text
212 100
1m30s

The time.Duration(seconds) * time.Second line is a pattern worth memorizing. A Duration is an int64 of nanoseconds, so multiplying a variable int by time.Second is a type mismatch, and the fix is to convert the variable first. Writing time.Second * 90 works because 90 is an untyped constant. The inverse, a bare time.Duration(90), means 90 nanoseconds, which is a mistake that durations without getting burned explains.

Interfaces and slices

Converting a value to an interface needs no syntax, because any type with the right methods already satisfies it. Going the other way, from an interface back to a concrete type, is a type assertion and not a conversion, and the form v, ok := x.(T) is the safe one. That is covered in type assertions and type switches.

Since Go 1.20 you can convert a slice to an array, or to a pointer to an array, as long as the slice is long enough:

main.goGo
package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 4}

	a := [3]int(s)
	fmt.Println(a)

	b := [5]int(s)
	fmt.Println(b)
}
Text
[1 2 3]
panic: runtime error: cannot convert slice with length 4 to array or pointer to array with length 5

goroutine 1 [running]:
main.main()
	example.test/lab/main.go:11 +0x8c
exit status 2

The first conversion copies three elements. The second panics at run time with a message about converting a slice of length 4 to an array of length 5, since the slice is too short. The compiler cannot know the length, so the check happens when the line runs. Use len(s) >= 5 before the conversion if the length is not known.

Where to be careful

Conversions from untrusted input are where to spend attention: user-supplied integers, sizes from a file header and counts from a network protocol. Parse with the narrowest type that fits the domain, check the range, then convert. And do not use conversion as a hack around the type system. If you find yourself converting a struct to another struct "because the fields match" more than once, a shared type or an interface is usually the better design. The Go specification on conversions is the reference for the exact rules, and strconv lists every parse and format function with the precise error behavior.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.