Go Learning Notes
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
deferregisters 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
A slice is a descriptor, not the backing array itself.
Passing a slice copies the descriptor.
Multiple slices can share the same backing array.
appendmay reuse the backing array or allocate a new one.lenandcapare different.Always assign the result of
append.

