Basic Types & Variables
Go's type system is simple and explicit. There are no implicit conversions, no union types, and no type coercion surprises. What you declare is what you get. This chapter covers the building blocks: primitive types, variables, constants, and the collection types you will use every day.
Primitive Types
Numeric Types
var i int // Platform-dependent: 64-bit on 64-bit systems
var i8 int8 // -128 to 127
var i32 int32 // -2 billion to 2 billion
var i64 int64 // Very large range
var u uint // Unsigned, platform-dependent
var f64 float64 // Double precision (the default for float literals)
In practice, use int for most integer work and float64 for floating-point. Use sized types (int32, int64) when you need a specific size for binary protocols or serialization.
Strings, Bytes & Runes
var s string // UTF-8 encoded, immutable
var b byte // Alias for uint8
var r rune // Alias for int32, represents a Unicode code point
Strings in Go are immutable sequences of bytes. A byte is a single byte. A rune is a Unicode code point, which can be 1-4 bytes in UTF-8.
s := "Hello, 世界"
fmt.Println(len(s)) // 13 (bytes, not characters)
fmt.Println(len([]rune(s))) // 9 (rune count)
for i, r := range s {
fmt.Printf("byte %d: %c (U+%04X)\n", i, r, r)
}
byte 0: H (U+0048)
byte 1: e (U+0065)
byte 2: l (U+006C)
byte 3: l (U+006C)
byte 4: o (U+006F)
byte 5: , (U+002C)
byte 6: (U+0020)
byte 7: 世 (U+4E16)
byte 10: 界 (U+754C)
Notice the byte index jumps from 7 to 10 -- the Chinese characters are 3 bytes each in UTF-8.
Bool
var b bool // false (zero value)
done := true
No truthy/falsy values. if 1 does not compile. Only bool expressions work in conditions.
Variable Declaration
Short Declaration (:=)
Inside functions, := declares and initializes a variable with an inferred type.
name := "Alice" // string
count := 42 // int
ratio := 3.14 // float64
active := true // bool
items := []string{} // []string
var Declaration
Use var at the package level, when you want the zero value, or when you need a specific type.
var name string // "" (zero value)
var count int // 0
var ratio float32 = 3.14 // Explicit type (float32, not float64)
// Grouped declaration
var (
host = "localhost"
port = 8080
timeout = 30 * time.Second
)
Zero Values
Every type in Go has a zero value. Variables are never uninitialized.
var i int // 0
var f float64 // 0.0
var s string // ""
var b bool // false
var p *int // nil
var sl []int // nil
var m map[string]int // nil
Type Conversion
Go never converts types implicitly. You must be explicit about every conversion.
var i int = 42
var f float64 = float64(i) // Must convert explicitly
var u uint = uint(f) // Must convert explicitly
// This does not compile:
// var f float64 = i // cannot use i (type int) as type float64
String conversions follow specific rules:
// int to string: does NOT give you "65"
s := string(65) // "A" (treats 65 as a rune)
// For number-to-string conversion, use strconv
s := strconv.Itoa(65) // "65"
n, err := strconv.Atoi("65") // 65, nil
// Byte slice to string and back
bs := []byte("hello")
s := string(bs)
Constants & iota
Constants are declared with const and must be determinable at compile time.
const pi = 3.14159
const maxRetries = 3
const greeting = "hello"
iota for Enumerations
iota is a constant generator that starts at 0 and increments for each constant in a group. It is Go's answer to enums.
type Role int
const (
RoleGuest Role = iota // 0
RoleUser // 1
RoleAdmin // 2
)
iota supports expressions: 1 << (10 * (iota + 1)) gives you KB, MB, GB, TB constants.
Arrays
Arrays have a fixed size, determined at compile time. They are value types -- assigning an array copies all its elements.
var a [5]int // [0, 0, 0, 0, 0]
b := [3]string{"a", "b", "c"} // Fixed size 3
c := [...]int{1, 2, 3, 4} // Size inferred from elements: [4]int
Arrays are rarely used directly. Their fixed size makes them inflexible. You will use slices instead.
Slices
Slices are dynamic, reference-backed views over arrays. They are what you use for ordered collections in Go.
// Create slices
s := []int{1, 2, 3} // Literal
s := make([]int, 5) // Length 5, all zeros
s := make([]int, 0, 100) // Length 0, capacity 100
// Append
s = append(s, 4, 5, 6)
s = append(s, otherSlice...) // Append another slice
// Slicing
sub := s[1:3] // Elements at index 1, 2 (not 3)
sub := s[:3] // First 3 elements
sub := s[2:] // From index 2 to end
// Length and capacity
fmt.Println(len(s)) // Number of elements
fmt.Println(cap(s)) // Underlying array capacity
Slices Are References
A slice is a header containing a pointer to an underlying array, a length, and a capacity. Assigning a slice does not copy the data.
original := []int{1, 2, 3, 4, 5}
subset := original[1:3] // [2, 3] -- shares underlying array
subset[0] = 99
fmt.Println(original) // [1, 99, 3, 4, 5] -- original is modified
To get an independent copy, use the copy built-in or the slices.Clone function (Go 1.21+):
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)
// Or: dst := slices.Clone(src)
Nil Slices vs Empty Slices
A nil slice (var s []int) and an empty slice (s := []int{}) both have len(s) == 0 and both work with append, range, and len. Prefer nil slices unless you specifically need non-nil (e.g., JSON serialization where null vs [] matters).
Maps
Maps are unordered key-value collections. Keys must be comparable types (no slices or maps as keys).
// Create maps
m := map[string]int{
"alice": 32,
"bob": 28,
}
m := make(map[string]int) // Empty map, ready to use
// Read, write, delete
m["charlie"] = 35
age := m["alice"] // 32
delete(m, "bob")
// Check existence with the comma-ok idiom
age, ok := m["dave"]
if !ok {
fmt.Println("dave not found")
}
A nil map can be read from (returns zero values) but writing to a nil map panics:
var m map[string]int
fmt.Println(m["key"]) // 0 (zero value, no panic)
m["key"] = 1 // PANIC: assignment to entry in nil map
Always initialize maps with make or a literal before writing.
make vs new
These two built-in functions serve different purposes and are a common source of confusion.
// make: initializes slices, maps, and channels
s := make([]int, 10) // Slice with length 10
m := make(map[string]int) // Empty, initialized map
ch := make(chan int, 5) // Buffered channel
// new: allocates memory and returns a pointer
p := new(int) // *int, pointing to 0
fmt.Println(*p) // 0
In practice, make is used constantly (for slices, maps, and channels). new is rarely used because struct literals with & are more idiomatic:
// Prefer this:
cfg := &Config{Port: 8080}
// Over this:
cfg := new(Config)
cfg.Port = 8080
Common Pitfalls
- Assuming string indexing gives characters.
s[0]gives a byte, not a rune. For Unicode-safe iteration, userangeor[]rune(s). - Forgetting that slice operations share memory. Modifying a sub-slice modifies the original. Use
copyorslices.Clonewhen you need independence. - Writing to a nil map. Reading a nil map returns zero values silently. Writing to one panics. Always initialize with
makeor a literal. - Implicit type conversions from other languages.
int + float64does not compile. You must convert one side explicitly. - Using iota without a type. Untyped iota constants are just
int. Define a named type for your enum to get type safety. - Comparing slices with ==. Slices cannot be compared with
==(except to nil). Useslices.Equalfrom Go 1.21+ or write a loop.
Key Takeaways
- Go has a small set of built-in types: integers, floats, strings, bools, bytes, and runes.
- Use
:=for concise declarations inside functions,varfor package-level or zero-value declarations. - All type conversions are explicit. Go does not coerce types silently.
iotagenerates enumeration constants. Pair it with a named type for type safety.- Slices are dynamic and reference-based; arrays are fixed and value-based. Use slices.
- Maps must be initialized before writing. Use
makeor a literal. makecreates slices, maps, and channels.newallocates a pointer. Prefer struct literals with&overnew.