Go · lesson 4 of 25
Data Types in Go
Go's basic types run for real: integer overflow, float precision and NaN, strings vs bytes vs runes, explicit conversions, strconv, and defined types.
By SkillByExample · Updated
Go is statically typed and never converts between types behind your back. These programs show where that strictness bites, and the few places — integer overflow, float rounding, string indexing — where Go does exactly what the hardware does instead of what you might expect.
Integer types and their limits
Go has signed int8…int64, unsigned uint8…uint64, and int/uint sized to the platform’s word.
package main
import (
"fmt"
"math"
"unsafe"
)
func main() {
fmt.Println(math.MinInt8, math.MaxInt8)
fmt.Println(math.MaxInt16, math.MaxInt32)
fmt.Println(math.MaxInt64, uint64(math.MaxUint64))
var n int
fmt.Println("int is", unsafe.Sizeof(n)*8, "bits here")
var small int8 = 127
small++
fmt.Println("127 + 1 as int8 =", small)
}
$ go run ints.go
-128 127
32767 2147483647
9223372036854775807 18446744073709551615
int is 64 bits here
127 + 1 as int8 = -128
int is 64 bits on any 64-bit platform, so it is the right default for counts and indexes. The last line is the surprise: incrementing an int8 past 127 wraps to -128 with no error. Go performs two’s-complement arithmetic at run time and never checks for overflow. math.MaxUint64 needs the uint64(...) conversion because, as an untyped constant, it doesn’t fit the int that Println would otherwise give it.
Overflow in a constant expression is different — the compiler evaluates it and refuses:
package main
import "fmt"
func main() {
var small int8 = 127 + 1
fmt.Println(small)
}
$ go run constoverflow.go
# command-line-arguments
./constoverflow.go:6:19: cannot use 127 + 1 (untyped int constant 128) as int8 value in variable declaration (overflows)
Numeric literals
Underscores separate digits for readability, and prefixes select the base. The value is the same number either way.
package main
import "fmt"
func main() {
population := 8_000_000_000
color := 0xFF5733
perm := 0o755
flags := 0b1010_1100
fmt.Println(population, color, perm, flags)
fmt.Printf("%x %o %b\n", color, perm, flags)
}
$ go run literals.go
8000000000 16734003 493 172
ff5733 755 10101100
The prefixes only affect how you write the literal; Println shows every value in decimal. %x, %o and %b format them back into hexadecimal, octal and binary.
Floating point
float64 (about 15–17 significant digits) is the default; float32 keeps about 7.
package main
import (
"fmt"
"math"
)
func main() {
fmt.Println(0.1+0.2 == 0.3) // constants: exact arithmetic
a, b := 0.1, 0.2
fmt.Println(a+b == 0.3, a+b) // variables: float64 arithmetic
var f32 float32 = 16777216
fmt.Println(f32+1 == f32)
zero := 0.0
nan := math.NaN()
fmt.Println(1/zero, -1/zero, nan == nan, math.IsNaN(nan))
}
$ go run floats.go
true
false 0.30000000000000004
true
+Inf -Inf false true
The first two lines differ even though they look identical. With literals, 0.1+0.2 is a constant expression evaluated exactly at compile time, so it equals 0.3. With variables the sum happens in binary float64, which cannot represent 0.1 exactly, giving 0.30000000000000004. Compare floats with a tolerance (math.Abs(x-y) < 1e-9), never ==. A float32 stops being able to represent consecutive integers above 2²⁴, so adding 1 changes nothing. Dividing a float by zero yields infinity rather than panicking, and NaN is the only value not equal to itself.
Booleans are not numbers
bool holds true or false, and no other type stands in for it.
package main
import "fmt"
func main() {
count := 1
if count {
fmt.Println("non-zero")
}
}
$ go run boolint.go
# command-line-arguments
./boolint.go:7:5: non-boolean condition in if statement
Write the comparison you mean: if count != 0. The same rule rejects if ptr for nil checks and !n on an integer.
Strings, bytes and runes
A string is an immutable sequence of UTF-8 bytes. byte (alias for uint8) is one of those bytes; rune (alias for int32) is one decoded Unicode code point.
package main
import "fmt"
func main() {
s := "Hi, 世界"
fmt.Println(len(s), len([]rune(s)))
fmt.Println(s[0], string(s[0]), s[4])
for i, r := range s {
fmt.Printf("%d:%c(U+%04X) ", i, r, r)
}
fmt.Println()
var b byte = 'A'
var r rune = '界'
fmt.Printf("%T %v | %T %v\n", b, b, r, r)
}
$ go run runes.go
10 6
72 H 228
0:H(U+0048) 1:i(U+0069) 2:,(U+002C) 3: (U+0020) 4:世(U+4E16) 7:界(U+754C)
uint8 65 | int32 30028
len counts bytes: each Chinese character takes three bytes in UTF-8, so the string is 10 bytes but 6 runes. Indexing gives a single byte — s[4] is 228, the first byte of 世, not the character. range decodes runes instead, and its index jumps from 4 to 7 because it reports byte offsets. The %T verb shows that byte and rune are really uint8 and int32. The strings lesson builds on this.
Conversions are always explicit
Mixing int and float64, or int and int64, is a compile error until you convert.
package main
import "fmt"
func main() {
count := 3
price := 1.25
total := count * price
fmt.Println(total)
}
$ go run convert.go
# command-line-arguments
./convert.go:8:11: invalid operation: count * price (mismatched types int and float64)
Fix it by converting one side — float64(count) * price gives 3.75. Converting the other way, int(2.99), truncates toward zero to 2.
For numbers to and from text, use strconv. The tempting string(n) does something else entirely:
package main
import (
"fmt"
"strconv"
)
func main() {
n := 65
fmt.Println(string(rune(n)), strconv.Itoa(n), fmt.Sprint(n))
x, err := strconv.Atoi("123")
fmt.Println(x+1, err)
_, err = strconv.Atoi("12a")
fmt.Println(err)
f, _ := strconv.ParseFloat("3.14159", 64)
fmt.Println(strconv.FormatFloat(f, 'f', 2, 64), strconv.FormatInt(255, 16))
}
$ go run strconv.go
A 65 65
124 <nil>
strconv.Atoi: parsing "12a": invalid syntax
3.14 ff
Converting an integer to string treats it as a code point, so 65 becomes "A"; written as string(n) without the rune(...), go vet rejects it with conversion from int to string yields a string of one rune, not a string of digits. strconv.Itoa gives the digits "65". Parsing returns an error you must check: Atoi("12a") reports exactly which input failed and why.
Defined types and aliases
type Meters float64 creates a new type with the same underlying representation; type Celsius = float64 is just another name.
package main
import "fmt"
type Meters float64
type Feet float64
type Celsius = float64 // alias: identical to float64
func toFeet(m Meters) Feet { return Feet(m * 3.28084) }
func main() {
d := Meters(100)
fmt.Println(toFeet(d))
var t Celsius = 21.5
var plain float64 = t // fine: same type
fmt.Println(plain)
var f Feet = d
fmt.Println(f)
}
$ go run defined.go
# command-line-arguments
./defined.go:19:15: cannot use d (variable of float64 type Meters) as Feet value in variable declaration
The alias assignment compiles because Celsius is float64. Meters to Feet doesn’t, even though both are float64 underneath — that is the point of defining them, since it catches unit mix-ups at compile time. Feet(d) would be the explicit conversion; toFeet is the version that also does the arithmetic.
Type sizes
| Type | Size | Range / notes |
|---|---|---|
bool | 1 byte | true / false |
int8 / uint8 (byte) | 1 byte | -128…127 / 0…255 |
int16 | 2 bytes | -32,768…32,767 |
int32 (rune) | 4 bytes | about ±2.1 billion |
int64 | 8 bytes | about ±9.2×10¹⁸ |
int / uint | 8 bytes on 64-bit | 4 bytes on 32-bit platforms |
float32 / float64 | 4 / 8 bytes | ~7 / ~15–17 significant digits |
complex64 / complex128 | 8 / 16 bytes | pairs of float32 / float64 |
string | 16-byte header | pointer + length to immutable bytes |
Putting it together
Text in, numbers out: parse, compute in float64, classify, format.
package main
import (
"fmt"
"strconv"
)
func classify(bmi float64) string {
switch {
case bmi < 18.5:
return "underweight"
case bmi < 25:
return "normal"
case bmi < 30:
return "overweight"
}
return "obese"
}
func main() {
for _, in := range [][2]string{{"70", "1.75"}, {"95", "1.80"}, {"heavy", "1.7"}} {
w, err := strconv.ParseFloat(in[0], 64)
if err != nil {
fmt.Println("skip:", err)
continue
}
h, _ := strconv.ParseFloat(in[1], 64)
bmi := w / (h * h)
fmt.Printf("%.1f %s\n", bmi, classify(bmi))
}
}
$ go run bmi.go
22.9 normal
29.3 overweight
skip: strconv.ParseFloat: parsing "heavy": invalid syntax
The third input fails to parse and is skipped with the error strconv built for it, instead of silently becoming 0.
Practice
1. Change ints.go to use a uint8 holding 0 and decrement it. What do you get?
Solution
package main
import "fmt"
func main() {
var u uint8 = 0
u--
fmt.Println(u)
}
$ go run p1.go
255
Unsigned integers wrap too: 0 minus 1 becomes the maximum value, 255. This is a classic bug when a length is stored unsigned and something subtracts from it.
2. Write a function almostEqual(a, b float64) bool with a tolerance of 1e-9, and use it to compare 0.1+0.2 (as variables) with 0.3.
Solution
package main
import (
"fmt"
"math"
)
func almostEqual(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
func main() {
a, b := 0.1, 0.2
fmt.Println(a+b == 0.3, almostEqual(a+b, 0.3))
}
$ go run p2.go
false true
The difference is about 5.5e-17, far below the tolerance.
3. Count the bytes and the runes in "naïve café" and print the byte offset of every non-ASCII rune.
Solution
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "naïve café"
fmt.Println(len(s), utf8.RuneCountInString(s))
for i, r := range s {
if r > 127 {
fmt.Println(i, string(r))
}
}
}
$ go run p3.go
12 10
2 ï
10 é
ï and é take two bytes each, so 10 runes occupy 12 bytes. utf8.RuneCountInString counts without allocating a []rune.
4. Fix convert.go so it prints 3.75, then print the total rounded down to a whole number of units.
Solution
package main
import "fmt"
func main() {
count := 3
price := 1.25
total := float64(count) * price
fmt.Println(total, int(total))
}
$ go run p4.go
3.75 3
int(total) truncates toward zero; use math.Round first if you want the nearest integer.