Go Variables, Types and Zero Values
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Go is statically typed and has no implicit conversion: int and int64 are different types and you must convert explicitly. That one rule removes a whole category of bugs, and it also means you have to pick the right type up front.
var versus :=
var count int // declared, zero value 0
var name string = "ann" // declared and initialised
count = 1 // assignment
age := 30 // short declaration, functions only
:= means “declare and assign”, with the type inferred from the right side. Three rules:
- Function scope only. At package level you must use
varorconst. - At least one variable on the left must be newly declared, otherwise it is a compile error.
- The inferred type is fixed.
age = "30"does not compile.
x := 1
x := 2 // compile error: no new variables on left side of :=
x = 2 // fine
The second rule is what makes n, err := f() work at the start of a function and n, err = f() work later in the same scope — the second call does not introduce a new variable.
Zero values: there is no “uninitialised”
Every variable has a defined initial value. There is no undefined state to reason about:
| Type | Zero value |
|---|---|
numeric (int, float64, …) |
0 |
string |
"" |
bool |
false |
| pointer, slice, map, channel, interface, func | nil |
| struct | each field set to its zero value |
var s string
var n int
var p *int
var m map[string]int
fmt.Println(s == "", n, p == nil, m == nil) // true 0 true true
Usable zero values are a design goal, not an accident. This struct works without a constructor:
type Counter struct {
total int
}
var c Counter // ready to use
c.total++
The exceptions to remember: a nil map is readable but not writable, while a nil slice accepts append directly. Both are covered in the slices and maps chapters.
Basic types
var (
i int = 42
i8 int8 = 127 // -128 .. 127
i64 int64 = 1 << 40
f float64 = 3.14
b bool = true
s string = "hello"
by byte = 'a' // byte is an alias for uint8
r rune = 'é' // rune is an alias for int32: one Unicode code point
)
int is platform-sized: 64 bits on any machine you are likely to deploy to, but not guaranteed. Use fixed-width types (int64, uint32) for anything serialised, hashed or compared across platforms, otherwise results can differ between architectures.
byte and rune are aliases, not distinct types, so []byte and []uint8 are the same type. Prefer the alias that communicates intent.
Constants and iota
const Pi = 3.14159
const AppName = "hello"
const (
StatusPending = iota // 0
StatusRunning // 1
StatusDone // 2
)
iota restarts at 0 in each const block and increments per line. Skip a value with _:
const (
_ = iota // discard 0
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20
GB // 1 << 30
)
Constants are untyped unless you say otherwise, so a single definition adapts to context:
const Max = 100
var a int = Max // ok
var b int64 = Max // also ok
That flexibility disappears the moment you write const Max int64 = 100 — now var a int = Max fails. Keep constants untyped unless you need the constraint.
Conversion is always explicit
var i int = 42
var f float64 = float64(i)
var u uint = uint(i)
fmt.Println(int(3.9)) // 3 — truncation, not rounding
Strings and numbers need the standard library; a cast will not do:
import "strconv"
s := strconv.Itoa(42) // "42"
n, err := strconv.Atoi("42") // 42, nil
f, _ := strconv.ParseFloat("3.14", 64)
Remember that string(65) is not "65" — it converts the code point 65 to the character "A":
fmt.Println(string(rune(65))) // A
fmt.Println(strconv.Itoa(65)) // 65
Since Go 1.15, go vet flags string(int) conversions for exactly this reason.
string, byte and rune
A Go string is an immutable byte sequence, conventionally UTF-8:
s := "héllo"
fmt.Println(len(s)) // 6 — bytes, not characters
fmt.Println(utf8.RuneCountInString(s)) // 5 — characters
for i, r := range s {
fmt.Println(i, r) // 0 104 / 1 233 / 3 108 ...
}
range over a string decodes UTF-8 for you: i is the byte offset of the character, r is the rune. That is why the index jumps from 1 to 3 after a two-byte character, and why indexing s[1] gives you the first byte of a character rather than the character.
Work with runes when you mean characters:
chars := []rune("你好")
fmt.Println(len(chars)) // 2
fmt.Println(string(chars[0])) // 你
Converting between string and []byte copies:
b := []byte("hello")
s := string(b)
For concatenation, + is fine for a handful of pieces. Inside a loop use strings.Builder, which grows a single buffer instead of allocating a new string per iteration:
var sb strings.Builder
for _, w := range []string{"a", "b", "c"} {
sb.WriteString(w)
}
fmt.Println(sb.String()) // abc
Common mistakes
1. Mixing int and int64
mismatched types int and int64. Convert explicitly; do not paper over it with int(...) everywhere.
2. Using string(n) to format a number
You get a code point. Use strconv.Itoa or fmt.Sprintf.
3. Treating len(s) as a character count
It is a byte count. Use utf8.RuneCountInString or len([]rune(s)) when characters are what you mean.
4. Redeclaring with := in the same scope
Compile error. Use = for assignment.
5. Building a string with + inside a loop
Quadratic allocations. Use strings.Builder.
6. Reading the zero value as “unset”
0 from a config parser is indistinguishable from a deliberate 0. Use a pointer or a separate set flag when that matters.
Summary
:=inside functions,var/constat package level;:=needs at least one new variable.- Every value has a zero value, so structs are usually usable as-is; nil maps are read-only.
- Use fixed-width types across platforms and serialisation boundaries; convert explicitly.
strconvfor number/string conversion;constplusiotafor enumerations.- Strings are bytes:
rangeor[]runefor characters,strings.Builderfor accumulation.
Next: control flow — one loop keyword, no parentheses, and a switch that breaks by default.