Skip to main content

Command Palette

Search for a command to run...

The "Magic" Behind Go's cap() Function

Published
4 min read
The "Magic" Behind Go's cap() Function
K

I am a passionate full-stack software developer in the MERN stack. With a deep love for coding and problem solving, I thrive in creating robust and scalable applications that deliver seamless user experiences.

As a developer, I'm driven by the desire to build elegant and efficient solutions that meet the unique requirements of each project. I enjoy working collaboratively, brainstorming ideas, and translating them into clean, maintainable code. From conceptualization to deployment, I take pride in delivering high-quality applications that exceed expectations.

If you’ve written any Go code, chances are you’ve used the built-in functions len() and cap(). At first glance, len() is straightforward - it tells you how many elements a slice currently holds. But cap()? That one feels a little mysterious.

Why does a slice have a “capacity”? What does Go do behind the scenes when you append elements and suddenly cap() changes? And is it really magic — or just smart engineering?

In this article, we’ll peel back the curtain on the “magic“ of Go’s cap() function and see what’s really happening under the hood.

Length vs Capacity: A Quick Refresher

Let’s start simple:

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

fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 5
  • Length (len): how many elements are currently in the slice.

  • Capacity (cap): how many elements the slice can hold before it needs to grow

Think of it like a bookshelf:

  • len = number of books currently on the shelf.

  • cap = the total number of books that can fit on the shelf without buying a bigger one.

The Secret Life of a Slice

Here’s where the magic begins. A slice in Go is not an array — it’s a descriptor. Internally, a slice has three fields:

type sliceHeader struct {
    Data uintptr // pointer to the underlying array
    Len  int     // number of elements in use
    Cap  int     // total capacity of the array
}

// Given the slice below
s := []string{"a", "b", "c"}

So when you check the cap(s), Go is simply reading the Cap field from the slice header. Nothing magical yet — just a smart way of tracking “used space“ and “available space”.

Growing Pains: What Happens When Capacity Is Exceeded

Now here’s where the illusion of magic really shows up.

Consider this example:

s := []int{1, 2, 3}
fmt.Println(len(s), cap(s)) // 3 3

s = append(s, 4)            // appending beyond capacity
fmt.Println(len(s), cap(s)) // 4 6 (capacity changed!)

Notice what happened:

  • Initially, length and capacity were both 3.

  • After appending a new element, Go silently allocated a new array (with larger capacity), copied over the old elements, and added the new one.

  • The slice header was updated to point to the new array.

The resizing strategy is efficient:

  • For small slices, Go thoroughly doubles the capacity.

  • For larger slices, the growth slows down (about 25% increments).

So while it looks like you can infinitely grow a slice, under the hood Go is managing memory carefully and doing occasional heavy lifting.

Why Capacity Matters in Real-World Code

You might wonder: “Okay, cool — but why should I care?”

Here' are a few reasons:

  1. Performance Optimization

    If you know you’ll append thousands of items it’s better to allocate upfront:

     s := make([]int, 0, 1000) // reserve space
    

    This prevents Go from repeatedly reallocating and copying arrays behind the scenes.

  2. Avoiding Subtle Bugs

    Since slices share the same underlying array, changes in one slice can leak into another if they share capacity.

     a := []int{1, 2, 3, 4, 5}
     b := a[:3]   // slice of first 3 elements
     c := a[2:]   // slice of last elements
    
     c[0] = 99    // modifies a[2], which also affects b!
     fmt.Println(a) // [1 2 99 4 5]
     fmt.Println(b) // [1 2 99]
     fmt.Println(c) // [99 4 5]
    

    Capacity is the reason this works — b and c are still tied to the same array under the hood.

A Peek into Go’s Runtime

For the curious, here’s the actual function in Go’s runtime that handles slice growth (runtime/slice.go):

func growslice(et *_type, old slice, cap int) slice {
    // newCap is calculated (double or 25% growth depending on size)
    // a new underlying array is allocated
    // old elements are copied into the new array
    // a new slice header is returned
}

In other words, every time you append past capacity, Go is quietly

  1. Allocating a bigger array

  2. Copying everything into it

  3. Updating your slice to point to the new array.

Pretty clever, right?

Wrapping Up

The cap() function in Go isn’t really “magic”. It’s a simple number — but it represents a powerful design choice that gives Go slices their flexibility and efficiency.

Next time you see cap(), remember:

  • It’s just reporting the size of the underlying array.

  • When you exceed it, Go handles the messy work of allocating and copying for you.

  • Knowing this can help you write more efficient, bug-free code.

So, the next time your slice suddenly doubles in size, don’t be surprised — it’s not magic, it’s just Go being really smart.

The Magic Behind Go’s cap() Function: How Slice Capacity Really Works