Skip to content

Variables & Types

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: 0
var active bool // zero value: false
var count int = 10 // explicit initial value

Inside 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.

Every type in Go has a zero value — the value a variable holds before any assignment. There is no concept of an uninitialised variable.

TypeZero value
int, int8, int16, int32, int640
uint, uint8, uint16, uint32, uint640
float32, float640.0
string""
boolfalse
rune (alias for int32)0
byte (alias for uint8)0
array [N]Tan array of N zero values of T
structevery field set to its own zero value
interface (including error)nil
pointer, slice, map, channel, functionnil

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.

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 integer
var u uint = 100 // platform-sized unsigned integer
var i32 int32 = 2_147_483_647
var f64 float64 = 3.14159
var b byte = 255 // alias for uint8
var r rune = 'A' // alias for int32; holds a Unicode code point
var s string = "hello"
var ok bool = true

rune and byte are built-in type aliases, not distinct types. rune is int32; byte is uint8.

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.

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 int32

Compiling 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 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.

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 = 7
var y float64 = 2.5
// sum := x + y // compile error: mismatched types int and float64
sum := float64(x) + y // explicit conversion: 9.5
var n int = int(sum) // truncates: 9
s := strconv.Itoa(n) // int → string
back, _ := strconv.Atoi(s) // string → int (error ignored with _)

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 = 127
small++
fmt.Println(small) // -128, not a compile error or a panic

The 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)) - 1
fmt.Println(lastIndex) // 18446744073709551615 on a 64-bit platform, not -1

len 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.

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 position i, not a character.
  • range s yields 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-8
fmt.Println(len(word)) // 6 — byte length, not 5 characters
fmt.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
len(word) counts 6 bytes; range word visits 5 runes at byte offsets 0, 1, 3, 4, 5

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.

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)
}
What you getBenefitCost
Static typingThe compiler catches type mismatches before runtimeMore type annotations to write
Zero valuesNo uninitialised variables, no null-dereference surpriseA nil pointer or nil map can still panic — the zero value is only safe for types designed to be immediately usable
No implicit conversionNo hidden precision lossYou must write float64(x) every time you convert
iota enumsConcise way to build enumerationsNo built-in string representation — you must write a String() method yourself
Fixed-width integersPredictable size and layoutOverflow wraps silently instead of panicking — no built-in bounds checking on arithmetic
  • “int is always 32-bit.” The size of int depends on GOARCH (amd64/arm64 = 64-bit, 386/arm = 32-bit), not the OS. Use int32 or int64 if you need a fixed size.
  • “var x = y and x := y are 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 in s.” It tells you the byte length. A string with multi-byte runes has more bytes than characters.

Generated code that touches variables and basic types tends to hide these exact bugs:

  • Does an if/for header use := with a name (often err) 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 (or uint32/uint64) ever produced by subtracting from len(...) or another value that could be 0? That’s the classic underflow-to-huge-number bug.
  • Does the code index a string with s[i] or compare len(s) against a “number of characters,” when the string might contain non-ASCII text?
  • Does an iota block 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.

What is the zero value of a string variable in Go?
Given const ( A = iota; B; C ), what is the value of C?
Where can the short declaration operator := NOT be used?
If step() always returns a non-nil error, what does run() return?
func run() error {
    var err error
    if v, err := step(); err != nil {
        fmt.Println(v, err)
    }
    return err
}
What does this print?
var small int8 = 127
small++
fmt.Println(small)
Does this compile?
const big = 1 << 62
var v int32 = int32(big)