Go Basics — Types, Memory and Data Structures

This is the first tutorial in a series on Go. It covers three foundational topics: pointers and references (how Go passes data around and when mutations are visible), structs with methods and receivers (how Go models data and behavior without classes), and slices and maps (the two built-in data structures you will use constantly). Each section builds on the previous one — pointers explain why receiver types matter, and receiver types explain how slice and map patterns work in practice.

Pointers and References

Go is a pass-by-value language. When you pass a variable to a function, Go copies the value. The function works on its own independent copy, and the caller never sees changes. This is safe and predictable, but sometimes you need the function to modify the original. That is what pointers are for.

Value Semantics

A function that takes a plain int gets a copy. Incrementing the copy has no effect on the caller’s variable:

1
2
3
4
5
6
7
8
9
10
11
func incrementBroken(counter int) {
	counter++
	fmt.Printf("  inside incrementBroken: counter = %d\n", counter)
}

func main() {
	counter := 0
	fmt.Printf("  before: counter = %d\n", counter)
	incrementBroken(counter)
	fmt.Printf("  after:  counter = %d  <- still 0! the function got a copy\n", counter)
}

Inside incrementBroken, counter is 1. Back in main, it is still 0. The function modified its own copy and the original was untouched.

Pointer Semantics

A pointer holds the memory address of a value. The & operator takes the address of a variable, and the * operator dereferences a pointer to access the value it points to. When you pass a pointer to a function, both the caller and the function are looking at the same memory:

1
2
3
4
5
6
7
8
9
10
11
12
func incrementFixed(counter *int) {
	*counter++
	fmt.Printf("  inside incrementFixed: *counter = %d\n", *counter)
}

func main() {
	counter := 0
	fmt.Printf("  before: counter = %d\n", counter)
	fmt.Printf("  address of counter: %p\n", &counter)
	incrementFixed(&counter)
	fmt.Printf("  after:  counter = %d  <- it changed! both see the same memory\n", counter)
}

&counter produces a *int — a pointer to an int. Inside incrementFixed, *counter++ follows the pointer to the original variable and increments it. After the call, counter in main is 1.

Shared State via Pointers

Pointers let multiple functions share and coordinate through the same variable. Here, a producer writes values and a consumer reads the result — both operate on the same uint64 through a pointer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func producer(lsn *uint64) {
	for i := 0; i < 5; i++ {
		*lsn = uint64((i + 1) * 100)
		fmt.Printf("  producer set LSN to %d\n", *lsn)
	}
}

func consumer(lsn *uint64) {
	fmt.Printf("  consumer reads LSN = %d  <- sees the producer's last write\n", *lsn)
}

func main() {
	var confirmedLSN uint64 = 0
	producer(&confirmedLSN)
	consumer(&confirmedLSN)
	fmt.Printf("  final LSN:   %d  <- everyone sees the same value\n", confirmedLSN)
}

Both producer and consumer receive a *uint64 pointing to the same confirmedLSN variable. The producer’s writes are immediately visible to anyone else who holds a pointer to that address.

Pointers to Structs

Pointers become essential with structs. If you pass a struct by value to a function, the function gets a full copy — modifications are lost. A method with a pointer receiver, on the other hand, mutates the original:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type Writer struct {
	Path      string
	BytesUsed int
}

func NewWriter(path string) *Writer {
	return &Writer{Path: path, BytesUsed: 0}
}

func (w *Writer) Write(data string) {
	w.BytesUsed += len(data)
	fmt.Printf("  wrote %d bytes to %s (total: %d)\n", len(data), w.Path, w.BytesUsed)
}

func writeBroken(w Writer, data string) {
	w.BytesUsed += len(data)
	fmt.Printf("  (broken) wrote %d bytes (total in copy: %d)\n", len(data), w.BytesUsed)
}

func main() {
	w := NewWriter("/data/output.parquet")
	w.Write("hello")
	w.Write("world")
	fmt.Printf("  after writes: BytesUsed = %d <- pointer receiver mutated it\n\n", w.BytesUsed)

	w2 := Writer{Path: "/data/broken.parquet", BytesUsed: 0}
	writeBroken(w2, "hello")
	writeBroken(w2, "world")
	fmt.Printf("  after broken writes: BytesUsed = %d <- still 0, copies were modified\n", w2.BytesUsed)
}

NewWriter returns a *Writer — a pointer to a heap-allocated struct. The Write method has a pointer receiver (w *Writer), so it modifies the same struct the caller holds. By contrast, writeBroken takes a Writer by value. Each call gets a fresh copy, increments the copy’s BytesUsed, and throws it away. The caller’s w2 never changes.

Nil Pointers

A pointer that has not been assigned points to nothing — its zero value is nil. Calling a method on a nil pointer will panic at runtime. Always check before dereferencing:

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
	var w *Writer
	fmt.Printf("  w == nil? %t\n", w == nil)
	if w != nil {
		w.Write("data")
	} else {
		fmt.Println("  skipped write: w is nil (would panic if we called w.Write)")
	}
	w = NewWriter("/data/safe.parquet")
	fmt.Printf("  w == nil? %t  <- initialized now\n", w == nil)
	w.Write("safe data")
}

The var w *Writer declaration gives w a zero value of nil. The nil check prevents the panic. After calling NewWriter, w points to a valid struct and methods work normally.

Structs, Methods and Receivers

Go does not have classes. Instead, you define structs to hold data and attach methods to types using receivers. The receiver type — value or pointer — determines whether the method can mutate the struct.

Struct Initialization

A struct groups fields into a single type. You can initialize it with named fields, rely on zero values, or provide only the fields you care about:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Duration time.Duration

type Config struct {
	Host     string
	Port     int
	Interval Duration
}

func main() {
	c1 := Config{Host: "localhost", Port: 5432, Interval: Duration(5 * time.Second)}
	fmt.Printf("  named fields:  %+v\n", c1)
	var c2 Config
	fmt.Printf("  zero value:    %+v\n", c2)
	c3 := Config{Host: "prod-db"}
	fmt.Printf("  partial init:  %+v\n", c3)
}

c1 sets every field explicitly. c2 uses Go’s zero values — empty string for Host, 0 for Port, 0 for Interval. c3 sets only Host; the rest default to zero values. The %+v format verb prints field names alongside values, which is useful for debugging.

Custom Types

Go lets you define a new named type based on an existing type. The new type is distinct — you cannot mix them without an explicit conversion — but it can carry its own methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Duration time.Duration

func (d Duration) Std() time.Duration {
	return time.Duration(d)
}

func main() {
	d := Duration(10 * time.Second)
	fmt.Printf("  Duration value: %v\n", d)
	fmt.Printf("  As time.Duration: %v\n", d.Std())
	td := time.Duration(d)
	fmt.Printf("  Explicit conversion: %v\n", td)
}

Duration is a new type with the same underlying representation as time.Duration, but Go treats them as separate types. You cannot pass a Duration where a time.Duration is expected without converting. The Std() method provides a convenient way to convert back. This pattern is useful for adding domain-specific methods to standard library types.

Value Receivers vs Pointer Receivers

A value receiver gets a copy of the struct. It can read fields but any changes are lost when the method returns. A pointer receiver gets the address of the original, so changes persist:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (d Duration) Std() time.Duration {
	return time.Duration(d)
}

func (d *Duration) Set(v time.Duration) {
	*d = Duration(v)
	fmt.Printf("  duration set to %v\n", d.Std())
}

func main() {
	d := Duration(3 * time.Second)
	std := d.Std()
	fmt.Printf("  d.Std() = %v (d is unchanged: %v)\n", std, time.Duration(d))

	d = Duration(1 * time.Second)
	fmt.Printf("  before Set: %v\n", d.Std())
	d.Set(5 * time.Second)
	fmt.Printf("  after Set:  %v  <- mutated via pointer receiver\n", d.Std())
}

Std() uses a value receiver — it reads d but does not change it. Set() uses a pointer receiver — it overwrites the value that d points to. The rule of thumb: use a pointer receiver if the method needs to modify the receiver, or if the struct is large enough that copying it would be wasteful. Use a value receiver for small, immutable reads.

The Constructor Pattern

Go does not have constructors, but the convention is to write a NewXxx function that returns a pointer to an initialized struct. This lets you set up internal state (like pre-allocated buffers) that callers should not need to think about:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
type Writer struct {
	Path         string
	BatchSize    int
	BytesWritten int
	buffer       []string
}

func NewWriter(path string, batchSize int) *Writer {
	return &Writer{
		Path:      path,
		BatchSize: batchSize,
		buffer:    make([]string, 0, batchSize),
	}
}

func (w *Writer) Write(data string) {
	w.buffer = append(w.buffer, data)
	w.BytesWritten += len(data)
	fmt.Printf("  wrote %q (buffer: %d/%d, total bytes: %d)\n",
		data, len(w.buffer), w.BatchSize, w.BytesWritten)
}

func (w *Writer) Flush() {
	fmt.Printf("  flushing %d items from buffer\n", len(w.buffer))
	for _, item := range w.buffer {
		fmt.Printf("    -> %s\n", item)
	}
	w.buffer = w.buffer[:0]
	fmt.Printf("  buffer cleared (len=%d, cap=%d)\n", len(w.buffer), cap(w.buffer))
}

func main() {
	w := NewWriter("/data/output.parquet", 3)
	fmt.Printf("  created: %+v\n", *w)
	w.Write("INSERT users alice")
	w.Write("INSERT users bob")
	w.Write("UPDATE users alice")
	w.Flush()
	w.Write("DELETE users bob")
	fmt.Printf("  after more writes: buffer=%d, total bytes=%d\n",
		len(w.buffer), w.BytesWritten)
}

NewWriter allocates the struct on the heap (returning &Writer{...} ensures the struct escapes the function’s stack frame) and pre-allocates the internal buffer with make([]string, 0, batchSize). The lowercase buffer field is unexported — callers outside the package cannot access it directly. Both Write and Flush use pointer receivers because they mutate the struct. Notice how Flush resets the buffer with w.buffer[:0] — this keeps the allocated memory and just sets the length to zero, which we will look at more closely in the next section.

Slices and Maps

Slices and maps are Go’s two workhorse collection types. A slice is a dynamically-sized view into an underlying array. A map is a hash table. Both are reference types — they contain internal pointers, so passing them to a function does not copy the underlying data.

Slice Internals: Length vs Capacity

A slice header has three fields: a pointer to the underlying array, a length (how many elements are in use), and a capacity (how many elements the array can hold before a new allocation is needed). Understanding this distinction is key to writing efficient Go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
	s := make([]int, 0, 5)
	fmt.Printf("  make([]int, 0, 5): len=%d, cap=%d\n", len(s), cap(s))
	for i := 0; i < 8; i++ {
		s = append(s, i)
		fmt.Printf("  append(%d): len=%d, cap=%d\n", i, len(s), cap(s))
	}
	var nilSlice []int
	fmt.Printf("\n  nil slice: len=%d, cap=%d, is nil=%t\n",
		len(nilSlice), cap(nilSlice), nilSlice == nil)
	nilSlice = append(nilSlice, 1)
	fmt.Printf("  after append: len=%d, cap=%d, is nil=%t\n",
		len(nilSlice), cap(nilSlice), nilSlice == nil)
}

make([]int, 0, 5) creates a slice with length 0 and capacity 5. The first five appends fit without allocating. On the sixth append, Go allocates a new, larger array (typically doubling the capacity), copies the existing elements, and updates the slice header. This is why append returns a new slice — the pointer inside the header may have changed.

A nil slice (var nilSlice []int) has length 0, capacity 0, and is equal to nil. But append works on nil slices — it allocates on the first call. This means you do not need to initialize a slice before appending to it.

Append and Spread

The ... operator spreads a slice into individual arguments. This is how you merge slices:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Change struct {
	Table  string
	Action string
	Key    string
}

func main() {
	batch1 := []Change{
		{Table: "users", Action: "INSERT", Key: "alice"},
		{Table: "users", Action: "UPDATE", Key: "bob"},
	}
	batch2 := []Change{
		{Table: "orders", Action: "INSERT", Key: "ord-1"},
	}
	var all []Change
	all = append(all, batch1...)
	all = append(all, batch2...)
	fmt.Printf("  merged %d + %d = %d changes\n", len(batch1), len(batch2), len(all))
	for _, c := range all {
		fmt.Printf("    %s %s (key=%s)\n", c.Action, c.Table, c.Key)
	}
}

append(all, batch1...) is equivalent to appending each element of batch1 individually. Without the ..., the compiler would complain — append expects individual elements of the slice’s element type, not another slice.

Efficient Reset with [:0]

When you need to reuse a buffer without reallocating, slice it back to zero length. The capacity (and the underlying array) are preserved:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
	buffer := make([]Change, 0, 10)
	buffer = append(buffer,
		Change{Table: "users", Action: "INSERT", Key: "alice"},
		Change{Table: "users", Action: "INSERT", Key: "bob"},
		Change{Table: "orders", Action: "INSERT", Key: "ord-1"},
	)
	fmt.Printf("  before reset: len=%d, cap=%d\n", len(buffer), cap(buffer))
	buffer = buffer[:0]
	fmt.Printf("  after [:0]:   len=%d, cap=%d  <- capacity retained!\n", len(buffer), cap(buffer))
	buffer = append(buffer, Change{Table: "events", Action: "INSERT", Key: "evt-1"})
	fmt.Printf("  after reuse:  len=%d, cap=%d  <- no new allocation\n", len(buffer), cap(buffer))
}

buffer[:0] creates a new slice header with length 0 but the same capacity and underlying array. The next append writes into the existing array instead of allocating a new one. This pattern is common in hot loops where you process batches repeatedly — allocate once, reset between iterations.

Maps and the Comma-Ok Pattern

A map is an unordered collection of key-value pairs. Accessing a missing key returns the zero value for the value type, which can be ambiguous — is the value actually zero, or does the key not exist? The comma-ok idiom resolves this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
	schemas := map[string]string{
		"users":  "id, name, email",
		"orders": "id, user_id, total",
	}
	if cols, ok := schemas["users"]; ok {
		fmt.Printf("  users columns: %s\n", cols)
	}
	if _, ok := schemas["missing"]; !ok {
		fmt.Println("  'missing' table not found (comma-ok prevented silent zero value)")
	}
	for table, cols := range schemas {
		fmt.Printf("    %s -> %s\n", table, cols)
	}
}

The two-value assignment cols, ok := schemas["users"] returns the value and a boolean. If the key exists, ok is true. If not, ok is false and cols is the zero value (empty string for string). Always use the comma-ok form when the distinction between “missing” and “zero” matters.

Grouping with Maps of Slices

A common pattern is grouping items by some key. Since accessing a missing map key returns the zero value — and the zero value of a slice is nil — you can append directly without initializing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
	changes := []Change{
		{Table: "users", Action: "INSERT", Key: "alice"},
		{Table: "orders", Action: "INSERT", Key: "ord-1"},
		{Table: "users", Action: "UPDATE", Key: "bob"},
		{Table: "orders", Action: "DELETE", Key: "ord-2"},
		{Table: "users", Action: "INSERT", Key: "charlie"},
	}
	groups := make(map[string][]Change)
	for _, c := range changes {
		groups[c.Table] = append(groups[c.Table], c)
	}
	for table, tableChanges := range groups {
		fmt.Printf("  %s: %d changes\n", table, len(tableChanges))
		for _, c := range tableChanges {
			fmt.Printf("    %s key=%s\n", c.Action, c.Key)
		}
	}
}

The first time groups["users"] is accessed, it returns nil. But append(nil, item) works — it allocates a new slice. On subsequent accesses, it appends to the existing slice. This means you never need to check whether a key exists before appending. The pattern works because Go’s zero values are designed to be useful, not just empty.

Deduplication with a Seen Map

A map[string]bool is the standard way to track whether you have already seen a value. Combined with a slice to preserve order, it gives you a deduplicated list:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
	changes := []Change{
		{Table: "users", Action: "INSERT", Key: "alice"},
		{Table: "orders", Action: "INSERT", Key: "ord-1"},
		{Table: "users", Action: "UPDATE", Key: "bob"},
		{Table: "events", Action: "INSERT", Key: "evt-1"},
		{Table: "orders", Action: "DELETE", Key: "ord-2"},
	}
	seen := make(map[string]bool)
	var uniqueTables []string
	for _, c := range changes {
		if !seen[c.Table] {
			seen[c.Table] = true
			uniqueTables = append(uniqueTables, c.Table)
		}
	}
	fmt.Printf("  %d changes across %d unique tables: %v\n",
		len(changes), len(uniqueTables), uniqueTables)
}

seen[c.Table] returns false (the zero value for bool) if the key has never been set. The first time a table name appears, it passes the !seen check, gets added to both the map and the slice. On subsequent appearances, the map returns true and the item is skipped. The slice preserves insertion order, which the map alone does not guarantee.

This pattern generalizes to any deduplication task — swap string for whatever type you are deduplicating by, and the logic stays the same.