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, data will be nil and any further operation on data will 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.
  • var is mandatory for package-level variables; := is more idiomatic for local variables inside functions.
  • Use var when 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 err variable.
  • 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.

← Previous: Comments   Next: Constants →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact