Variables & Constants
Every Go variable has a type, a value, a scope, a lifetime, and a home in memory. Master all declaration forms, understand zero values, and learn how Go decides whether a variable lives on the stack or the heap.
π― Learning Objectives
- Use all four Go variable declaration forms correctly
- Explain type inference and when to use explicit types
- Understand every zero value and why they exist
- Define constants and use
iotafor enumerations - Explain variable scope, shadowing, and how to avoid bugs from it
- Understand what escape analysis is and how it determines stack vs heap allocation
- Explain variable lifetime and what it means for GC
π Variable Declarations
Go has four ways to declare variables. Each has a specific use case.
Form 1: var with explicit type
package main
import "fmt"
// Package-level variable (lives for the program's lifetime)
var globalCounter int = 0
func main() {
// Function-level variable with explicit type
var name string = "Alice"
var age int = 30
var isAdmin bool = true
// Multiple variables in one var block (common for related variables)
var (
host string = "localhost"
port int = 8080
tls bool = false
)
fmt.Printf("name=%s age=%d admin=%v\n", name, age, isAdmin)
fmt.Printf("host=%s port=%d tls=%v\n", host, port, tls)
}
// When to use: package-level variables, when you want the type visible for clarity
Form 2: var with type inference
func main() {
// Go infers the type from the right-hand side
var name = "Alice" // inferred as string
var age = 30 // inferred as int
var pi = 3.14159 // inferred as float64
var done = false // inferred as bool
var letter = 'A' // inferred as int32 (rune)
// What type did Go infer? Use %T to find out
fmt.Printf("%T\n", name) // string
fmt.Printf("%T\n", age) // int
fmt.Printf("%T\n", pi) // float64
fmt.Printf("%T\n", letter) // int32
_ = done // suppress "declared and not used" error
}
Integer literals are inferred as int, NOT int64 or int32. Float literals are inferred as float64, NOT float32. If you need a specific numeric type, use explicit declaration: var x int32 = 42
Form 3: Short variable declaration :=
The most common form. Can only be used inside functions.
func main() {
// Short declaration β type inferred, cannot be used at package level
name := "Alice"
age := 30
pi := 3.14
// Multiple assignment
x, y := 10, 20
// Swap values elegantly
x, y = y, x // Note: = not := (both already declared)
fmt.Printf("%s is %d years old\n", name, age)
fmt.Println("pi =", pi)
fmt.Printf("x=%d y=%d (after swap)\n", x, y)
// Re-declaring with := (valid if at least ONE new variable on left)
val, err := doSomething() // both new
val, err = doSomethingElse() // neither new β use = not :=
newVal, err := doThird() // newVal is new, err is re-used β valid :=
_ = val; _ = newVal; _ = err
}
func doSomething() (int, error) { return 1, nil }
func doSomethingElse() (int, error) { return 2, nil }
func doThird() (int, error) { return 3, nil }
Form 4: Blank identifier _
package main
import (
"fmt"
"strconv"
)
func divide(a, b int) (int, int, error) {
if b == 0 {
return 0, 0, fmt.Errorf("division by zero")
}
return a / b, a % b, nil
}
func main() {
// Discard the remainder with _
quotient, _, err := divide(10, 3)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Quotient:", quotient) // 3
// Discard the error (NOT recommended in production β shown for illustration)
n, _ := strconv.Atoi("42")
fmt.Println(n) // 42
// _ can also discard import side effects:
// import _ "github.com/lib/pq" // registers PostgreSQL driver
}
π’ Zero Values β The Safety Net
In Go, every variable is initialized to its zero value automatically. There is no concept of an uninitialized variable. This is a deliberate safety feature β it prevents entire classes of bugs common in C/C++.
| Type | Zero Value | Why this zero value? |
|---|---|---|
bool | false | Safest default β nothing is active/enabled by default |
int, int8, int16, int32, int64 | 0 | Mathematical identity for addition |
uint, byte, uint8β¦ | 0 | Same as above |
float32, float64 | 0.0 | Mathematical identity |
complex64, complex128 | (0+0i) | Additive identity in complex numbers |
string | "" (empty string) | Empty, but never nil β safe to len() and iterate |
rune (int32) | 0 | The null character (U+0000) |
pointer | nil | Points to nothing β safe nil checks protect against crashes |
slice | nil | nil slice is safe: len(), cap(), range all work on nil slice |
map | nil | nil map can be read but NOT written β must make() before writing |
channel | nil | nil channel: send/receive block forever; close panics |
func | nil | Calling a nil function panics |
interface | nil | nil interface has no type and no value |
struct | All fields zeroed | Each field gets its own zero value recursively |
array | All elements zeroed | Same as struct β every element gets zero value |
package main
import "fmt"
type Config struct {
Host string // zero: ""
Port int // zero: 0
TLS bool // zero: false
Timeout float64 // zero: 0.0
Tags []string // zero: nil (but safe to use)
}
func main() {
var c Config
// No need to set every field β zero values are safe
fmt.Printf("Host: %q\n", c.Host) // ""
fmt.Printf("Port: %d\n", c.Port) // 0
fmt.Printf("TLS: %v\n", c.TLS) // false
fmt.Printf("Tags: %v (nil=%v)\n", c.Tags, c.Tags == nil) // [] (nil=true)
fmt.Printf("len(Tags): %d\n", len(c.Tags)) // 0 β safe on nil slice!
// Zero value makes sync.Mutex immediately usable:
// var mu sync.Mutex β this just works, no New() needed
}
A core Go design principle: types should be immediately usable at their zero value without
requiring explicit initialization. sync.Mutex, bytes.Buffer, and
sync.WaitGroup are great examples. When designing your own types, always ask:
"Is the zero value useful?"
π Constants & iota
Constants
Constants are values that are computed at compile time and never change at runtime. They can be typed or untyped β and untyped constants are more flexible than you might expect.
package main
import "fmt"
// Typed constant β has a fixed type
const MaxRetries int = 3
const Pi float64 = 3.14159265358979323846
// Untyped constant β extremely precise, type inferred at use
const E = 2.71828182845904523536 // untyped floating-point
const Greeting = "Hello" // untyped string
const Big = 1 << 62 // untyped integer (huge precision)
// Constant block
const (
StatusOK = 200
StatusNotFound = 404
StatusError = 500
)
// Constants can be expressions (evaluated at compile time)
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
TB = 1024 * GB
)
func main() {
fmt.Printf("1 KB = %d bytes\n", KB)
fmt.Printf("1 GB = %d bytes\n", GB)
fmt.Printf("Pi = %v\n", Pi)
// Untyped constant adapts to context
var x float32 = E // E (untyped) assigned to float32
var y float64 = E // E (untyped) assigned to float64
fmt.Printf("float32: %v, float64: %v\n", x, y)
}
iota β The Enumerator
iota is a compile-time counter that resets to 0 at each const block
and increments by 1 at each constant specification. It's Go's elegant solution for enums.
package main
import "fmt"
// Basic iota β 0, 1, 2, 3
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1 (iota is implicitly repeated)
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
// iota with arithmetic β bit flags (extremely common pattern)
type Permission uint
const (
Read Permission = 1 << iota // 1 << 0 = 1 (0001)
Write // 1 << 1 = 2 (0010)
Execute // 1 << 2 = 4 (0100)
Admin // 1 << 3 = 8 (1000)
)
// Skip a value with _
type Level int
const (
_ = iota // 0: unused/discard (sentinel)
Low Level = iota // 1
Mid // 2
High // 3
)
// iota in expressions
const (
_ = iota // 0: skip
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20 = 1048576
GB // 1 << 30 = 1073741824
)
func (d Weekday) String() string {
names := [...]string{"Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"}
if d < Sunday || d > Saturday {
return fmt.Sprintf("Weekday(%d)", d)
}
return names[d]
}
func main() {
today := Wednesday
fmt.Println("Today is", today) // Today is Wednesday
// Combining permissions with bitwise OR
myPerms := Read | Write
fmt.Printf("Permissions: %04b (%d)\n", myPerms, myPerms) // 0011 (3)
// Checking permissions with bitwise AND
if myPerms&Write != 0 {
fmt.Println("Has write permission β")
}
if myPerms&Execute == 0 {
fmt.Println("No execute permission β")
}
fmt.Printf("1KB = %d, 1MB = %d, 1GB = %d\n", KB, MB, GB)
}
π Variable Scope
Scope defines where in the source code a variable is visible and accessible.
Go uses lexical scoping β the scope is determined by the block
(pair of {}) in which the variable is declared.
Universe scope
βββ Package scope (var/const/func declared outside any function)
βββ File scope (import declarations)
βββ Function scope
βββ Block scope (if/for/switch bodies)
βββ Deeper blocks (nested if/for)
package main
import "fmt"
var packageLevel = "I am visible throughout this package" // package scope
func scopeDemo() {
funcLevel := "I am visible in this function" // function scope
{ // inner block
blockLevel := "I am only visible in this block"
fmt.Println(blockLevel)
fmt.Println(funcLevel) // can access outer scope
fmt.Println(packageLevel) // can access package scope
}
// fmt.Println(blockLevel) // β compile error: undefined: blockLevel
// if statement creates its own scope
if x := computeValue(); x > 0 { // x is scoped to this if block!
fmt.Println("positive:", x)
}
// fmt.Println(x) // β compile error: undefined: x (out of if scope)
// for loop variable scope
for i := 0; i < 3; i++ {
// i is scoped to the for loop
_ = i
}
// fmt.Println(i) // β compile error
fmt.Println(funcLevel)
}
func computeValue() int { return 42 }
func main() {
scopeDemo()
}
Variable Shadowing β A Common Bug Source
Shadowing occurs when an inner variable declaration uses the same name as an outer one, "hiding" the outer variable. This is legal Go but a common source of bugs.
package main
import "fmt"
func shadowingBug() {
err := doStep1() // outer err
if true {
result, err := doStep2() // β THIS SHADOWS outer err!
// A new 'err' is created inside this block.
// The outer 'err' is still the one from doStep1.
fmt.Println(result, err)
}
// Bug: you might think err was updated above, but it wasn't
if err != nil { // β this checks the ORIGINAL err from doStep1
fmt.Println("Error from step 1:", err)
}
}
// The correct way β use = not := to update the outer variable:
func correctVersion() {
var result int
err := doStep1()
if true {
var e error
result, e = doStep2() // assign to existing variables
if e != nil {
err = e // explicitly update the outer err
}
fmt.Println(result)
}
if err != nil {
fmt.Println("Error:", err)
}
}
// Another common shadowing trap: err shadowed in a loop
func loopShadowing() {
var outerErr error
for i := 0; i < 3; i++ {
// This := creates a new err each iteration, doesn't update outerErr
_, err := doSomethingN(i)
if err != nil {
outerErr = err // must explicitly assign to outer
}
}
if outerErr != nil {
fmt.Println("Loop error:", outerErr)
}
}
func doStep1() error { return nil }
func doStep2() (int, error) { return 0, nil }
func doSomethingN(int) (int, error) { return 0, nil }
func main() {
shadowingBug()
correctVersion()
loopShadowing()
}
The most dangerous shadowing pattern: using := in a block when you mean to update
the outer err. The code compiles fine. The outer error is silently never updated.
Use go vet with the shadow analyser to detect this.
# Install and use the shadow analyser
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
shadow ./...
π§ Stack vs Heap β Where Variables Live
When you declare a variable in Go, the compiler decides whether it should live on the stack (fast, automatic cleanup) or the heap (slower, GC-managed). This decision is called escape analysis.
The Stack
- Each goroutine has its own stack (starts at 2β8 KB, grows/shrinks as needed)
- Stack allocation: simply decrements the stack pointer β essentially free
- Variables on the stack are automatically freed when the function returns
- No GC involvement
- Variables on the stack cannot outlive the function that created them
The Heap
- Shared memory pool managed by the Go runtime
- Allocation requires the memory allocator to find a free slot β slower
- Variables on the heap live until the GC determines no references exist
- GC overhead increases with heap size
Escape Analysis β How the Compiler Decides
The Go compiler runs escape analysis to determine where each variable should be allocated. A variable "escapes to the heap" if it can outlive the function that created it.
package main
// Case 1: Variable stays on stack β doesn't escape
func noEscape() int {
x := 42 // x is used only within this function
return x // returning the VALUE, not a pointer β x stays on stack
}
// Case 2: Variable escapes to heap β returned pointer
func escapes() *int {
x := 42 // x must live beyond this function's return
return &x // returning a POINTER β x escapes to heap
}
// Case 3: Escapes via interface β common source of heap allocations
func escapesViaInterface() interface{} {
x := 42
return x // Storing in interface{} causes escape in most cases
}
// Case 4: Escapes via slice/map storage
func escapesViaSlice() {
s := make([]int, 10)
for i := range s {
val := i * 2
s[i] = val // val might escape if slice escapes
}
_ = s
}
func main() {
a := noEscape() // a on stack
b := escapes() // *b on heap, b (the pointer) on stack
_ = a; _ = b
}
# Inspect escape analysis decisions
go build -gcflags="-m" main.go
# Output will tell you what escaped:
# ./main.go:11:2: x escapes to heap
# ./main.go:6:2: x does not escape
# ./main.go:17:2: x escapes to heap (interface conversion)
# More verbose output:
go build -gcflags="-m -m" main.go
Heap allocations are more expensive than stack allocations and create work for the GC. High-performance Go code minimises unnecessary heap allocations. But don't prematurely optimise β write clear code first, then use profiling to find real bottlenecks.
Variable Lifetime
Lifetime is the interval during which a variable is guaranteed to exist. The compiler tracks this precisely:
- Stack variables: Lifetime = duration of the function call that created them
- Heap variables: Lifetime = from allocation until the GC determines no more references exist (reachability-based)
- Package-level variables: Lifetime = entire program duration
β Best Practices
- Prefer
:=inside functions β it's idiomatic and concise - Use
varat package level or when you want to declare a variable without an initializer - Use
varwhen the zero value is a meaningful "empty/not-set" state - Never ignore errors β
_, err := f()is fine if you checkerr; ignoring both is a bug - Keep variable declarations close to their first use β don't declare at top of function like in C
- Name constants clearly β
MaxRetriesnotMAX_RETRIES(Go style is MixedCaps) - Use
iotafor sequential enum-like constants; use explicit values when order matters for serialisation - Be alert to shadowing β read your code for inner
:=that might shadow outer variables
π« Common Mistakes
package main
// β MISTAKE 1: Declared variable never used
func unused() {
x := 42 // compile error: x declared and not used
}
// β MISTAKE 2: Assuming := updates an outer variable
func shadowErr() error {
err := doSomething()
if true {
result, err := doMore() // BUG: shadows outer err
_ = result; _ = err
}
return err // this is the original err, not the one from doMore()
}
// β MISTAKE 3: Thinking short int literals are int64
func intSizeAssumption() {
x := 42 // x is int (platform-dependent: 32 or 64 bits)
var y int64 = x // compile error: cannot use x (int) as int64
_ = y
}
// β
FIX for mistake 3:
func intSizeFixed() {
x := int64(42) // explicit conversion
var y int64 = 42 // or explicit type declaration
_, _ = x, y
}
// β MISTAKE 4: Modifying a nil map
func nilMap() {
var m map[string]int // m is nil
m["key"] = 1 // PANIC: assignment to entry in nil map
}
// β
FIX:
func nilMapFixed() {
m := make(map[string]int) // initialise with make
m["key"] = 1 // now safe
}
func doSomething() error { return nil }
func doMore() (int, error) { return 0, nil }
func main() {}
π€ Interview Questions
var x int and x := 0?Both declare an integer variable x with value 0. The differences are:
Syntax: var x int uses the long form β explicit type, initialised to zero value. x := 0 uses the short declaration β type inferred, can only appear inside functions.
Location: var x int can appear at package level; := cannot.
Idiom: var x int is preferred when the zero value is your intentional starting value and you want that intent to be visible in the code. x := 0 is most common inside functions when type inference makes the code cleaner.
Common interview trap: "Are they the same?" β Almost, but var x int is 100% valid at package scope; x := 0 is a compile error at package scope.
Escape analysis is a compile-time analysis that determines where each variable should be allocated β stack or heap. It's performed by the Go compiler.
A variable "escapes to the heap" when it must outlive the function that creates it. Common reasons: (1) a pointer to the variable is returned; (2) a pointer is stored in an interface; (3) a pointer is stored in a data structure that itself escapes; (4) the variable is captured by a closure; (5) the variable's size is not known at compile time.
Stack allocation is faster (no GC involvement), heap allocation enables longer lifetimes but requires GC. You can inspect escape analysis decisions with: go build -gcflags="-m"
Production relevance: In hot code paths, excessive heap allocations cause GC pressure, increasing latency. Profiling with pprof and the allocs profile reveals this.
Shadowing occurs when a variable declared in an inner scope has the same name as one in an outer scope, effectively hiding the outer variable within the inner scope.
Classic bug: using := in an if block to handle an error when you meant to update the outer error variable. The outer error remains unchanged, the inner one is discarded. The code compiles perfectly β the bug only manifests at runtime.
Detection: Use go vet with the shadow analyser, or configure golangci-lint to report shadowing.
Why interviewers ask: Shadowing is responsible for real production bugs. Knowing about it demonstrates you write defensive code.
iota? How do you create bit flags with it?iota is a compile-time incrementing counter available inside const blocks. It starts at 0 and increments by 1 for each constant in the block.
Bit flags: use 1 << iota to generate powers of two, which can be combined with bitwise OR and tested with bitwise AND. This is the standard Go pattern for permission flags, feature flags, and option bitmasks.
Example: Read = 1 << iota gives 1; Write gives 2; Execute gives 4. These can be combined: Read | Write = 3. Tested: (perms & Write) != 0.
π§ͺ Quiz
:= be used?string in Go?x := 42?iota have at the third constant in a const block?βοΈ Exercises
Write a program that declares at least one variable using each of the four forms (var type, var inference, :=, blank _), and prints the type of each using %T.
package main
import "fmt"
func main() {
var a int = 10 // explicit type
var b = "hello" // type inference with var
c := 3.14 // short declaration
d, _ := 42, "discard" // _ discards second value
fmt.Printf("a: %T = %v\n", a, a)
fmt.Printf("b: %T = %v\n", b, b)
fmt.Printf("c: %T = %v\n", c, c)
fmt.Printf("d: %T = %v\n", d, d)
}
Create a custom type StatusCode and use iota to define constants
for: StatusOK=200, StatusCreated=201, StatusBadRequest=400,
StatusUnauthorized=401, StatusNotFound=404, StatusInternalServerError=500.
Implement a String() method.
package main
import "fmt"
type StatusCode int
const (
StatusOK StatusCode = 200
StatusCreated StatusCode = 201
StatusBadRequest StatusCode = 400
StatusUnauthorized StatusCode = 401
StatusNotFound StatusCode = 404
StatusInternalServerError StatusCode = 500
)
func (s StatusCode) String() string {
switch s {
case StatusOK: return "200 OK"
case StatusCreated: return "201 Created"
case StatusBadRequest: return "400 Bad Request"
case StatusUnauthorized: return "401 Unauthorized"
case StatusNotFound: return "404 Not Found"
case StatusInternalServerError: return "500 Internal Server Error"
default: return fmt.Sprintf("Unknown(%d)", int(s))
}
}
func main() {
codes := []StatusCode{StatusOK, StatusNotFound, StatusInternalServerError}
for _, c := range codes {
fmt.Println(c)
}
}
// 200 OK
// 404 Not Found
// 500 Internal Server Error
Write three functions: one that returns a value (no escape), one that returns a pointer (escape),
and one that stores a value in a slice (check if it escapes). Use
go build -gcflags="-m" to observe the compiler's escape analysis output.
package main
// noEscape: x stays on stack
func noEscape() int {
x := 42
return x // value copy, not pointer
}
// escapes: x goes to heap
func escapes() *int {
x := 42
return &x // pointer returned β x escapes
}
// mayEscape: depends on what happens to the slice
func sliceEscape() []int {
s := make([]int, 3) // slice backing array may escape
s[0] = 1
return s // returning slice β backing array escapes
}
func main() {
a := noEscape()
b := escapes()
c := sliceEscape()
_, _, _ = a, b, c
}
// Build with: go build -gcflags="-m" main.go
// Output:
// ./main.go:5:2: x does not escape
// ./main.go:11:2: x escapes to heap
// ./main.go:17:15: make([]int, 3) escapes to heapπ Summary
- Four declaration forms:
var T,var inferred,:=, blank_ - Every Go variable gets its zero value β no uninitialised variables
- Constants are compile-time values;
iotacreates sequential enums and bit flags - Scope is lexical: inner blocks see outer names; outer blocks cannot see inner names
- Shadowing: inner
:=can silently hide outer variable β watch for err shadowing - Stack: fast, goroutine-local, auto-freed on function return
- Heap: GC-managed, used when variable must outlive its function
- Escape analysis: use
go build -gcflags="-m"to see compiler decisions
π Cheat Sheet
var x int = 42var x = 42x := 42_, err := f()const Max = 100A = iota // 0, B // 1...R = 1 << iota // 1,2,4...""0falsenilgo build -gcflags="-m"