Variables & Types
Declaring variables
Section titled “Declaring variables”Go has two ways to declare a variable. The var keyword works at both package scope and function scope:
var name string // zero value: ""var age int // zero value: 0var active bool // zero value: falsevar count int = 10 // explicit initial valueInside a function, the short declaration operator := infers the type from the right-hand side and is the idiomatic choice:
func main() { name := "Alice" // string score := 98.6 // float64 done := false // bool}:= cannot be used at package scope — only var works there. You also cannot use := if every variable on the left side was already declared (at least one must be new). That “at least one new” rule is exactly what makes accidental shadowing possible — see below.
Zero values
Section titled “Zero values”Every type in Go has a zero value — the value a variable holds before any assignment. There is no concept of an uninitialised variable.
| Type | Zero value |
|---|---|
int, int8, int16, int32, int64 | 0 |
uint, uint8, uint16, uint32, uint64 | 0 |
float32, float64 | 0.0 |
string | "" |
bool | false |
rune (alias for int32) | 0 |
byte (alias for uint8) | 0 |
array [N]T | an array of N zero values of T |
| struct | every field set to its own zero value |
interface (including error) | nil |
| pointer, slice, map, channel, function | nil |
This guarantee means you can always use a variable safely after declaration, even without an explicit initialiser. One nuance to keep in the back of your mind for later: nil itself is untyped, and the zero value of an interface variable is nil — but a nil concrete pointer stored inside an interface is not the same nil. That trap is covered in depth in Interfaces; for now, just know a var err error starts genuinely nil.
Basic types
Section titled “Basic types”Go’s numeric types are explicit about their size. The unsized int and uint match the platform word size (32 or 64 bits, decided by GOARCH, not the OS).
var i int = -100 // platform-sized signed integervar u uint = 100 // platform-sized unsigned integervar i32 int32 = 2_147_483_647var f64 float64 = 3.14159var b byte = 255 // alias for uint8var r rune = 'A' // alias for int32; holds a Unicode code pointvar s string = "hello"var ok bool = truerune and byte are built-in type aliases, not distinct types. rune is int32; byte is uint8.
Constants and iota
Section titled “Constants and iota”Constants are declared with const and evaluated at compile time. A const block with iota creates auto-incrementing integer sequences — the idiomatic Go way to define enumerations:
const Pi = 3.14159
type Direction int
const ( North Direction = iota // 0 East // 1 South // 2 West // 3)iota resets to zero at the start of each const block and increments by one for each constant specification within the block.
Untyped constants and constant arithmetic
Section titled “Untyped constants and constant arithmetic”Until a constant is used, it has no fixed type — it is an untyped constant that adapts to whatever numeric type the context requires, so the same const Pi = 3.14159 can be assigned directly to a float32 or a float64 variable with no conversion needed. This is also why const Timeout = 5 * time.Second works: 5 is an untyped integer constant, the multiplication happens at compile time, and the untyped result takes on time.Duration’s type only when it is assigned.
Untyped constants are computed with arbitrary precision at compile time, which lets you write things that would overflow any real machine type — as long as you never convert them to one that’s too small:
const big = 1 << 62 // fine — an untyped constant, not yet any concrete int size_ = int32(big) // compile error: constant 4611686018427387904 overflows int32Compiling the second line with Go 1.26.5 gives exactly that message. The overflow is caught at compile time precisely because big is untyped — the compiler knows its exact value and knows it cannot fit in an int32.
iota beyond +1
Section titled “iota beyond +1”iota is not limited to “increment by one” — it is just a counter you can use in any expression, and you can skip values with the blank identifier:
const ( _ = iota // skip 0 KB = 1 << (10 * iota) // 1 << 10 == 1024 MB // 1 << 20 == 1048576 GB // 1 << 30 == 1073741824)Each subsequent line without an explicit expression repeats the previous line’s expression with the new iota substituted in. A new const ( ... ) block always resets iota back to 0, regardless of what the previous block ended on.
Explicit type conversions
Section titled “Explicit type conversions”Go has no implicit numeric conversions. To use a value of one numeric type where another is expected, you must convert explicitly using the target type as a function:
var x int = 7var y float64 = 2.5
// sum := x + y // compile error: mismatched types int and float64sum := float64(x) + y // explicit conversion: 9.5
var n int = int(sum) // truncates: 9
s := strconv.Itoa(n) // int → stringback, _ := strconv.Atoi(s) // string → int (error ignored with _)Integer overflow and uint underflow
Section titled “Integer overflow and uint underflow”Go’s fixed-width integers wrap around silently on overflow — there is no panic, no error, just a value that quietly went past its range:
var small int8 = 127small++fmt.Println(small) // -128, not a compile error or a panicThe same thing happens at the bottom of an unsigned type’s range. This is a common bug when computing an index from len(...): subtracting from an unsigned value that could be zero wraps around to a huge positive number instead of going negative:
s := ""var lastIndex uint = uint(len(s)) - 1fmt.Println(lastIndex) // 18446744073709551615 on a 64-bit platform, not -1len itself returns a signed int, so len(s)-1 on an int is a perfectly normal -1 — the bug only appears once that value is stored into (or passed as) an unsigned type. Watch for uint, uint32, or similar showing up in size/index/count arithmetic near zero.
Strings are bytes
Section titled “Strings are bytes”A Go string is a read-only sequence of bytes, usually (but not necessarily) holding UTF-8 text. len, indexing with s[i], and range all operate on it, but they don’t agree on what a single “character” is:
len(s)counts bytes.s[i]yields the byte at positioni, not a character.range syields runes (Unicode code points) and the byte offset where each one starts — which is why the index sequence has gaps for multi-byte characters.
word := "héllo" // é encodes to 2 bytes in UTF-8fmt.Println(len(word)) // 6 — byte length, not 5 charactersfmt.Println(word[1]) // 195 — one byte of é's two-byte encoding, not 'é'for i, r := range word { fmt.Printf("byte offset %d: %q\n", i, r)}flowchart TB
subgraph Bytes["len(word) = 6 bytes"]
direction LR
B0["byte 0: 'h'"] --> B1["byte 1: 0xC3"] --> B2["byte 2: 0xA9"] --> B3["byte 3: 'l'"] --> B4["byte 4: 'l'"] --> B5["byte 5: 'o'"]
end
subgraph Runes["range word = 5 runes"]
direction LR
R0["offset 0: 'h'"] --> R1["offset 1: 'é'"] --> R2["offset 3: 'l'"] --> R3["offset 4: 'l'"] --> R4["offset 5: 'o'"]
end If you need the number of characters, use utf8.RuneCountInString(s) or convert with []rune(s) — both cost an O(n) scan, since Go does not precompute a rune count.
:= shadowing
Section titled “:= shadowing”Because := only requires one new variable on the left, it is easy to accidentally declare a fresh variable with the same name as one in an outer scope, instead of reusing it. This is especially dangerous with err inside an if:
func run() error { var err error if v, err := step(); err != nil { // := declares a NEW err here, shadowing the outer one fmt.Println("inner saw:", v, err) } return err // the outer err was never touched — still nil}step() fails, the if body correctly prints the error — but run() still returns nil, because the err checked and printed inside the if is a completely different variable from the err declared with var one line above. The compiler accepts this silently: both err variables are used, so there is no “declared and not used” error to warn you.
go vet does not catch this by default — the shadow check was pulled out of the default vet suite because it flags too many harmless cases. To catch it deliberately, install the separate analyzer and run it explicitly: go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest, then go vet -vettool=$(which shadow) ./.... golangci-lint also ships a govet setting that can enable the same shadow check. The safer habit, though, is to avoid := inside an if/for when a variable with the same name already exists in the enclosing scope — declare it with var outside, then use plain = inside.
package main
import "fmt"
type Weekday int
const ( Monday Weekday = iota + 1 Tuesday Wednesday Thursday Friday Saturday Sunday)
func main() { // Zero values var i int var s string var b bool fmt.Println("Zero int:", i) fmt.Println("Zero string:", s == "") fmt.Println("Zero bool:", b)
// Short declaration name := "Go" version := 1.27 fmt.Println("Language:", name, "Version:", version)
// iota enum fmt.Println("Monday =", Monday) fmt.Println("Wednesday =", Wednesday) fmt.Println("Sunday =", Sunday)
// Explicit type conversion meters := 100 kilometers := float64(meters) / 1000.0 fmt.Println("100 meters =", kilometers, "km")
// rune vs byte ch := 'Z' fmt.Println("rune 'Z' as int32:", int32(ch)) fmt.Println("byte value:", byte(ch))} The second playground below is a self-contained tour of everything on this page that can silently go wrong: shadowing, overflow, underflow, and strings-as-bytes.
package main
import ( "errors" "fmt" "unicode/utf8")
// step simulates an operation that can fail.func step() (int, error) { return 0, errors.New("boom")}
// run demonstrates := shadowing: the if-statement's err is a NEW variable,// not the outer one declared with var.func run() error { var err error if v, err := step(); err != nil { // shadows the outer err fmt.Println("inner saw:", v, err) } return err // outer err was never touched — still nil}
const ( _ = iota // skip 0 KB = 1 << (10 * iota) // 1 << 10 MB // 1 << 20 GB // 1 << 30)
func main() { // := shadowing: a classic silent bug. fmt.Println("run() returned:", run())
// Silent integer overflow: int8 wraps instead of panicking. var small int8 = 127 small++ fmt.Println("int8(127)+1 =", small)
// uint underflow: subtracting past zero wraps to a huge number. s := "" var lastIndex uint = uint(len(s)) - 1 fmt.Println("uint(len(\"\"))-1 =", lastIndex)
// Strings are bytes: len, indexing, and range disagree on multi-byte runes. word := "héllo" fmt.Println("len(word) =", len(word)) fmt.Println("word[1] =", word[1]) fmt.Println("rune count =", utf8.RuneCountInString(word)) for i, r := range word { fmt.Printf(" byte offset %d: %q\n", i, r) }
// iota beyond +1: bit-shift pattern for size constants. fmt.Println("KB, MB, GB =", KB, MB, GB)} Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Static typing | The compiler catches type mismatches before runtime | More type annotations to write |
| Zero values | No uninitialised variables, no null-dereference surprise | A nil pointer or nil map can still panic — the zero value is only safe for types designed to be immediately usable |
| No implicit conversion | No hidden precision loss | You must write float64(x) every time you convert |
iota enums | Concise way to build enumerations | No built-in string representation — you must write a String() method yourself |
| Fixed-width integers | Predictable size and layout | Overflow wraps silently instead of panicking — no built-in bounds checking on arithmetic |
Common Misconceptions
Section titled “Common Misconceptions”- “
intis always 32-bit.” The size ofintdepends onGOARCH(amd64/arm64 = 64-bit, 386/arm = 32-bit), not the OS. Useint32orint64if you need a fixed size. - “
var x = yandx := yare always the same.”:=only works at function scope — it cannot be used at package scope. - “The zero value of a pointer is 0.” The zero value of a pointer, slice, map, channel, or function is
nil, not the number 0. - “Overflowing an integer panics, like a nil dereference does.” It does not — Go’s fixed-width integers wrap around silently. Nothing stops at runtime to tell you.
- “
len(s)tells you how many characters are ins.” It tells you the byte length. A string with multi-byte runes has more bytes than characters.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated code that touches variables and basic types tends to hide these exact bugs:
- Does an
if/forheader use:=with a name (oftenerr) that already exists in the enclosing scope? Check whether it’s meant to reuse that variable or is silently shadowing it. - Does arithmetic on a fixed-width type (
int8,uint16, a byte count, …) ever approach its type’s minimum or maximum? There’s no automatic overflow check. - Is a
uint(oruint32/uint64) ever produced by subtracting fromlen(...)or another value that could be0? That’s the classic underflow-to-huge-number bug. - Does the code index a string with
s[i]or comparelen(s)against a “number of characters,” when the string might contain non-ASCII text? - Does an
iotablock start with a plain identifier instead of_when the zero-th value should be skipped or treated as “invalid”?
🐛 Spot the bug
func step() (int, error) { return 0, errors.New("boom")}
func run() error { var err error if v, err := step(); err != nil { fmt.Println("inner saw:", v, err) } return err}Show the answer
The if statement’s := declares a new err, scoped to the if block — it does not assign to the outer err declared with var. step() really does fail, and the fmt.Println inside the if really does print the error, but run() still returns nil, because the outer err was never reassigned. Fix it by not shadowing: v, err := step() using the already-declared err requires reusing = for err and := only where a name is genuinely new, e.g. write var v int; v, err = step(), or move the if check onto its own line after the assignment.