Skip to main content

Command Palette

Search for a command to run...

Go Learning Notes

Updated
•15 min read•View as Markdown
A
Hey, I'm Aman Kumar — a Software Engineer focused on backend, distributed systems, and cloud infrastructure. I enjoy building scalable systems and working with technologies like Go, Kubernetes, Azure, Temporal, Kafka, and GitOps. I'm also exploring MLOps and ML infrastructure, with a growing interest in deploying and operating machine learning systems at scale.

Command-Line Arguments in Go

Mental model

Go exposes command-line arguments through:

os.Args

os.Args is a []string.

  • os.Args[0] is the program name/path.

  • os.Args[1:] contains the arguments supplied by the user.

Example

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println(os.Args)
}

Running:

go run main.go hello world

Conceptually gives:

[.../main hello world]

So:

os.Args[1]

is "hello".

Important: indexing can panic

This:

fmt.Println(os.Args[1])

will panic if no argument was supplied because index 1 does not exist.

For safe handling:

if len(os.Args) > 1 {
    fmt.Println(os.Args[1])
}

Slicing the arguments

args := os.Args[1:]

Now args contains only user-provided arguments.

The slice may be empty:

args := os.Args[1:]
fmt.Println(len(args))

Quick recap

os.Args      -> []string containing all command-line arguments
os.Args[0]   -> program name/path
os.Args[1:]  -> user arguments

Interview takeaway

os.Args[1:] is a slice expression. It is allowed to produce an empty slice when there are no user arguments.

Variables, Constants, and var

What is a variable?

A variable is a named location/value binding used to store data.

age := 10

Go infers the type:

var age int = 10

is equivalent in this case to:

age := 10

var

You can declare a variable using:

var age int

The variable receives its zero value:

var age int      // 0
var name string  // ""
var active bool  // false

You can also initialize it:

var age int = 10

or let Go infer the type:

var age = 10

Short declaration

Inside functions:

age := 10

is usually the most convenient form.

One limitation:

:= 

cannot be used at package level.

Constants

Constants are declared using const:

const Pi = 3.14159
const AppName = "my-app"

A constant's value is known at compile time and cannot be reassigned.

const x = 10
// x = 20 // compile error

Constants can be boolean, numeric, string, or values formed from constant expressions.

fmt.Printf basics

fmt.Printf("age = %d\n", age)
fmt.Printf("name = %s\n", name)
fmt.Printf("value = %v\n", age)
fmt.Printf("type = %T\n", age)

Useful verbs:

Verb Meaning
%v default value format
%T type
%d decimal integer
%s string
%f floating-point value

Quick recap

var       -> explicit variable declaration
:=        -> short variable declaration, inside functions
const     -> compile-time constant
zero value -> default value of a type

Strings, Bytes, and Runes in Go

Mental model

A Go string is an immutable sequence of bytes.

s := "hello"

You cannot modify a string in place:

// s[0] = 'H' // compile error

Instead, create a new string.

s = "Hello"

String length

s := "hello"
fmt.Println(len(s)) // 5

But len returns the number of bytes, not necessarily the number of human-readable characters.

For ASCII text, these are usually the same.

For UTF-8 text, they can differ:

s := "é"
fmt.Println(len(s)) // 2 bytes in UTF-8

Bytes

A string can be converted to bytes:

b := []byte("hello")

Now b is a mutable byte slice.

b[0] = 'H'
fmt.Println(string(b)) // Hello

Runes

A rune is an alias for int32 and is commonly used to represent a Unicode code point.

r := []rune("hello")

For Unicode-aware character processing, converting to []rune can be useful.

s := "é"
r := []rune(s)

fmt.Println(len(r)) // 1

Why are strings immutable?

Immutability makes strings safe to share and enables efficient string handling. When you need to modify text, Go generally creates a new string or uses a mutable representation such as []byte or []rune.

A useful mental model

string
  |
  +-- immutable bytes
  |
  +-- UTF-8 encoded text by convention

[]byte
  |
  +-- mutable bytes

rune
  |
  +-- int32 representing a Unicode code point

Important correction

Don't think of a Go string as "an array of characters." It is a read-only byte sequence, commonly containing UTF-8 encoded text.

Arrays vs Slices in Go

This is one of the most important distinctions to understand in Go.

Arrays

An array has a fixed length.

var a [5]int

Its type includes the length:

[5]int

and:

[10]int

are different types.

You can initialize an array:

a := [5]int{1, 2, 3, 4, 5}

Slices

A slice is a dynamically sized view over an underlying array.

s := []int{1, 2, 3}

Its type is:

[]int

Unlike an array, the slice length can change:

s = append(s, 4)

Key differences

Property Array Slice
Length Fixed Dynamic
Type [N]T []T
Assignment Copies elements Copies slice header
Pass to function Value Slice header passed by value
Can grow No Yes, with append
Comparable Yes, if element type is comparable No
Map key Can be key if comparable Cannot be key

The important "pass by reference" correction

It is common to hear:

"Slices are passed by reference."

More accurately, Go always passes arguments by value.

A slice value is a small descriptor containing information such as:

pointer -> underlying array
length
capacity

When a slice is passed to a function, that descriptor is copied.

Both the original and copied slice can still refer to the same underlying array.

Therefore:

func change(s []int) {
    s[0] = 99
}

can modify the caller's underlying array.

But changing the slice variable itself is different:

func grow(s []int) {
    s = append(s, 100)
}

The caller's slice header is not automatically replaced.

If you need the caller's slice variable to be updated, return the new slice:

s = grow(s)

Arrays are values

a := [3]int{1, 2, 3}
b := a

b[0] = 99

fmt.Println(a) // [1 2 3]
fmt.Println(b) // [99 2 3]

The array was copied.

Quick mental model

ARRAY
[1][2][3][4][5]
  fixed size

SLICE
pointer ────────> [1][2][3][4][5]
length = 3
capacity = 5

Interview takeaway

The sentence to remember:

Go is pass-by-value. Slices are values containing a reference to an underlying array.

Maps in Go

A map stores key-value pairs.

m := make(map[string]int)

m["alice"] = 10
m["bob"] = 20

Declaration

This creates a nil map:

var m map[string]int

You can read from a nil map:

fmt.Println(m["alice"]) // 0

But you cannot assign to it:

m["alice"] = 10 // panic

Use make to create an editable empty map:

m := make(map[string]int)

You can also use a literal:

m := map[string]int{
    "alice": 10,
    "bob":   20,
}

Checking whether a key exists

Use the two-value lookup:

value, ok := m["alice"]

if ok {
    fmt.Println("found:", value)
}

The boolean tells you whether the key exists.

This distinction matters because a missing key returns the zero value:

m := map[string]int{}

fmt.Println(m["missing"]) // 0

You cannot tell from the value alone whether 0 was stored or the key was absent.

Delete

delete(m, "alice")

Iteration

for key, value := range m {
    fmt.Println(key, value)
}

Map iteration order is not guaranteed.

Map keys

Map keys must be comparable.

Good examples:

map[string]int
map[int]string
map[[2]int]string

A slice cannot be a map key:

// map[[]int]string // invalid

because slices are not comparable.

Quick recap

nil map       -> readable, but cannot assign
make(map...)  -> creates an editable map
m[key]        -> lookup
m[key] = val  -> insert/update
delete(m,key) -> remove
value, ok     -> distinguish missing key from zero value

Functions and Parameter Passing

Functions have parameters

A function declaration uses formal parameters:

func add(a int, b int) int {
    return a + b
}

A call supplies actual arguments:

result := add(10, 20)

Here:

a, b       -> formal parameters
10, 20     -> actual arguments

Go passes arguments by value

This is a fundamental Go rule:

Arguments are passed by value.

For a simple value:

func change(x int) {
    x = 100
}

x := 10
change(x)

fmt.Println(x) // 10

The function receives a copy of x.

What about pointers?

func change(x *int) {
    *x = 100
}

x := 10
change(&x)

fmt.Println(x) // 100

The pointer itself is passed by value, but the copied pointer points to the same memory location.

Reference-like types

Slices, maps, channels, functions, and pointers can contain references to data or runtime state.

That does not change Go's parameter-passing rule.

For example:

func change(s []int) {
    s[0] = 100
}

The slice header is copied, but both slice headers can point to the same underlying array.

Returning multiple values

Go functions can return multiple values:

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }

    return a / b, nil
}

Call it as:

result, err := divide(10, 2)

This pattern is extremely common in Go.

Key takeaway

Don't use the phrase "Go passes slices by reference."

Use:

Go passes everything by value. Some values, such as slice/map/channel values, contain references to underlying runtime data.

Scope, Lifetime, and Escape Analysis

These three concepts are related but are not the same thing.

Scope

Scope answers:

Where in the source code can I refer to this name?

Example:

func demo() {
    b := 10

    if true {
        fmt.Println(b)
    }

    fmt.Println(b)
}

b is in scope throughout the relevant function block.

Go has lexical/static scope: the compiler can determine name visibility from the program's structure.

Lifetime

Lifetime answers:

How long does the value/object remain needed and valid at runtime?

This is a runtime concept.

A variable may be declared inside a function but its referenced data can outlive that function.

Example

func makeCounter() func() int {
    b := 0

    return func() int {
        b++
        return b
    }
}

The returned function still needs b after makeCounter returns.

Therefore the compiler/runtime ensures the captured value remains alive.

Escape analysis

A common oversimplification is:

"Local variables live on the stack and returned variables live on the heap."

That is not a rule you should rely on.

The Go compiler performs escape analysis to determine whether values need to escape their current scope.

Conceptually:

func create() *int {
    x := 10
    return &x
}

The pointer to x is returned, so x must remain valid after create returns. The compiler can arrange for that value to live in memory that remains valid.

You can inspect compiler escape-analysis decisions with:

go build -gcflags="-m" .

Scope vs lifetime

Remember:

Scope   -> where the NAME can be used
Lifetime -> how long the VALUE remains alive

They are different concepts.

Interview takeaway

Don't say:

"Anything local goes on the stack."

Say:

"Go uses compiler escape analysis to determine whether values need to escape, and the compiler/runtime manages their storage accordingly."

Closures in Go

A closure is a function value that captures variables from its surrounding lexical environment.

Simple example

func counter() func() int {
    n := 0

    return func() int {
        n++
        return n
    }
}

Usage:

c := counter()

fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3

The returned function remembers n.

Mental model

You can think of a closure as:

closure
  |
  +-- function code
  |
  +-- environment containing captured variables

The function and the captured environment travel together.

Why this matters for lifetime

Normally, n is a local variable inside counter.

But the returned function still needs n.

Therefore n must remain alive as long as the closure needs it.

This is one reason closures are closely related to escape analysis.

Common use: custom behavior

Closures are useful when you want to create a function with some configuration/state already attached.

func multiplier(x int) func(int) int {
    return func(y int) int {
        return x * y
    }
}

double := multiplier(2)

fmt.Println(double(5)) // 10

Here double closes over x.

Quick takeaway

A closure is a function together with the variables from its surrounding scope that it captures.

defer in Go

defer schedules a function call to run when the surrounding function returns.

Basic example

func demo() {
    defer fmt.Println("cleanup")
    fmt.Println("work")
}

Output:

work
cleanup

Why use defer?

It is commonly used for cleanup:

f, err := os.Open("file.txt")
if err != nil {
    return err
}

defer f.Close()

Now every return path after the successful open automatically reaches the deferred close.

Arguments are evaluated when defer is executed

This is important:

func demo() {
    x := 10

    defer fmt.Println(x)

    x = 20
}

The deferred call prints:

10

The argument x was evaluated when the defer statement executed.

Multiple defers

Deferred calls execute in LIFO order:

func demo() {
    defer fmt.Println("first")
    defer fmt.Println("second")
    defer fmt.Println("third")
}

Output:

third
second
first

Think:

defer A
defer B
defer C

return

C
B
A

Named return values

defer can interact with named return values:

func demo() (result int) {
    defer func() {
        result++
    }()

    return 10
}

The function returns 11.

This is useful, but should be used carefully because it can make control flow less obvious.

Quick takeaway

defer registers a call now; the deferred call runs when the surrounding function returns.

Slices Under the Hood

Slices are one of the most important Go concepts to understand deeply.

A slice is not the underlying array

Consider:

s := make([]int, 5, 10)

Conceptually, the slice contains:

pointer -> underlying array
len = 5
cap = 10

The exact runtime representation is an implementation detail, but this mental model is extremely useful.

Length

len tells you how many elements are currently in the slice:

s := make([]int, 5, 10)

fmt.Println(len(s)) // 5

Capacity

cap tells you how far the slice can grow from its current starting position before a new backing array is required:

fmt.Println(cap(s)) // 10

make([]T, len)

s := make([]int, 5)

Conceptually:

len = 5
cap = 5

The elements are initialized to the zero value:

[0 0 0 0 0]

make([]T, len, cap)

s := make([]int, 0, 5)

Now:

len = 0
cap = 5

The backing array has capacity, but the slice currently contains zero accessible elements.

s = append(s, 10)

Now:

len = 1
cap = 5

append

When there is enough capacity, append can reuse the existing backing array.

When capacity is insufficient, Go allocates a new backing array and copies the elements.

Therefore:

s = append(s, value)

is important: always use the returned slice.

The runtime may return a slice header pointing at a completely new backing array.

Slicing a slice

s := []int{10, 20, 30, 40, 50}

sub := s[1:4]

Conceptually:

s:    [10 20 30 40 50]
          ^        ^
          |        |
        start     end

sub:  [20 30 40]

sub shares the same underlying array.

Therefore:

sub[0] = 999

also changes s[1].

Full slice expression

Go also supports:

s[low:high:max]

This allows you to control the resulting capacity.

Example:

sub := s[1:4:4]

Now:

len(sub) = 3
cap(sub) = 3

This can help prevent an append to sub from modifying elements beyond its intended range in the original backing array.

The big picture

slice value
+-------------------------+
| pointer | len | cap     |
+----|--------|-----|-----+
     |        |     |
     |        |     +---- how far it can grow
     |        +---------- accessible elements
     +------------------- backing array

Interview takeaways

  1. A slice is a descriptor, not the backing array itself.

  2. Passing a slice copies the descriptor.

  3. Multiple slices can share the same backing array.

  4. append may reuse the backing array or allocate a new one.

  5. len and cap are different.

  6. Always assign the result of append.