Skip to content
gohacks.dev
Basics

Integer Types in Go and How Overflow Really Behaves

Go integers wrap around silently at run time and refuse to compile when a constant is too big. See the sized types, the wraparound cases and a safe add.

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

C programmers learn that signed overflow is undefined behavior and that the optimizer may do anything with it. Go made a different choice, and it is worth stating plainly: integer overflow is defined, it wraps, and nothing tells you it happened. A counter that passes its limit goes quietly negative. That is usually what you want from the hardware and rarely what you want from a balance, a size or an ID. This article shows exactly what each integer type does at its edge, where Go does stop you at compile time, and the small check that makes addition safe. It belongs next to zero values and type conversion in the Go basics path.

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

The integer types and their ranges

Go has a type for each width, in signed and unsigned forms, plus int and uint, which are as wide as a machine word. byte is an alias for uint8 and rune is an alias for int32.

TypeBitsSmallestLargest
int88-128127
uint8 (byte)80255
int1616-32,76832,767
uint1616065,535
int32 (rune)32-2,147,483,6482,147,483,647
uint323204,294,967,295
int6464-9,223,372,036,854,775,8089,223,372,036,854,775,807
uint6464018,446,744,073,709,551,615
int, uint32 or 64depends on the platformdepends on the platform

The math package exports these limits as constants (math.MaxInt8, math.MinInt64, math.MaxUint32 and the rest), so you never need to type them. The last row is the one that bites. On a 64-bit machine int is the same size as int64, and on a 32-bit build it is the same size as int32. If you are curious which one you have, strconv.IntSize and math/bits.UintSize both report it. A program that stores nanoseconds since an epoch, a file size or a Unix timestamp in milliseconds in an int works on your laptop and wraps on a 32-bit ARM board. Choose int64 when the value's range is a property of the data, and int when it is an index or a length.

What wraparound looks like

Here is each overflow direction, run on the types that make it visible. Nothing in the output marks the moment it happened.

main.goGo
package main

import (
	"fmt"
	"math"
	"strconv"
)

func main() {
	var i8 int8 = 127
	i8++
	fmt.Println("int8 127 + 1 =", i8)

	var u8 uint8 = 0
	u8--
	fmt.Println("uint8 0 - 1 =", u8)

	var x int64 = math.MaxInt64
	x++
	fmt.Println("int64 max + 1 =", x, x == math.MinInt64)

	var u uint = 3
	var v uint = 5
	fmt.Println("uint 3 - 5 =", u-v)

	n := 300
	fmt.Println("int8(300) =", int8(n), "uint8(300) =", uint8(n))

	lowest, minusOne := int64(math.MinInt64), int64(-1)
	fmt.Println("min / -1 =", lowest/minusOne)

	fmt.Println("int bits:", strconv.IntSize)
}
Text
int8 127 + 1 = -128
uint8 0 - 1 = 255
int64 max + 1 = -9223372036854775808 true
uint 3 - 5 = 18446744073709551614
int8(300) = 44 uint8(300) = 44
min / -1 = -9223372036854775808
int bits: 64

Read it line by line. int8 at its maximum plus one is its minimum. uint8 at zero minus one is its maximum. The 64-bit case is the same arithmetic at a larger scale. The uint line is the one I see cause trouble, because u-v with a smaller left operand yields an enormous number, 18446744073709551614, and that number then becomes a loop bound or a slice length. And the conversion on the n line truncates: 300 is 0x12C, the low byte is 0x2C, and that is 44 for both. The upper bits are discarded.

The rule is the one in the specification's arithmetic section: for unsigned values the result is the true result modulo 2 to the power of the width, and for signed values the operations "may legally overflow and the resulting value exists and is deterministic" under two's-complement. That determinism is why Go can say it has no undefined behavior here. It does not make the answer correct for your problem.

The compiler stops constants, not variables

Constants are checked at compile time with no wraparound. If a constant expression does not fit where you put it, you get an error and no binary:

main.goGo
package main

import "fmt"

func main() {
	var b int8 = 128
	fmt.Println(b)
}
Text
# example.test/lab
./main.go:6:15: cannot use 128 (untyped int constant) as int8 value in variable declaration (overflows)

That check applies to anything the compiler can fold into a constant, so uint8(256) and var x uint = -1 fail the same way. A variable that holds the same value at run time gets no such protection, which is the gap the previous program walked through. The mismatch is the sharp edge: int8(300) on a constant is an error, while int8(n) on a variable truncates with no complaint. If the value comes from user input, a network read or a file, the compiler sees only the type, so the checks have to be yours.

Overflow in places you do not expect

A few ordinary operations hide the same arithmetic.

  • Sizes and multiplication. width * height * bytesPerPixel for an image buffer wraps if the inputs are large, and the wrapped (smaller) value is what you hand to make. The program then allocates less than it needs and writes past the end of what it thinks is its buffer, which in Go becomes a panic rather than memory corruption. Multiply with a check, or bound each factor first.
  • Division. Dividing by zero panics at run time with integer divide by zero. The one overflow case in division is the minimum value divided by -1. The specification defines the result as the dividend itself, so a variable holding math.MinInt64 divided by -1 does not panic, and it does not give you a positive number either, as the min / -1 line in the first program shows.
  • Durations. time.Duration is an int64 count of nanoseconds, so about 292 years is its limit. time.Duration(hours) * time.Hour with a huge hours wraps silently. Constants can overflow it at compile time, as in time.Duration(300) * 365 * 24 * time.Hour. See durations without getting burned.
  • Hashes and IDs. Wrap is a feature here. A hash function that multiplies and adds in a uint32 or uint64 relies on the modular result. Marking those spots with a comment saves the next reader from "fixing" them.

Checking before you add

For addition, the cheapest safe check for signed integers compares the result with an operand. If you add a positive number and the sum comes out smaller than where you started, it wrapped. Written both ways:

main.goGo
package main

import (
	"fmt"
	"math"
	"math/bits"
)

// addInt64 reports whether a+b fits in an int64.
func addInt64(a, b int64) (int64, bool) {
	sum := a + b
	if (sum > a) == (b > 0) {
		return sum, true
	}
	return 0, false
}

// addUint64 uses the carry out of the 64-bit add.
func addUint64(a, b uint64) (uint64, bool) {
	sum, carry := bits.Add64(a, b, 0)
	return sum, carry == 0
}

func main() {
	fmt.Println(addInt64(40, 2))
	fmt.Println(addInt64(math.MaxInt64, 1))
	fmt.Println(addInt64(math.MinInt64, -1))
	fmt.Println(addInt64(5, 0))

	fmt.Println(addUint64(1<<63, 1<<63-1))
	fmt.Println(addUint64(math.MaxUint64, 1))
}
Text
42 true
0 false
0 false
5 true
18446744073709551615 true
0 false

The signed check works because of a property of two's-complement addition: if b is positive, a correct sum is greater than a, and if b is negative or zero, it is not. The comparison (sum > a) == (b > 0) is true exactly when the result is consistent with that. The edge case that confuses people is b == 0, where sum > a is false and b > 0 is false, and they agree, so the function returns true. The last signed call above shows that.

For unsigned addition, math/bits.Add64 returns the sum and the carry out, so overflow is a single boolean. There are matching Sub64 and Mul64 functions, and the 32-bit variants if you work with uint32. The math/bits documentation lists them. Multiplication is where bits.Mul64 earns its place, because it returns the high 64 bits as well, and a non-zero high word is the overflow signal.

If the numbers can be genuinely large, such as factorials, Fibonacci numbers or cryptographic values, stop fighting the width and use math/big. It is slower and allocates, and it never overflows. A conversion helper for checking what a number looks like in other bases is the number base converter, and the bitwise calculator shows what the high bits do.

A safer habit for unsigned subtraction

Most of the overflow bugs I would worry about in review are unsigned subtractions. The pattern is len(a) - len(b) stored in a uint, or an index i - 1 when i is a uint that can be zero. The value wraps to the top of the range, the comparison i - 1 < n stays true for no reason anyone intended, and an off-by-one becomes an out-of-range panic far from the cause.

My own rule: use int for lengths, indexes and counts, because the language does. len, cap and slice indexes are all int, and mixing in uint forces conversions at every boundary. Reach for unsigned types for bit patterns, hashes, byte values and wire formats. If you must subtract unsigned values, compare first: if a < b { return 0 } followed by a - b.

Where the checking cost is not worth it

If the values are small by construction, the extra branch is noise. A loop counter bounded by a slice length cannot overflow an int. A uint8 pixel channel is meant to wrap in some image filters. And hot loops that process millions of values per second may not want a check on every add, in which case you validate the inputs once at the boundary and trust the arithmetic inside.

The danger zone is untrusted input. Sizes, counts and offsets that arrive from a client, a file header or a network protocol should be bounded before they are multiplied or converted. That is a security concern as much as a correctness one: an attacker who controls both factors of a multiplication controls where the wrapped result lands. The same discipline for conversions between integer types is in converting between types without surprises, and the integer overflow section of the Go specification is short enough to read in a minute.

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.