We have mastered the atoms (primitives like int and bool) and we have mastered the molecules (structs).

In the last section, I promised we would look at the “Big Three” complex types you use every day: Strings, Slices, and Interfaces.

But as we peel back the layers of Go’s memory model, we have to admit there is a fourth player that demands attention. A “pointer-in-disguise” that confuses beginners constantly. So today, we are doing The Big Three… plus a bonus look at Maps.

These types aren’t magic. They are just structs with good PR. Understanding what they look like in memory is the difference between a “Go user” and a “Go engineer.”

1. The String: It’s Just a Header

When you type:

s := "Hello World"

You aren’t holding the text “Hello World” in that variable s. You are holding a tiny 2-word header.

Under the hood (in the reflect package <Technically deprecated. Layout is the same, but internal types are now preferred>), a string looks exactly like this:

type StringHeader struct {
    Data uintptr // 8 bytes: Pointer to the actual backing array
    Len  int     // 8 bytes: The length of the string
}

Total Size: 16 bytes (on a 64-bit arch).

This is why strings in Go are so fast to pass around.

  • Copying a string? You only copy 16 bytes.
  • Passing a 1GB JSON payload to a function? You still only copy 16 bytes.

The actual data (the bytes “Hello World”) sits somewhere else in memory, usually immutable. The variable s is just the map to find it.

2. The Slice: The “Triple” Threat

A slice is essentially a string with one extra superpower: Capacity.

When you type:

nums := make([]int, 0, 10)

You are creating a 3-word header:

type SliceHeader struct {
	Data uintptr // 8 bytes: Pointer to the underlying array (memory address)
	Len  int     // 8 bytes: Number of elements in the slice
	Cap  int     // 8 bytes: Capacity of the underlying array
}

Total Size: 24 bytes(64-bit systems).

This explains the classic “Append Trap.” If you pass a slice to a function, Go copies these 24 bytes. If the function appends to the slice, it updates the Len field of the copied header. Your original header back in main() has no idea the length changed. This is why you must always do s = append(s, x).

3. The Interface: A Tale of Two Structs

This is the most misunderstood type in Go. When you assign a value to an interface, what gets stored?

It turns out, Go uses two different internal structures depending on whether the interface has methods or not.

The Empty Interface (eface)

When you use interface{} (or any in modern Go), you are saying “I don’t care what methods this has, just hold the data.”

// interface{}
type eface struct {
    _type *_type         // 8 bytes: Info about what the concrete type IS
    data  unsafe.Pointer // 8 bytes: Pointer to the data
}

Go only needs to know what the thing is (so it can reflect on it later) and where it is.

The Non-Empty Interface (iface)

When you use an interface with methods (like io.Reader), Go needs more info. It needs to know how to call Read() on the specific object you passed in.

// io.Reader
type iface struct {
    tab  *itab           // 8 bytes: The Interface Table (Method dispatch)
    data unsafe.Pointer  // 8 bytes: Pointer to the data
}

The tab (itab) is a special pointer. It points to a table generated by the compiler that maps the interface methods (Read) to the concrete type’s implementation (os.File.Read). This is how Go achieves dynamic dispatch (polymorphism).

Key Takeaway: Regardless of eface or iface, an interface is always two words (16 bytes) on a 64bit arch.

The “Why can’t I cast this?” Interview Question

You have nums := []int{1, 2, 3}. You want to pass it to func process(items []any). Compiler Error.

Why?

  • []int is a block of raw integers: [1][2][3].
  • []any is a block of eface structs: [Type+Ptr][Type+Ptr][Type+Ptr].

They are fundamentally different shapes at the bit level. Go refuses to implicitly allocate a new array and rewrite every single integer into an interface header.

4. The Map: The Pointer in Disguise

I know I said we’d cover three types, but we can’t ignore the Map.

Maps are weird. When you declare var m map[string]int, unsafe.Sizeof(m) returns 8 bytes.

Wait, just 8 bytes? Yes.

Unlike slices (which are structs), a map in Go is just a pointer to a much larger, complex struct hidden in the runtime called hmap.

// Simplified view
type hmap struct {
    count     int    // Live cells
    flags     uint8
    B         uint8  // Log_2 of # of buckets
    noverflow uint16 
    hash0     uint32 // Hash seed
    buckets   unsafe.Pointer // Array of buckets
    // ... more fields
}

When you pass a map to a function, you are passing that single 8-byte pointer. This is why maps feel like they are passed by reference. If you modify the map inside a function, the changes stick, because you are following the pointer to the same hmap struct on the heap.

Verify It Yourself

Let’s use unsafe to prove the sizes of these headers.

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // 1. String Header
    s := "Hello World"
    fmt.Printf("String:    %d bytes (Ptr + Len)\n", unsafe.Sizeof(s))

    // 2. Slice Header
    sl := []int{1, 2, 3}
    fmt.Printf("Slice:     %d bytes (Ptr + Len + Cap)\n", unsafe.Sizeof(sl))

    // 3. Interface Header (Both eface and iface)
    var empty any = sl
    var reader fmt.Stringer // An interface with methods
    fmt.Printf("Interface: %d bytes (Type/Tab + Data)\n", unsafe.Sizeof(empty))
    fmt.Printf("Reader:    %d bytes (Type/Tab + Data)\n", unsafe.Sizeof(reader))

    // 4. Map
    m := make(map[string]int)
    fmt.Printf("Map:       %d bytes (Just a pointer!)\n", unsafe.Sizeof(m))
}

Output on 64-bit:

String:    16 bytes
Slice:     24 bytes
Interface: 16 bytes
Reader:    16 bytes
Map:       8 bytes

Conclusion

We have now mapped out the memory footprint of Go’s most common types.

  • Strings & Interfaces: 16 bytes.
  • Slices: 24 bytes.
  • Maps: 8 bytes (pointer).

But there is a consequence to all these pointers. Every time you use a pointer (in a string, slice, interface, or map), you are giving work to the janitor.

In Part 5, we will explore how your memory layout decisions affect the Garbage Collector. Did you know that using a map[int]*Struct can be 5-10x slower for the GC to scan than map[int]Struct? We’ll find out why.


<
Previous Post
Part 3: The Hardware Reality (CPU Caches and False Sharing)
>
Next Post
Breaking the Glass: A Deep Dive into Go’s Unsafe Package (Part 2)