Most developers write Go code every day without thinking about what actually lives in memory. Then a bug shows up — a balance is slightly off, a counter wraps to negative, or Bengali text gets garbled mid-slice. This post explains exactly why these things happen.
The Foundation: Computers Only Know 0 and 1
Everything stored in a computer — numbers, text, images — is ultimately a sequence of bits. A bit is a single 0 or 1.
0 → off
1 → on
Group 8 bits together and you get a byte. Memory is measured in bytes.
8 bits = 1 byte
16 bits = 2 bytes
32 bits = 4 bytes
64 bits = 8 bytes
This is the foundation for understanding every number type.
Integer Types: Exact, But Bounded
An integer type defines two things: how much memory to use, and how large a number you can store.
| Type | Memory | Range |
|---|---|---|
| int8 | 1 byte | -128 to 127 |
| int16 | 2 byte | -32,768 to 32,767 |
| int32 | 4 byte | ~±2 billion |
| int64 | 8 byte | ~±9.2 quintillion |
The range is not arbitrary. With 8 bits, you have exactly 2⁸ = 256 possible values. Split between negative and positive (using one bit for the sign), you get -128 to 127. No more. That's physics.
var x int8 = 127 // fine
var y int8 = 128 // compile error
Why Only Powers of 2?
int4 and int12 don't exist because CPU architecture works in powers of 2. Hardware is optimized for 8, 16, 32, 64. Anything else would be slower or require special handling. Go follows this convention.
Unsigned Integers
If you know a value is always positive, you can use unsigned types:
var score uint8 = 255 // range: 0 to 255
You get the full 256 values on the positive side.
What Happens When You Overflow?
This is where things get dangerous. If you exceed an integer's range, Go doesn't crash or throw an exception — it silently wraps around.
package main
import "fmt"
func main() {
var x int8 = 127
x++
fmt.Println(x) // -128
}
In memory, 127 is stored as:
01111111
Add 1:
10000000
That pattern means -128 in a signed integer. The counter silently became negative.
In a banking or financial system, this is a critical bug. A transaction counter hits max, wraps to a negative, and your system thinks money was subtracted instead of added.
The fix: use int64 for financial counters. Its max (~9.2 quintillion) is large enough for any real-world value.
Floating-Point Types: Range, But Not Exactness
Float types (float32, float64) handle decimal numbers:
var pi float64 = 3.14159
var price float32 = 9.99
The key difference from integers:
| Type | Memory | Accurate Digits |
|---|---|---|
| float32 | 4 byte | ~7 digits |
| float64 | 8 byte | ~15-16 digits |
Go's default floating-point type is float64.
How Float Is Actually Stored
A float64 is stored in three parts following the IEEE 754 standard:
| 1 bit | 11 bits | 52 bits |
| sign | exponent | fraction |
A number like 3.14 is not stored as "3.14". It's converted into binary scientific notation:
(For an example, Value Representation of Binary: 11.001000111...=1.1001000111... × 2¹)
decimal to binary: 3.14 ≈ 1.5700... × 2¹
Then each part (sign, exponent, fraction) is encoded into those fixed bit slots. The problem: 52 bits of fraction is finite. Many decimal values need an infinite binary representation. The computer stores the closest approximation it can fit.
The Famous 0.1 + 0.2 Problem
fmt.Println(0.1 + 0.2)
// 0.30000000000000004
This is not a Go bug. It happens in Python, JavaScript, Java, C++, Rust — every language using IEEE 754 floating-point (which is almost all of them).
Here's why:
0.1 in binary is:
0.0001100110011001100110011... (infinite)
Similar to how 1/3 = 0.3333... never ends in decimal. The computer can't store infinite digits, so it rounds. The stored value of 0.1 is actually:
0.10000000000000000555...
And 0.2 is:
0.20000000000000001110...
Add them, and the tiny errors accumulate:
0.30000000000000004
Want to see what's really happening under the surface?
fmt.Printf("%.20f\n", 0.1 + 0.3)
// 0.40000000000000002220
The output looks fine with default formatting, but the error is there.
The rule: Float is an approximation. It's designed for range and performance, not for exactness.
The Three Types Compared
| Type | Exact? | Handles Decimals? | Max Size |
|---|---|---|---|
| int64 | ✅ Yes | ❌ No | ~9.2 × 10¹⁸ |
| float64 | ❌ No | ✅ Yes | ~1.8 × 10³⁰⁸ |
| big.Int | ✅ Yes | ❌ No | Unlimited |
big.Int: When You Need Huge Exact Numbers
int64 is exact but has a ceiling. For numbers beyond that — cryptography, arbitrary-precision math — Go provides math/big:
package main
import (
"fmt"
"math/big"
)
func main() {
n := new(big.Int)
n.SetString("999999999999999999999999999999999", 10)
fmt.Println(n)
// 999999999999999999999999999999999 — exact
}
How does it work? Instead of one fixed 8-byte block, big.Int allocates multiple memory chunks and chains them together. The number grows as needed. Slower than int64, but unlimited and exact.
The Banking Rule
Money is the most common place developers get this wrong.
Never use float for money.
One cent of precision loss per transaction. Multiplied by millions of transactions. That's real money, and it's gone to rounding errors.
The industry-standard approach: store money as an integer in the smallest unit.
// DON'T do this
var balance float64 = 100.50
// DO this
var balance int64 = 10050 // stored as paisa or cents
When displaying:
10050 paisa → 100.50 taka
Integer arithmetic is exact. The display logic handles the decimal point. No precision issues.
For even more control — like fixed decimal arithmetic with explicit rounding rules — use a dedicated decimal library. But for most applications, the integer-as-smallest-unit approach is sufficient and battle-tested.
Text Types: rune and string
Go has two types for text. They look similar but work completely differently.
rune is an alias for int32. It holds one Unicode character — just a number under the hood.
var r rune = 'ক' // stored as: 2453 (its Unicode code point)
var e rune = '😊' // stored as: 128522
string is a read-only sequence of UTF-8 encoded bytes — not characters.
var s string = "Go😊ক"
That string takes 9 bytes in memory, not 4 characters:
| Char | Bytes used |
|---|---|
| G | 1 |
| o | 1 |
| 😊 | 4 |
| ক | 3 |
Go stores strings as raw bytes. Unicode meaning only appears when Go decodes them.
The len() Trap
s := "Go😊ক"
fmt.Println(len(s)) // 9 — bytes, not characters
len() on a string returns byte count, not character count. For ASCII-only strings this looks correct. For anything else, it misleads you.
fmt.Println(len("😊")) // 4
fmt.Println(len("ক")) // 3
The correct way to count characters:
fmt.Println(len([]rune("Go😊ক"))) // 4 ✓
Or iterate with range, which decodes UTF-8 properly:
for _, r := range "Go😊ক" {
fmt.Printf("%c\n", r) // prints each character correctly
}
Why String Slicing Breaks Characters
Since strings are byte arrays, slicing cuts bytes — not characters.
s := "😊"
fmt.Println(s[:2]) // prints: � (broken)
You cut the emoji's 4 bytes in half. The output is invalid UTF-8.
Bengali is even more dangerous. ক is 3 bytes (E0 A6 95). Almost every possible slice position cuts it mid-byte:
s := "ক"
fmt.Println(s[:1]) // E0 alone → broken
fmt.Println(s[:2]) // E0 A6 → still broken
And unlike emoji corruption (which renders visibly as ?), Bengali corruption can silently produce blank or wrong glyphs — harder to catch.
Safe slicing always goes through runes:
r := []rune("বাংলা")
fmt.Println(string(r[:2])) // ✓ safe, correct characters
[]byte ↔ string: Memory Is Always Copied
Converting between []byte and string is not free — Go allocates new memory and copies every byte.
b := []byte("Hello")
s := string(b) // new allocation + full copy
b[0] = 'X' // does NOT affect s
This is intentional. Strings in Go are immutable. Sharing memory between a mutable []byte and an immutable string would cause data races and bugs. So Go always copies.
The cost is O(n). In hot paths that do many conversions, use strings.Builder or keep data as []byte for as long as possible.
Summary
- Bit = one 0 or 1. Byte = 8 bits. All numbers are stored in binary.
- int types are exact, but have a fixed range. Exceeding it causes silent overflow — it wraps around without error.
- float types handle decimals, but are approximations. They use IEEE 754 binary scientific notation, and most decimals cannot be represented exactly in binary.
- The 0.1 + 0.2 problem is not a language bug — it's how IEEE 754 works, and it applies to almost every modern language.
- big.Int is exact and unlimited, but slower and uses more memory.
- For financial systems, store amounts as integers (cents, paisa). Never use floats for money.
In the END:
Counting things → int / int64
Decimal math → float64 (if approximation is acceptable)
Financial values → int64 (in smallest unit)
Huge exact numbers → big.Int
Understanding this distinction is the difference between code that works and code that silently corrupts data.
Top comments (0)