Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Functions and Packages

Functions are the building blocks of Go programs. Go functions have several features that set them apart from other languages: multiple return values (used everywhere for error handling), first-class function values (functions can be passed around like any other value), and closures (functions that capture variables from their surrounding scope).

Function Syntax

func add(x int, y int) int {
    return x + y
}
If parameters share a type, you can omit the type for all but the last.
func add(x, y int) int {
    return x + y
}

Multiple Return Values

Go functions can return multiple results. This is one of Go’s most distinctive features and is foundational to the entire error-handling philosophy: virtually every function that can fail returns (result, error).
func swap(x, y string) (string, string) {
    return y, x
}

func main() {
    a, b := swap("hello", "world")
    fmt.Println(a, b)
}
The (result, error) convention is so pervasive that it shapes how you read Go code. When you see two return values, your brain should immediately pattern-match to “value and error”:
// The pattern you will write hundreds of times:
file, err := os.Open("config.yaml")
if err != nil {
    return fmt.Errorf("opening config: %w", err)
}
defer file.Close()

Named Return Values

Return values can be named. If so, they are treated as variables defined at the top of the function. A “naked” return statement returns the named return values.
func split(sum int) (x, y int) {
    x = sum * 4 / 9
    y = sum - x
    return // returns x, y
}
Best Practice: Use named returns only for short functions. In longer functions, they can harm readability.

Variadic Functions

Functions can take a variable number of arguments.
func sum(nums ...int) int {
    total := 0
    for _, num := range nums {
        total += num
    }
    return total
}

func main() {
    fmt.Println(sum(1, 2))
    fmt.Println(sum(1, 2, 3))
    
    nums := []int{1, 2, 3, 4}
    fmt.Println(sum(nums...)) // spread slice
}

Packages

Every Go program is made up of packages. Programs start running in package main.

Imports

import (
    "fmt"
    "math/rand"
)

Exported Names

In Go, a name is exported if it begins with a capital letter. For example, Pizza is an exported name, as is Pi, which is exported from the math package. pizza and pi do not start with a capital letter, so they are not exported.
func main() {
    fmt.Println(math.Pi) // OK
    // fmt.Println(math.pi) // Error: cannot refer to unexported name math.pi
}

Package Initialization (init)

Variables can be initialized during declaration, but init functions allow for more complex initialization logic.
package main

import "fmt"

var myVar int

func init() {
    fmt.Println("Initializing...")
    myVar = 42
}

func main() {
    fmt.Println("Main started, myVar =", myVar)
}
init functions run automatically before main.
Pitfall — Overusing init(): While init functions are convenient, they can make code harder to test and reason about. They run implicitly (just importing a package triggers them), they cannot return errors, and their execution order across packages can be surprising. Prefer explicit initialization in main() when possible, and reserve init for truly package-level setup like registering database drivers or codecs:
// Good use of init: registering a driver (the standard pattern)
import _ "github.com/lib/pq" // The blank import triggers pq's init()

// Bad use of init: complex setup that can fail
func init() {
    db, err := sql.Open(...) // Cannot return this error!
    if err != nil {
        panic(err) // Panic in init is poor practice
    }
}

First-Class Functions and Closures

Functions in Go are first-class values — you can assign them to variables, pass them as arguments, and return them from other functions. This enables powerful patterns like callbacks, middleware, and functional options.
// Functions as values
add := func(a, b int) int { return a + b }
result := add(3, 4) // 7

// Higher-order function: accepts a function as a parameter
func apply(nums []int, transform func(int) int) []int {
    result := make([]int, len(nums))
    for i, n := range nums {
        result[i] = transform(n)
    }
    return result
}

doubled := apply([]int{1, 2, 3}, func(n int) int { return n * 2 })
// [2, 4, 6]

// Closure: captures variables from the enclosing scope
func counter() func() int {
    count := 0
    return func() int {
        count++ // count is "closed over" -- it persists between calls
        return count
    }
}

next := counter()
fmt.Println(next()) // 1
fmt.Println(next()) // 2
fmt.Println(next()) // 3
Pitfall — Closures and Loop Variables: A classic Go bug occurs when a closure captures a loop variable. Before Go 1.22, the loop variable was shared across iterations:
// Before Go 1.22: BUG -- all goroutines print the same value
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // Captures the variable i, not its value
    }()
}

// Fix (needed for Go versions before 1.22):
for i := 0; i < 5; i++ {
    i := i // Shadow the loop variable with a new copy
    go func() {
        fmt.Println(i) // Now each goroutine has its own copy
    }()
}
Go 1.22+ changed loop variable scoping so each iteration gets a fresh variable. However, you will see the i := i shadow pattern in older codebases, and it is important to understand why it exists.

Interview Deep-Dive

Strong Answer:
  • Multiple return values are the foundation of Go’s “errors as values” philosophy. The convention is (result, error) — virtually every function that can fail returns its result and an error. The caller must explicitly handle the error at the call site with if err != nil.
  • The designers chose this over exceptions for several reasons. First, exceptions create invisible control flow: a function call in Java might throw an exception that unwinds the stack across multiple frames to a catch block you forgot existed. In Go, every error is handled right where it occurs, making the control flow explicit and local. Second, exceptions encourage lazy error handling — developers wrap large blocks in try/catch and handle everything generically, losing context. Go’s pattern forces you to think about each error individually.
  • The trade-off is verbosity. Go code has significantly more if err != nil blocks than equivalent code in Python or Java. This is the single most common complaint about Go, and the Go team has acknowledged it. But the benefit is that when you read Go code, you always know exactly where errors are handled and what happens when things fail.
  • In practice, the pattern becomes muscle memory: call function, check error, wrap with context using fmt.Errorf("doing X: %w", err), return. Each layer adds context, creating an “error trace” like "creating order: charging payment: connecting to stripe: dial tcp: timeout".
Follow-up: What is the purpose of the blank identifier _ in the context of multiple return values, and when is it acceptable to use it?The blank identifier _ discards a return value, telling the compiler you intentionally do not need it. The only time it is acceptable to discard an error is when the function genuinely cannot fail in your context (like fmt.Println writing to stdout) or when the error is truly irrelevant to program correctness. In practice, I almost never discard errors — even in logging code, a write failure to stdout can indicate a broken pipe that you should know about. Linters like errcheck will flag discarded errors, and most production codebases require them to pass clean. The one place where _ is routinely used with error returns is in test code where you have already validated the happy path and are testing a specific branch.
Strong Answer:
  • A closure is a function value that references variables from outside its body. The function “closes over” those variables, meaning it captures a reference to them, not a copy of their values. The captured variables survive as long as the closure exists, even after the enclosing function returns. This is how Go implements stateful function values — the classic example is a counter function that returns an incrementing closure.
  • Under the hood, when the compiler detects that a local variable is captured by a closure, it moves that variable from the stack to the heap (escape analysis). The closure struct holds a pointer to the heap-allocated variable. This is why closures are slightly more expensive than regular functions — they involve heap allocation and indirection.
  • The classic goroutine-closure bug (pre-Go 1.22): launching goroutines in a loop where the closure captures the loop variable. All goroutines share the same variable, and by the time they execute, the loop has finished, so they all see the final value. The fix is i := i shadowing to create a per-iteration copy, or passing the value as a function argument.
  • Go 1.22 changed loop variable semantics so each iteration gets its own variable, which fixes the most common manifestation of this bug. But the underlying principle still applies: if you capture a variable by reference in a closure that runs asynchronously, you must understand that the variable may change before the closure executes.
Follow-up: What happens to the memory of variables captured by a closure? When are they garbage collected?Captured variables are heap-allocated and garbage collected only when all closures referencing them become unreachable. This means closures can inadvertently keep large objects alive longer than expected. For example, if a closure captures a reference to a large slice just to read one element, the entire backing array stays in memory as long as the closure is reachable. In production, this can manifest as memory leaks when closures are stored in long-lived data structures like callback registries or event handlers. The fix is to extract only the data you need into local variables before creating the closure, so the closure does not hold a reference to the large parent object.
Strong Answer:
  • Init functions run automatically after all variable declarations in a package are evaluated. The execution order across packages follows the import dependency graph: if package A imports package B, all of B’s init functions run before A’s. Within a single package, init functions run in the order they appear in source files (sorted by filename), and a single file can have multiple init functions.
  • Init functions cannot return errors, cannot be called explicitly, and run as a side effect of importing a package. This last point is the core of why overusing them is an anti-pattern: just importing a package triggers its init functions, which means your program has invisible side effects at import time. This makes testing difficult because you cannot control when initialization happens, and it makes debugging painful because the execution flow is implicit.
  • The legitimate use cases are narrow: registering database drivers (import _ "github.com/lib/pq"), registering codecs or serialization formats, and setting up package-level constants that require computation. These are truly package-level setup that happens once and cannot fail.
  • The anti-pattern is using init for anything that can fail (database connections, HTTP clients, configuration loading) or anything that has dependencies on external state. If your init function panics, your entire program crashes before main even starts, with no opportunity for graceful error handling. The idiomatic alternative is explicit initialization in main: create your dependencies, check for errors, and pass them down through your call chain via constructor injection.
Follow-up: What is a blank import (import _ "pkg") and why would you use one?A blank import imports a package solely for its side effects — specifically, its init functions. The blank identifier _ tells the compiler you are not using any exported names from the package, which would normally cause a compile error. The most common use is database drivers: import _ "github.com/lib/pq" triggers pq’s init function, which calls sql.Register("postgres", &Driver{}) to register itself with the database/sql package. After that, you can use sql.Open("postgres", connStr) without directly referencing the pq package. This is an elegant pattern for plugin-style architectures, but it should be used sparingly because it creates hidden dependencies — someone reading your code has to know that the blank import is essential, even though it looks like it does nothing.