Skip to content
CalliCoder

Golang Basic Types, Operators and Type Conversion

Published Updated Golang 12 min read

The numeric types and what int actually is, integer division and overflow that wraps silently, why comparing floats with == is wrong, and the conversions Go refuses to do for you.

Go’s type system is deliberately unhelpful about numbers. It will not convert between numeric types for you. It will not compare an int with an int64, and it has no implicit truthiness. Every one of those refusals removes a category of bug that other languages leave to the reader.

Written against Go 1.22.

The numeric types

Signed integers, unsigned integers, and two floats:

var a int8   = 127                  // -128 .. 127
var b int16  = 32767
var c int32  = 2147483647
var d int64  = 9223372036854775807
var e int                           // 32 or 64 bits, platform dependent

var f uint8  = 255                  // 0 .. 255
var g uint64 = 18446744073709551615
var h uint                          // same width as int

var i float32 = 3.14
var j float64 = 3.141592653589793

Two aliases exist and both carry meaning: byte is uint8 and rune is int32. They are identical to their underlying types and say something different: byte means raw data, rune means one Unicode code point.

int is the one to know about. It is 64 bits on any current platform and 32 bits on a 32-bit build, and it is a distinct type from int64 even when they are the same width:

var n int = 42
var m int64 = n        // compile error: cannot use n (variable of type int) as int64
var m int64 = int64(n) // fine

Use int for counts, indices and lengths, len() returns int. Use a sized type when the width is part of the contract: a wire format, a database column, a file header.

Integer arithmetic has two surprises

Division truncates toward zero. There is no automatic promotion to float:

fmt.Println(7 / 2)          // 3, not 3.5
fmt.Println(-7 / 2)         // -3, truncated toward zero
fmt.Println(7 % 2)          // 1
fmt.Println(-7 % 2)         // -1  — the sign follows the dividend
fmt.Println(7.0 / 2.0)      // 3.5 — float literals
fmt.Println(float64(7) / 2) // 3.5

The -7 % 2 == -1 behaviour differs from Python’s 1 and matters for anything doing modular arithmetic on possibly-negative values, such as a ring buffer index.

Overflow wraps silently and there is no runtime panic:

var x int8 = 127
x++
fmt.Println(x)        // -128

var u uint8 = 0
u--
fmt.Println(u)        // 255

That unsigned case is the dangerous one. u-- on a zero-valued unsigned counter produces a very large number, and a subsequent loop bound or slice length built from it fails far from the cause. Compare before subtracting rather than checking the result.

Constant overflow, by contrast, is caught at compile time:

var y int8 = 128    // compile error: 128 overflows int8

Untyped constants are arbitrary precision until assigned, which is why math.MaxInt64 works in an expression that no runtime type could hold.

Floats

fmt.Println(0.1 + 0.2)              // 0.30000000000000004
fmt.Println(0.1+0.2 == 0.3)         // false

IEEE-754 binary floating point cannot represent 0.1 exactly. This is not a Go quirk, and the consequence is the same everywhere: never compare floats with ==. Compare against a tolerance:

const epsilon = 1e-9

func nearlyEqual(a, b float64) bool {
    return math.Abs(a-b) <= epsilon*math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
}

Scaling the tolerance by magnitude matters, an absolute epsilon of 1e-9 is meaningless when comparing values around a billion.

For money, do not use floats at all. Use integer minor units (cents, satoshi) or a decimal library. A float64 cannot hold 0.10 exactly, and a sum of a thousand of them is visibly wrong.

Three values behave unlike numbers:

z := 0.0
fmt.Println(1/z, -1/z)              // +Inf -Inf
fmt.Println(z/z)                    // NaN
fmt.Println(math.NaN() == math.NaN()) // false — NaN equals nothing, including itself
fmt.Println(math.IsNaN(z / z))      // true — this is the check

Because NaN != NaN, a NaN in a map key or a sort comparator produces behaviour that looks random. math.IsNaN is the only reliable test.

Booleans have no numeric identity

var ok bool = true

// all compile errors
if 1 { }
if ok == 1 { }
x := ok + 1

There is no truthiness and no implicit conversion. if takes a bool and nothing else, so if err != nil has to be written out, which is precisely why Go code is explicit about what is being tested.

&& and || short-circuit, which is what makes the nil-check idiom safe:

if u != nil && u.Active {      // u.Active is not evaluated when u is nil
    ...
}

Strings are bytes, and iterate as runes

s := "héllo"
fmt.Println(len(s))        // 6 — BYTES, not characters
fmt.Println(s[1])          // 195 — one byte of a two-byte 'é'

A string is an immutable slice of bytes holding UTF-8. len counts bytes and indexing yields a byte, so indexing into non-ASCII text gives you a fragment of a character.

range over a string decodes UTF-8:

for i, r := range "héllo" {
    fmt.Printf("%d: %c\n", i, r)   // 0:h 1:é 3:l 4:l 5:o — index jumps 1 -> 3
}

fmt.Println(utf8.RuneCountInString("héllo"))   // 5
fmt.Println([]rune("héllo")[1])                // 233, the 'é' code point

The index is a byte offset, which is why it skips. Convert to []rune when you need positional access by character, accepting the allocation.

Because strings are immutable, building one in a loop with += allocates each time. strings.Builder does not:

var b strings.Builder
for _, part := range parts {
    b.WriteString(part)
}
result := b.String()

Conversion is always explicit

Every numeric conversion is written out, and each one can lose information silently:

var big int32 = 300
var small int8 = int8(big)      // 44 — the high bits are discarded, no error

var f float64 = 3.99
var n int = int(f)              // 3 — truncated toward zero, not rounded

var neg int = -1
var u uint = uint(neg)          // 18446744073709551615

None of these panic. If a conversion could be out of range, check first:

func toInt8(v int32) (int8, error) {
    if v < math.MinInt8 || v > math.MaxInt8 {
        return 0, fmt.Errorf("%d out of int8 range", v)
    }
    return int8(v), nil
}

String conversion is the trap. string(65) does not give "65":

fmt.Println(string(rune(65)))        // "A" — an int is a code point
fmt.Println(strconv.Itoa(65))        // "65"
fmt.Println(fmt.Sprintf("%d", 65))   // "65"

go vet flags string(int) for exactly this reason, and since Go 1.15 the compiler warns. Use strconv:

i, err := strconv.Atoi("42")
f, err := strconv.ParseFloat("3.14", 64)
b, err := strconv.ParseBool("true")
n, err := strconv.ParseInt("ff", 16, 64)     // base 16, 64-bit result

s := strconv.Itoa(42)
s = strconv.FormatFloat(3.14, 'f', 2, 64)    // "3.14"

Every parse returns an error. Ignoring it means a malformed input becomes a silent zero, and zero is frequently a plausible value.

[]byte and string convert both ways, and both copy:

b := []byte("hello")     // copies
s := string(b)           // copies

The copy is what preserves string immutability. In a hot path, strings.Builder or a []byte buffer avoids repeating it.

Zero values

Every type has a zero value and a declared variable always has it. There is no uninitialised memory:

var (
    i int        // 0
    f float64    // 0
    b bool       // false
    s string     // ""
    p *int       // nil
)

This is why a struct is usable immediately after declaration, and why “was this field set or is it zero?” needs a pointer or a separate flag. 0 and “absent” are the same value for an int.

Frequently asked questions

What is the difference between int and int64?

int is platform-sized (64-bit on current platforms) and is a distinct type from int64 even at the same width. Conversion between them must be explicit.

When should I use int rather than a sized type?

int for counts, indices and lengths, len() returns int. A sized type when the width is part of a contract: wire formats, file headers, database columns.

Why does 7 / 2 give 3?

Both operands are integers, so the division is integer division and truncates toward zero. Convert one operand to a float first.

Why is -7 % 2 equal to -1?

Go’s remainder takes the sign of the dividend. Languages that return 1 use a different definition; it matters for modular arithmetic on negative values.

Does integer overflow panic?

No, it wraps silently. Decrementing an unsigned zero yields the maximum value, which then propagates into loop bounds and lengths. Check before subtracting.

Why is 0.1 + 0.2 != 0.3?

IEEE-754 cannot represent those decimals exactly. Compare with a magnitude-scaled tolerance, and use integer minor units for money.

How do I test for NaN?

math.IsNaN(x). x == math.NaN() is always false because NaN is not equal to anything, itself included.

Why does len(“héllo”) return 6?

len counts bytes and the string is UTF-8. Use utf8.RuneCountInString for characters, or range to iterate code points.

Why does string(65) give “A”?

An integer converted to string is treated as a Unicode code point. Use strconv.Itoa(65) for the decimal text; go vet flags the mistake.

Can I use an int in an if condition?

No. if requires a bool and there is no truthiness or implicit conversion.

Where should I go next?

The Golang guides cover the rest of the language, and a beginner’s guide to packages covers how these files get organised.