DEV Community

Cover image for Go Types Are Not What You Think: Numbers, Strings, and Memory Explained
Saiful Islam
Saiful Islam

Posted on

Go Types Are Not What You Think: Numbers, Strings, and Memory Explained

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

In memory, 127 is stored as:

01111111
Enter fullscreen mode Exit fullscreen mode

Add 1:

10000000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 |
Enter fullscreen mode Exit fullscreen mode

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¹
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

And 0.2 is:

0.20000000000000001110...
Enter fullscreen mode Exit fullscreen mode

Add them, and the tiny errors accumulate:

0.30000000000000004
Enter fullscreen mode Exit fullscreen mode

Want to see what's really happening under the surface?

fmt.Printf("%.20f\n", 0.1 + 0.3)
// 0.40000000000000002220
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When displaying:

10050 paisa → 100.50 taka
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

string is a read-only sequence of UTF-8 encoded bytes — not characters.

var s string = "Go😊ক"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The correct way to count characters:

fmt.Println(len([]rune("Go😊ক")))  // 4 ✓
Enter fullscreen mode Exit fullscreen mode

Or iterate with range, which decodes UTF-8 properly:

for _, r := range "Go😊ক" {
    fmt.Printf("%c\n", r)  // prints each character correctly
}
Enter fullscreen mode Exit fullscreen mode

Why String Slicing Breaks Characters

Since strings are byte arrays, slicing cuts bytes — not characters.

s := "😊"
fmt.Println(s[:2])  // prints: � (broken)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

[]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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Understanding this distinction is the difference between code that works and code that silently corrupts data.

Top comments (0)