Numbers That Don't Lie About Their Size: Go's Basic Types

The built-in types — the numeric family, bool, and a first look at string — plus the two rules that surprise newcomers most: Go converts no number for you, and integer overflow wraps in silence. And why untyped constants get to bend those rules. Compiled and run against Go 1.26.5.

Go’s built-in types are a short list, and most of them behave the way you’d guess. Two things do not, and they’re the reason this chapter exists: Go will never convert one numeric type to another on your behalf, and when an integer runs past the edge of its range it wraps around silently rather than raising anything. Both are deliberate, both follow from the same value — no hidden behavior at runtime — and both will surprise you at least once. We’ll build the chapter around them.

Everything here is compiled and run against Go 1.26.5.

The numeric family

Go’s numbers come sized. The integers are int8, int16, int32, int64 and their unsigned twins uint8, uint16, uint32, uint64; the floats are float32 and float64. The size is in the name, so there’s no guessing how many bits you have, and no platform where int32 quietly means something else.

Two of those have familiar aliases. byte is an alias for uint8 — the same type under a name that says “raw byte” — and rune is an alias for int32, used to mean “a single Unicode code point.” They aren’t separate types; they’re the same type wearing a name that documents intent:

package main

import "fmt"

func main() {
	var b byte = 65  // byte is an alias for uint8
	var r rune = '' // rune is an alias for int32, a Unicode code point
	fmt.Printf("byte %d is %c\n", b, b)
	fmt.Printf("rune %d is %c\n", r, r)

	// int is platform-sized: 64-bit on a 64-bit build
	fmt.Println("int max on this build:", int(^uint(0)>>1))
}
$ go run .
byte 65 is A
rune 19990 is
int max on this build: 9223372036854775807

Then there’s plain int (and uint), the one you’ll reach for by default. Unlike the sized types, int is platform-sized: 32 bits on a 32-bit build, 64 bits on a 64-bit build. On the machine above it’s 64-bit, so its maximum is 9223372036854775807. The rule of thumb is simple — use int for counts, indices, and ordinary arithmetic, and reach for a sized type only when you have a reason (a wire format, a memory budget, an interface that demands one). That '世' is a rune literal, a Unicode code point written between single quotes; we’ll do runes, bytes, and UTF-8 properly in the strings chapter. Here it’s just a number that happens to print as a character.

No implicit conversion

Here’s the first surprise. Add an int to a float64 and Go refuses to compile:

package main

import "fmt"

func main() {
	i := 3
	f := 2.5
	sum := i + f // no implicit conversion
	fmt.Println(sum)
}
$ go run .
# example.com/basics
./main.go:8:9: invalid operation: i + f (mismatched types int and float64)

Not a warning, a hard error. Most languages would silently promote the int to a float64 and hand you 5.5. Go won’t, and the reasoning is the through-line of the whole language: an implicit conversion is a hidden operation, and hidden operations are where precision loss and sign surprises come from. So Go makes you write the conversion yourself, as a plain function-call-looking T(x):

package main

import "fmt"

func main() {
	i := 3
	f := 2.5
	sum := float64(i) + f // explicit conversion, spelled out
	fmt.Println(sum)

	// converting a float VARIABLE to int truncates toward zero, it does not round
	a, b := 2.9, -2.9
	fmt.Println(int(a), int(b))
}
$ go run .
5.5
2 -2

float64(i) converts the int to a float, and now the addition has two operands of the same type. The verbosity is the point: every place a number changes type is visible in the source, so when you read code later you can see exactly where a value could have lost precision. And note the second line — converting a float back to an int truncates toward zero, it does not round. 2.9 becomes 2 and -2.9 becomes -2. If you wanted rounding you’d say so with math.Round.

(A subtlety for later: this truncation happens when you convert a variable. Trying to convert the constant 2.9 to int directly is instead a compile error, because a constant must be exactly representable in its target type. That’s the untyped-constant rule, coming up at the end.)

Integer overflow wraps, silently

The second surprise is what happens at the edge of a type’s range. Push a signed integer one past its maximum and it doesn’t panic, doesn’t error, doesn’t saturate — it wraps to the minimum, the way two’s-complement hardware does:

package main

import (
	"fmt"
	"math"
)

func main() {
	var max int8 = math.MaxInt8 // 127
	fmt.Println("max int8:", max)
	max++ // wraps, no panic, no error
	fmt.Println("after ++:", max)

	var u uint8 = 0
	u-- // wraps the other way
	fmt.Println("0 - 1 as uint8:", u)
}
$ go run .
max int8: 127
after ++: -128
0 - 1 as uint8: 255

Incrementing a maxed-out int8 gives -128, and decrementing a uint8 of 0 gives 255. This is well-defined behavior — Go specifies wraparound, so it’s portable and predictable — but it’s silent, and that’s the trap. A counter in a too-small type, a subtraction on an unsigned value that goes below zero, an index computed near a boundary: none of these announce themselves. The defense is to pick a type wide enough for the values you’ll actually see (int is 64-bit on modern builds for exactly this comfort) and to be deliberate with unsigned types, whose floor at zero is a common source of the wraparound above.

bool and a first look at string

After the numbers, bool is refreshingly boring. It’s true or false, its zero value is false, and — one thing to note coming from C — it is not a number. You cannot add a bool, and if 1 doesn’t compile; a condition must be an actual boolean expression. There’s no truthiness in Go, which removes a whole category of “did they mean the value or the truthiness” ambiguity.

string gets a full chapter later, but two facts belong here because they follow from the type system. A string is a sequence of bytes, and it is immutable: once created you cannot change its contents. You can build new strings from old ones, index into one to read a byte, and take its length, but s[0] = 'H' won’t compile. That immutability is what makes strings safe to pass around and share without defensive copying. The other fact you already saw in the last chapter — a string’s zero value is "", a usable empty string, never a null. The interesting parts (why len counts bytes not characters, how runes and UTF-8 fit together, why indexing gives you a byte) wait for chapter eight.

Untyped constants: the flexible ones

There’s one more piece, and it resolves something you might have wondered about. If Go is so strict about numeric types, how does var x float64 = 3 work when 3 looks like an int? The answer is that constants are untyped until they’re used.

A literal like 3, 2.5, or 1 << 40 has a default type but isn’t pinned to it. It carries its value with arbitrary precision and only takes on a concrete type at the moment you assign it to a variable or use it where a specific type is required. That’s why one constant can be used as several different types:

package main

import "fmt"

const big = 1 << 40 // untyped constant, way past int32 range

func main() {
	var f float64 = big // used as a float64 here
	var i int64 = big   // and as an int64 here — same constant
	fmt.Println(f, i)

	// untyped constants carry arbitrary precision until assigned
	const third = 1.0 / 3.0
	fmt.Printf("%.10f\n", third)
}
$ go run .
1.099511627776e+12 1099511627776
0.3333333333

The single constant big becomes a float64 in one line and an int64 in the next, with no conversion written, because it was never an int to begin with — it becomes whatever the context needs, as long as its value fits. This is exactly why float64(i) was required earlier but var f float64 = 3 is not: i is a typed variable and needs an explicit conversion, while 3 is an untyped constant that simply adopts float64. It’s also why the compiler could reject int(2.9) on a constant — the constant 2.9 is not representable as an integer, and the untyped-constant rules catch that at compile time instead of truncating silently.

The practical upshot: write your numeric literals plainly and let them flow into whatever type the surrounding code calls for. The strictness you feel with variables mostly evaporates for constants, and that’s by design.

Operators

The operators are mostly what you’d expect, so the point of this section is the one family that isn’t. Arithmetic is + - * / % (with % the integer remainder). Comparison is == != < <= > >=, and it always produces a bool. Logical operators are &&, ||, and !, and the first two short-circuit — the right side isn’t evaluated if the left already settles the answer. (Complex numbers get their own complex128/complex64 types and a couple of built-ins; they’re out of scope for this series.)

The part worth slowing down for is the bitwise operators, which work on the bits of integer values: & (AND), | (OR), ^ (XOR), the shifts << and >>, and Go’s distinctive &^ (AND-NOT, also called bit clear). That last one has no single-character equivalent in most languages: a &^ b keeps the bits of a except where b has a bit set, so it clears exactly the bits in the mask b. It’s the clean way to turn flags off.

package main

import "fmt"

func main() {
	a, b := 12, 10 // 1100 and 1010 in binary

	fmt.Printf("a & b  = %2d  (%04b)\n", a&b, a&b)   // AND
	fmt.Printf("a | b  = %2d  (%04b)\n", a|b, a|b)   // OR
	fmt.Printf("a ^ b  = %2d  (%04b)\n", a^b, a^b)   // XOR
	fmt.Printf("a &^ b = %2d  (%04b)\n", a&^b, a&^b) // AND-NOT (bit clear)

	fmt.Printf("1 << 4 = %d\n", 1<<4)   // left shift
	fmt.Printf("48 >> 2 = %d\n", 48>>2) // right shift
}
$ go run .
a & b  =  8  (1000)
a | b  = 14  (1110)
a ^ b  =  6  (0110)
a &^ b =  4  (0100)
1 << 4 = 16
48 >> 2 = 12

Read the binary and each result falls out. 12 & 10 keeps only the bits set in both (1000, which is 8); | keeps bits set in either; ^ keeps bits set in exactly one. The &^ line is the interesting one: starting from 1100, the mask 1010 clears the third bit and leaves 0100, which is 4. The shifts are multiply and divide by powers of two — 1 << 4 is 1 moved left four places, giving 16, and 48 >> 2 moves right two places, giving 12. One caveat that follows from the earlier overflow rule: shifting left far enough runs a value off the top of its type and the high bits are simply lost, silently, the same wraparound you saw before.

Final thoughts

Go’s basic types are a small, sized, honest set. The two rules that trip people up both come from the same place — no hidden runtime behavior: numeric conversion is always explicit, so you can see every place a value changes type, and integer overflow wraps silently, so you pick your widths on purpose. bool is not a number, string is an immutable byte sequence with a usable empty zero value, and untyped constants are the pressure valve that keeps literals from making the strictness painful. Next we start writing real logic, beginning with functions — including the multiple-return-value habit that makes Go’s whole approach to errors work, and defer, the cleanup mechanism you’ll come to rely on.

Next: functions, closures, and the cleanup magic of defer — multiple returns, and why cleanup lives right next to the thing it cleans up.

Comments