Numbers in depth

int and float64 carry most of the load, but Go's numeric menu is wider: integers with explicit widths, signed and unsigned versions, and two floating-point sizes.

Sized integers

When you need a specific bit width (for a binary format, a hash function, or a counter you know has a hard upper bound), Go gives you explicit sizes:

var tiny int8 = 127
var short int16 = 32000
var medium int32 = 2_000_000_000
var wide int64 = 9_000_000_000_000_000

int8 is eight bits wide, int16 is sixteen, int32 thirty-two, and int64 sixty-four. Each step doubles the memory one value occupies and massively expands the range of values the type can hold.

Signed versus unsigned

Every integer type in Go comes in two versions: signed (int8, int16, int32, int64, plus plain int) and unsigned (uint8, uint16, uint32, uint64, plus plain uint). The difference is what values the type can hold.

A signed integer dedicates one of its bits to recording the sign, so the range runs from a negative minimum to a positive maximum. An unsigned integer uses every bit for magnitude, so the range starts at zero and reaches twice as high on the positive side:

Type Range
int8 -128 to 127
uint8 0 to 255
int16 -32_768 to 32_767
uint16 0 to 65_535

Signed types can hold negative numbers. Unsigned types cannot, and the compiler refuses to let you try:

var signed int8 = -100    // fine, within range
var unsigned uint8 = -1   // error: constant -1 overflows uint8
Default to signed

Reach for int (signed) almost always. Unsigned types earn their keep when you are working with raw binary data, bit manipulation, or values that are genuinely non-negative by the nature of the problem (bytes in a buffer, port numbers, flag masks). The doubled positive range is rarely worth the extra ceremony, and mixing signed and unsigned in the same expression forces you to convert between them.

Two aliases are worth remembering now: byte is another name for uint8, and rune is another name for int32. Both come back in the strings lesson.

Writing number literals

Large numbers are hard to read as an unbroken string of digits. Go lets you insert underscores between digits to group them, and the compiler ignores the underscores:

var population = 8_000_000_000
var tenMillion = 10_000_000

You can also write integers in other bases, which is handy for bit masks, colour codes, or anything whose value is more meaningful in hex or binary than in decimal:

var hex = 0xFF          // 255
var binary = 0b0000_1010 // 10
var octal = 0o17         // 15

Underscores work inside these too: 0b1111_0000 is a clearer way to write 0xF0.

Integer arithmetic wraps silently

When arithmetic pushes a value outside its type's range, Go does not raise an error. It wraps:

var signed int8 = 127
signed = signed + 1
fmt.Println(signed)    // -128, wrapped up from the maximum

var unsigned uint8 = 0
unsigned = unsigned - 1
fmt.Println(unsigned)  // 255, wrapped down from zero

The signed result might look strange: adding 1 to the largest positive int8 gives the most negative int8. This happens because signed integers use a scheme called two's complement, where the bit pattern after 0111_1111 (127) is 1000_0000 (-128). The bits simply roll over from the top of the positive range to the bottom of the negative range.

Unsigned integers do the same thing in the other direction: 0000_0000 (0) minus 1 rolls to 1111_1111 (255). Either way Go uses the CPU's native wrapping behaviour and the program continues as if nothing happened. Fast, but it means you have to choose widths carefully when values might grow or shrink past the type's bounds.

Plain int is 32 or 64 bits wide depending on the platform, which is almost always enough to avoid both wraps in practice. Reach for a sized integer when the size is part of the spec (a binary format, a hash, a fixed-width counter), not because it "feels smaller".

float64 and the 0.1 + 0.2 gotcha

float64 uses the IEEE 754 double-precision format, the same format used by JavaScript numbers and C double. It is accurate to about fifteen significant decimal digits, which is plenty for everyday work. What it is NOT is exact for decimal fractions. The number 0.1 cannot be represented precisely in binary floating-point; it has an infinitely repeating expansion, just like 1/3 does in base ten.

Once a float64 value lives in a variable, it is already a slight approximation, and arithmetic on those approximations compounds the error:

var a, b float64 = 0.1, 0.2

fmt.Println(a + b)          // 0.30000000000000004
fmt.Println(a + b == 0.3)   // false

Comparing the approximation of 0.3 (the sum of two already-approximate floats) to the literal 0.3 returns false because they round to slightly different bit patterns.

Constants fold exactly, variables do not

The example above uses the typed variables a and b. Replace that line with the bare literals, so the code reads:

fmt.Println(0.1 + 0.2)

Run it. The output this time is 0.3, not 0.30000000000000004. Go evaluates constant expressions at compile time with arbitrary precision, not IEEE 754. When the addition is between bare literals, the compiler does the math in rational arithmetic and the answer is exactly 0.3. The IEEE 754 inaccuracy only surfaces once the values travel through typed variables, because the addition then happens at runtime on already-approximate floats. The upshot: demonstrating float imprecision in Go always requires a float64 variable on the way in.

This is not a Go quirk at its core. Python, JavaScript, C, and every other language that uses IEEE 754 behaves the same way once values are in variables. The practical rule: never use == to compare floats that came out of arithmetic.

When float64 is the wrong tool

float64 is a good fit for measurements, physics, statistics, and anything where a rounding error in the fifteenth decimal digit does not matter. It is the wrong choice for values that must be exact, and the canonical example is money.

The standard pattern in production systems is to store exact values as integers in the smallest indivisible unit for the domain. You never store a price as float64; you store the number of cents as an int64.

// Lossy: the float cannot represent 9.99 exactly
var priceApprox float64 = 9.99

// Exact: integer cents, no approximation
var priceCents int64 = 999

Arithmetic on cents stays exact because integer addition is exact:

a := 10    // $0.10
b := 20    // $0.20
fmt.Println(a + b)    // 30, always exactly 30

At display time, and only at display time, you convert the integer back to the human-facing unit:

cents := 999
fmt.Println(float64(cents) / 100)   // 9.99

The Printf family (covered in the next lesson) lets you format it as "$9.99" with a two-decimal-place verb, which is what you would actually reach for in a user interface.

This pattern is how real systems handle money. Stripe's API, Braintree, payroll engines, and most accounting software store amounts as integer cents (or millionths of a unit for currencies that need more precision) in their databases and across every API boundary. They only produce formatted strings at the very edge: the invoice, the receipt, the dashboard. A float64 total that reads 29.97 in your tests and 29.970000000000002 in production is a dropped penny that will show up in a support ticket.

The same pattern applies to anything measured in a smallest unit:

  • Time durations: store nanoseconds (Go's own time.Duration is an int64 of nanoseconds) or milliseconds, never fractional seconds.
  • Weights and distances: store grams, or millimetres, or whichever tiny unit keeps your numbers whole.
  • Basis points in finance: hundredths of a percent, not fractional percentages.

In small local programs, plain int is often fine. But once a value crosses API boundaries, databases, or file formats, a fixed-width type like int64 is usually the safer choice because it means the same thing everywhere. When even int64 is not enough (think enormous financial totals or scientific code needing more than fifteen significant digits), Go's standard library has math/big with big.Int, big.Float, and big.Rat for arbitrary-precision arithmetic. That package is covered in the Standard library chapter.

Task

The starter already shows signed overflow (127 + 1), unsigned underflow (0 - 1), and the float64 approximation of a + b. Extend it with two more lines:

  • Add fmt.Println(a+b == 0.3) to confirm the comparison prints false. Note that we use the variables a and b here, not the literals 0.1 and 0.2, so the addition is runtime float arithmetic.
  • Set signed to -128 (the int8 minimum), subtract 1, and print it. It should print 127, the wrap in the other direction.

Leave the existing prints in place.

Expected output
-128
255
0.30000000000000004
false
127