Variables #
Variables in Go look simple on the surface — there’s var and there’s :=. But behind that simplicity lie design decisions with real implications: when you must use var, when you should use :=, how zero values set Go apart from other languages, and how variable shadowing can become a very subtle source of bugs. Understanding variables in Go deeply means understanding how the Go compiler thinks — and that means you’ll fight the compiler less often and work with it more often.
Zero Values — Go’s Safety Foundation #
Before talking about how to declare variables, there’s one concept that sets Go apart from almost every other language: the zero value. In C, a newly declared variable holds an unpredictable value from memory — one of the biggest bug sources in programming history. In Go, every variable declared without an explicit value gets a well-defined, consistent initial value.
package main
import "fmt"
func main() {
var i int // → 0
var i8 int8 // → 0
var i64 int64 // → 0
var u uint // → 0
var f32 float32 // → 0.0
var f64 float64 // → 0.0
var b bool // → false
var s string // → "" (empty string, not null, not undefined)
var p *int // → nil (nil pointer)
var sl []int // → nil (nil slice, not an empty slice)
var m map[string]int // → nil (nil map)
var fn func() // → nil (nil function)
var iface interface{} // → nil
fmt.Println(i, i8, i64, u, f32, f64) // 0 0 0 0 0 0
fmt.Println(b) // false
fmt.Println(s) // (empty string)
fmt.Println(p) // <nil>
fmt.Println(sl) // []
fmt.Println(m) // map[]
fmt.Println(fn) // <nil>
}
Practical Implications of Zero Values #
Zero values let you write cleaner code because you don’t need defensive initialization:
// In C/Java — you need explicit initialization for "safe" values
int counter = 0;
String message = "";
List<String> items = new ArrayList<>();
// In Go — the zero value already gives you something useful
var counter int // already 0, ready for counter++
var message string // already "", ready for message += "text"
var items []string // nil slice, ready for append(items, "item")
Zero values also apply to structs — all fields get their respective types’ zero values:
type Config struct {
Host string
Port int
Debug bool
Timeout time.Duration
}
var cfg Config
// cfg.Host = ""
// cfg.Port = 0
// cfg.Debug = false
// cfg.Timeout = 0 (zero duration)
// This is valid and useful — a Config with all defaults
Four Ways to Declare Variables #
Go provides four ways to declare a variable, each with the right context for its use. Choosing correctly isn’t just about taste — it’s communicating intent to the developers reading the code. Visually, the decision flow for choosing the right variable declaration style in Go can be illustrated as follows:
flowchart TD
Start["Variable Declaration Need"] --> ScopeCheck{"Where is the variable declared?"}
ScopeCheck -->|"Outside a Function (Package-Level)"| MustVar["Must use the var keyword"]
ScopeCheck -->|"Inside a Function (Local Variable)"| InitialValue{"Is there an initial value?"}
MustVar --> VarNoInit["No Value: var name Type"]
MustVar --> VarWithInit["With Value: var name Type = value"]
InitialValue -->|"No (Use the Zero Value)"| VarNoInit
InitialValue -->|"Yes"| TypeMatch{"Does the type need to be precise/different\nfrom the default type inference?"}
TypeMatch -->|"Yes"| VarExplicit["var name Type = value\nor\nname := Type(value)"]
TypeMatch -->|"No (Use Type Inference)"| Shorthand["Shorthand Style:\nname := value"]Style 1: Full Declaration with var
#
The most explicit format — stating the name, type, and value explicitly:
var name string = "Budi Santoso"
var age int = 28
var salary float64 = 8500000.0
var active bool = true
When to use it: when you want to be very explicit about the type, usually in code that needs to be crystal clear for new readers.
Style 2: var with Zero Value (No Initialization)
#
Declaration without a value — the variable gets its type’s zero value:
var total float64 // will be accumulated later
var errorCount int // counted during processing
var lastError error // will be filled if an error occurs
var resultBuffer bytes.Buffer // empty buffer, ready to use
When to use it: when you know you’ll fill in the value later, or when the zero value itself is what you want. It’s also a very clear way to communicate “this variable intentionally starts empty.”
Style 3: var with Type Inference
#
Go infers the type from the given value:
var name = "Budi" // type: string (inferred)
var age = 28 // type: int (inferred)
var pi = 3.14159 // type: float64 (inferred)
var active = true // type: bool (inferred)
var limit = int64(1000) // type: int64 (explicit via conversion)
Style 4: Short Variable Declaration :=
#
The most concise style, and the most frequently used inside functions:
func processOrder(orderID int) error {
order := getOrder(orderID) // type inferred from the return type
total := calculateTotal(order) // declare and assign in one go
err := saveOrder(order) // err is an error
if err != nil {
return fmt.Errorf("processOrder: %w", err)
}
fmt.Printf("Order %d processed, total: %.2f\n", orderID, total)
return nil
}
:= can’t be used at package level (outside functions) — a deliberate restriction because assignment at package level can have hard-to-predict side effects.
var vs := — A Complete Guide to When to Use Which #
This is the most frequently asked question by developers new to Go. The answer isn’t “always use :=” or “always use var” — it depends on context.
Use var For:
#
1. Package-level declarations:
package main
// Only var can be used here
var (
db *sql.DB
redisClient *redis.Client
appConfig Config
)
// func main() { ... } — := is only valid inside functions
2. When the zero value is what you want, and you want to communicate that explicitly:
func processData(items []Item) ([]Result, error) {
var results []Result // intentionally starting empty
var lastErr error // will be filled if an error occurs
for _, item := range items {
result, err := processItem(item)
if err != nil {
lastErr = err
continue
}
results = append(results, result)
}
return results, lastErr
}
3. When the type needs to be stated explicitly because it differs from the default inference:
// Float literals default to float64 — if you need float32:
var temperature float32 = 36.6
// Int literals default to int — if you need int64:
var fileSize int64 = 10 * 1024 * 1024 * 1024 // 10 GB
// Interfaces — you can't use := for a typed nil interface
var r io.Reader // r is an io.Reader with a nil value
r = os.Stdin // assign later
4. Declaring several related variables together:
var (
serverHost = "localhost"
serverPort = 8080
maxConns = 100
readTimeout = 30 * time.Second
writeTimeout = 30 * time.Second
)
Use := For:
#
1. Almost all local variables inside functions:
func getUserProfile(userID int) (*Profile, error) {
user, err := db.GetUser(userID)
if err != nil {
return nil, fmt.Errorf("getUserProfile: %w", err)
}
posts, err := db.GetUserPosts(userID)
if err != nil {
return nil, fmt.Errorf("getUserProfile: %w", err)
}
profile := buildProfile(user, posts)
return profile, nil
}
2. Function call results used immediately:
data, err := json.Marshal(payload)
resp, err := http.Get(url)
rows, err := db.Query(query, args...)
3. Short-lived variables in a small scope:
for i := 0; i < len(items); i++ {
item := items[i]
result := process(item)
fmt.Println(result)
}
Scope and Block Scope #
Scope determines where a variable can be accessed. Go uses lexical block scope — variables only live inside the {} block where they’re declared.
package main
import "fmt"
// Package scope — accessible from all functions in files of the same package
var appName = "MyApp"
var appVersion = "1.0.0"
func scopeExample() {
// Function scope — only within this function
message := "Welcome"
if len(message) > 0 {
// Block scope inside if — only within this block
detail := "message active"
fmt.Println(message, detail) // ✓ both are accessible
}
// fmt.Println(detail) // ✗ compile error: undefined: detail
fmt.Println(message) // ✓ still in function scope
for i := 0; i < 3; i++ {
// Loop scope — i only lives inside this for block
squared := i * i
fmt.Println(i, squared)
}
// fmt.Println(i) // ✗ compile error: undefined: i
// fmt.Println(squared) // ✗ compile error: undefined: squared
fmt.Println(appName) // ✓ package scope is always accessible
}
Package Scope vs Exported #
Important to understand: package scope is different from “exported.” Package-level variables starting with a lowercase letter are accessible from all files in the same package, but not from other packages:
// file: config/config.go
package config
var defaultPort = 8080 // package scope, unexported
var MaxRetry = 3 // package scope, exported
// file: config/loader.go
package config
func Load() Config {
// defaultPort is accessible here — same package
return Config{Port: defaultPort, MaxRetry: MaxRetry}
}
// file: main.go
package main
import "myapp/config"
func main() {
fmt.Println(config.MaxRetry) // ✓ exported
// fmt.Println(config.defaultPort) // ✗ compile error: unexported
}
Variable Shadowing — A Subtle Source of Bugs #
Shadowing happens when a variable in an inner scope has the same name as a variable in an outer scope. The Go compiler allows this, but it can become a source of bugs that are very hard to detect because there’s no error or warning.
func main() {
x := 10
fmt.Println("outer x:", x) // 10
{
x := 20 // a NEW variable named x, not an assignment to the x above
fmt.Println("inner x:", x) // 20
}
fmt.Println("outer x again:", x) // 10 — unchanged!
}
The Shadowing Error — A Classic Go Bug #
The most common shadowing bug involves the err variable:
// ANTI-PATTERN: err is shadowed unintentionally
func processWithBug() error {
result, err := firstStep()
if err != nil {
return err
}
if result > 0 {
// := here creates a NEW err within this if block's scope
data, err := secondStep(result)
if err != nil {
return err
}
fmt.Println(data)
}
// err here is the err from firstStep (outer scope)
// an error from secondStep could be missed if there's another path!
return nil
}
// CORRECT: declare err outside, only assign inside
func processCorrect() error {
result, err := firstStep()
if err != nil {
return err
}
if result > 0 {
var data SomeType
// Use = instead of := for an err that already exists
data, err = secondStep(result)
if err != nil {
return err
}
fmt.Println(data)
}
return nil
}
Pay attention to the difference between:=and=in nested blocks. If all variables on the left side are new, you must use:=. But if one of them already exists in an outer scope and you want to assign to that same variable, make sure the other new variables are declared separately — or refactor so the nested assignment isn’t needed.
if with an Initializer Scope #
One shadowing case that’s very common and actually useful is the initializer in if:
// err in the if-initializer is scoped to the if block
if err := doSomething(); err != nil {
fmt.Println("error:", err)
return err
}
// err is not accessible here — this is GOOD, not a bug
This pattern intentionally limits err’s scope so it doesn’t “leak” into the code below. It’s a very common and recommended Go idiom.
Declaring Together and Multiple Assignment #
Go allows declaring or assigning several variables at once in a single line:
// Declaring together
var a, b, c int // all int, all zero values
var x, y = 10, 20 // type inference
p, q := "hello", "world" // short declaration
// Assigning together
a, b, c = 1, 2, 3
// Swap without a temporary variable — very elegant
a, b = b, a
fmt.Println(a, b) // a and b have swapped values
// Multiple return values
func getNameAndAge() (string, int) {
return "Budi", 28
}
name, age := getNameAndAge() // two variables from one function
Declaring Together with a var Block #
For package-level variables or when you want to group related variables:
var (
// Database config
dbHost = "localhost"
dbPort = 5432
dbName = "myapp"
dbUser = "postgres"
dbPassword = ""
// Server config
serverPort = 8080
serverHost = "0.0.0.0"
readTimeout = 30 * time.Second
writeTimeout = 30 * time.Second
)
The Blank Identifier _
#
The blank identifier _ is Go’s way of “discarding” values you don’t need. It’s mandatory to use because Go doesn’t allow declared-but-unused variables.
// A function returns two values — we only need the second
func getDimensions() (width, height int) {
return 1920, 1080
}
_, height := getDimensions()
fmt.Println("Height:", height) // only using height
// In for-range — discard the index if you don't need it
names := []string{"Alice", "Bob", "Charlie"}
for _, name := range names {
fmt.Println(name) // only need the value, not the index
}
// In for-range — discard the value if you only need the index
for i := range names {
fmt.Printf("index %d\n", i)
}
// Ignoring an error — DANGEROUS, use with great care
data, _ := os.ReadFile("config.json") // if the file doesn't exist, data is nil!
Don’t ignore errors with_carelessly.data, _ := os.ReadFile("file.txt")is code that compiles and runs, but if the file doesn’t exist,datawill beniland any further operation ondatawill panic. Always handle errors unless you’re absolutely sure the error can’t happen — and document the reason in a comment.
Variables and Type Assertions #
In idiomatic Go, you’ll often see variable declaration patterns combined with type assertions or type switches:
// Type assertion with the two-value form
var i interface{} = "Hello, Go!"
if s, ok := i.(string); ok {
// s is scoped to this if block
fmt.Println("String length:", len(s))
}
// A common pattern: asserting to a specific error type
if pathErr, ok := err.(*os.PathError); ok {
fmt.Println("Path error on:", pathErr.Path)
}
Complete Example Program #
Here’s a program combining all the variable concepts discussed in a real scenario — calculating statistics from sales data:
package main
import (
"fmt"
"math"
)
// Package-level constants and variables
var (
storeName = "Go Store"
currency = "IDR"
)
type Sale struct {
Product string
Quantity int
Price float64
}
func calculateStats(sales []Sale) (total, average, min, max float64) {
if len(sales) == 0 {
return 0, 0, 0, 0
}
// Initialize with useful zero values
min = math.MaxFloat64
var count int
for _, sale := range sales {
revenue := float64(sale.Quantity) * sale.Price
total += revenue // accumulate
count++
if revenue < min {
min = revenue
}
if revenue > max {
max = revenue
}
}
average = total / float64(count)
return // named return — returns total, average, min, max
}
func formatCurrency(amount float64) string {
return fmt.Sprintf("%s %.2f", currency, amount)
}
func main() {
// Initialize data with a composite literal
sales := []Sale{
{Product: "Laptop", Quantity: 2, Price: 15_000_000},
{Product: "Mouse", Quantity: 10, Price: 250_000},
{Product: "Keyboard", Quantity: 5, Price: 500_000},
{Product: "Monitor", Quantity: 3, Price: 4_000_000},
{Product: "Headset", Quantity: 7, Price: 750_000},
}
// Multiple return values
total, average, min, max := calculateStats(sales)
// Local variables for presentation
separator := "=========================="
fmt.Printf("\n%s\n", storeName)
fmt.Println(separator)
// Iterate with for-range, blank identifier for the index
for _, sale := range sales {
revenue := float64(sale.Quantity) * sale.Price
fmt.Printf("%-12s x%d = %s\n",
sale.Product,
sale.Quantity,
formatCurrency(revenue),
)
}
fmt.Println(separator)
fmt.Printf("Total: %s\n", formatCurrency(total))
fmt.Printf("Average: %s\n", formatCurrency(average))
fmt.Printf("Min transaction: %s\n", formatCurrency(min))
fmt.Printf("Max transaction: %s\n", formatCurrency(max))
fmt.Printf("Number of transactions: %d\n", len(sales))
}
Summary #
- Zero values guarantee every variable is initialized:
0,false,"",nil— no random values from memory like in C.- Four declaration styles:
var name Type = value,var name Type,var name = value,name := value— each has its right context.varis mandatory for package-level variables;:=is more idiomatic for local variables inside functions.- Use
varwhen you want an explicit zero value, a type different from the default inference, or to group related variables.- Scope is block-based — variables only live in the
{}where they’re declared; nested scopes allow shadowing.- Variable shadowing doesn’t cause errors but can be a subtle bug source — most often with the
errvariable.- The blank identifier
_discards unused values — mandatory because Go doesn’t allow unused variables.- Don’t ignore errors with
_carelessly — always consider the consequences if the operation fails.- Swapping values elegantly without a temp variable:
a, b = b, a.