How Go Slices Really Work: Header Layout, append Growth, Shared Backing Arrays and Pitfalls

Key takeaways

Go slice internal structure, memory allocation mechanism, capacity vs length, append operation principles, performance optimization techniques.

Overview

Almost every surprising slice behavior in Go — an append that changes another variable, memory that is never freed, a function that “loses” appended elements — follows from one fact: a slice is a small struct pointing into an array it does not own. This guide builds that model first, then uses it to explain append growth, copying, the classic pitfalls, and the standard-library helpers that avoid them.

Introduction: “Why Do Slices Behave This Way?”

Real-World Problem Scenarios

Scenario 1: Unexpected Memory Usage
Copied a slice but memory didn’t double. Why?

Scenario 2: Strange Behavior After append
Appended to slice and original was modified. What happened?

Scenario 3: Performance Degradation
Appending in loop makes program slow. How to optimize?


Slice Internal Structure

What is a Slice?

A slice is Go’s dynamic array. Internally, it’s a struct with 3 fields:

type slice struct {
    ptr    unsafe.Pointer  // Array pointer
    len    int              // Current length
    cap    int              // Capacity
}

Visualization

flowchart TB
    subgraph Slice["Slice Struct"]
        PTR["ptr: 0x1000"]
        LEN["len: 3"]
        CAP["cap: 5"]
    end
    
    subgraph Array["Actual Array (Memory)"]
        A0["[0]: 10"]
        A1["[1]: 20"]
        A2["[2]: 30"]
        A3["[3]: 0"]
        A4["[4]: 0"]
    end
    
    PTR --> A0

Actual Memory Layout

package main
import (
    "fmt"
    "unsafe"
)
func main() {
    s := []int{10, 20, 30}
    
    // Slice header size
    fmt.Printf("Slice header size: %d bytes\n", unsafe.Sizeof(s))
    // 24 bytes (pointer 8 + len 8 + cap 8)
    
    // Actual data size
    fmt.Printf("Data size: %d bytes\n", len(s)*int(unsafe.Sizeof(s[0])))
    // 24 bytes (int 8 * 3)
    
    // Pointer address
    fmt.Printf("Pointer: %p\n", &s[0])
    fmt.Printf("Length: %d\n", len(s))
    fmt.Printf("Capacity: %d\n", cap(s))
}

Output:

Slice header size: 24 bytes
Data size: 24 bytes
Pointer: 0xc000018030
Length: 3
Capacity: 3

The 24 bytes are the whole slice value on a 64-bit platform; the three ints live in a separate backing array. That split is the key to everything that follows. Assigning a slice, passing it to a function, or storing it in a struct copies only the 24-byte header, so it is cheap regardless of how many elements there are — and every copy points at the same array. len is how many elements this slice can see; cap is how many exist from the slice’s starting point to the end of the backing array. Indexing is bounds-checked against len, not cap: s[3] on this slice panics with index out of range [3] with length 3, even if the array behind it were larger. The runtime’s actual definition in runtime/slice.go matches the struct shown above, which is why unsafe.Sizeof reports 24.


Memory Allocation Mechanism

Creating Slices with make

// Method 1: Specify len only
s1 := make([]int, 5)
// len: 5, cap: 5
// [0, 0, 0, 0, 0]
// Method 2: Specify len and cap
s2 := make([]int, 3, 10)
// len: 3, cap: 10
// [0, 0, 0] + 7 reserved spaces
// Method 3: Literal
s3 := []int{1, 2, 3}
// len: 3, cap: 3

The two-argument and three-argument forms of make are easy to confuse, and the mistake is common: make([]int, n) creates n zero elements, so following it with append in a loop produces n zeros followed by your data. When you intend to append, use make([]int, 0, n); when you intend to assign by index, use make([]int, n). Every element make creates is zeroed, which is part of why Go slices never expose uninitialized memory.

nil Slice vs Empty Slice

package main
import "fmt"
func main() {
    // nil slice
    var s1 []int
    fmt.Printf("s1: %v, len: %d, cap: %d, nil: %v\n", 
               s1, len(s1), cap(s1), s1 == nil)
    // s1: [], len: 0, cap: 0, nil: true
    
    // Empty slice
    s2 := []int{}
    fmt.Printf("s2: %v, len: %d, cap: %d, nil: %v\n", 
               s2, len(s2), cap(s2), s2 == nil)
    // s2: [], len: 0, cap: 0, nil: false
    
    s3 := make([]int, 0)
    fmt.Printf("s3: %v, len: %d, cap: %d, nil: %v\n", 
               s3, len(s3), cap(s3), s3 == nil)
    // s3: [], len: 0, cap: 0, nil: false
}

Difference:

  • nil slice: Pointer is nil, no memory allocation
  • Empty slice: Non-nil pointer, but for zero-length slices the runtime points at a shared zero-size address, so nothing meaningful is allocated

Production Recommendation: Be careful with JSON encoding - [] vs null difference

For almost all operations the two behave identically: len, cap, range, and append work on a nil slice without any special case, which is why idiomatic Go declares var s []int and appends to it. The differences are observable only where something checks for nil explicitly. encoding/json marshals a nil slice as null and an empty slice as [], which matters to JavaScript clients that call .length on the field; reflect.DeepEqual treats them as unequal, which surprises tests. If an API contract requires [], initialize the field with []T{} (or make([]T, 0)) before encoding rather than relying on the zero value.


append Operation Principles

Basic append

package main
import "fmt"
func main() {
    s := []int{1, 2, 3}
    fmt.Printf("Before: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), &s[0])
    
    s = append(s, 4)
    fmt.Printf("After:  len=%d, cap=%d, ptr=%p\n", len(s), cap(s), &s[0])
}

Output:

Before: len=3, cap=3, ptr=0xc000018030
After:  len=4, cap=6, ptr=0xc000018060

Note: Pointer changed → New array allocated

append returns a new slice header, which is why its result must always be assigned (s = append(s, 4)); a bare append(s, 4) statement does not even compile (append(s, 4) (value of type []int) is not used), but assigning the result to the wrong variable compiles fine and is the real-world version of this bug. When there is room (len < cap), append writes into the existing array and returns a header with a larger len. When there is not, it allocates a bigger array, copies the elements, and returns a header pointing at the new array — the old array stays alive only as long as something still references it.

Capacity Growth Algorithm

Go allocates a new array and copies existing data when capacity is insufficient. Growth Rules (Go 1.18+):

  • cap < 256: 2x growth
  • cap >= 256: growth factor tapers smoothly from 2x toward 1.25x

The exact rule since Go 1.18 is that for larger slices the new capacity grows by (oldcap + 3*256) / 4 per step, which starts near 2x just above 256 and approaches 1.25x for very large slices, avoiding the abrupt jump the older rule (2x below 1024, 1.25x above) had. The result is then rounded up to the allocator’s size classes, so observed capacities are often a little larger than the formula predicts — for example, appending to a slice of bytes tends to jump to capacities like 8, 16, 32, 48. Treat the growth pattern as an implementation detail: the guarantee is amortized O(1) appends, not specific numbers.

package main
import "fmt"
func main() {
    s := make([]int, 0, 1)
    
    for i := 0; i < 10; i++ {
        oldCap := cap(s)
        s = append(s, i)
        newCap := cap(s)
        
        if oldCap != newCap {
            fmt.Printf("len=%d, cap: %d -> %d\n", len(s), oldCap, newCap)
        }
    }
}

Output:

len=2, cap: 1 -> 2
len=3, cap: 2 -> 4
len=5, cap: 4 -> 8
len=9, cap: 8 -> 16

append Performance Analysis

package main
import (
    "fmt"
    "time"
)
func benchmarkAppend(n int) time.Duration {
    start := time.Now()
    
    s := []int{}
    for i := 0; i < n; i++ {
        s = append(s, i)
    }
    
    return time.Since(start)
}
func benchmarkPrealloc(n int) time.Duration {
    start := time.Now()
    
    s := make([]int, 0, n)  // Pre-allocation
    for i := 0; i < n; i++ {
        s = append(s, i)
    }
    
    return time.Since(start)
}
func main() {
    n := 1000000
    
    t1 := benchmarkAppend(n)
    t2 := benchmarkPrealloc(n)
    
    fmt.Printf("append:    %v\n", t1)
    fmt.Printf("prealloc:  %v\n", t2)
    fmt.Printf("improvement: %.2fx\n", float64(t1)/float64(t2))
}

Result (illustrative; numbers vary by machine and Go version):

append:    15.2ms
prealloc:  3.8ms
improvement: 4.00x

Conclusion: pre-allocation typically gives a several-fold improvement for large appends

The gap comes from two costs that pre-allocation removes: about twenty reallocations for a million elements, each copying everything accumulated so far, and the garbage those abandoned arrays leave for the collector. For precise comparisons, use a real benchmark rather than time.Since in main — func BenchmarkAppend(b *testing.B) run with go test -bench . -benchmem reports allocations per operation alongside time, and the allocs/op column makes the difference unmistakable. Pre-allocating only helps when you know (or can estimate) the final size; over-allocating “just in case” wastes memory that is held for as long as the slice lives.


Slice Copy vs Reference

Is Slice a Reference Type?

Important: Slices are value types, but share internal pointers.

package main
import "fmt"
func main() {
    s1 := []int{1, 2, 3}
    s2 := s1  // Copy slice header
    
    fmt.Printf("s1: %p, s2: %p\n", &s1, &s2)
    // Different addresses (separate headers)
    
    fmt.Printf("s1[0]: %p, s2[0]: %p\n", &s1[0], &s2[0])
    // Same address (shared array)
    
    s2[0] = 999
    fmt.Println("s1:", s1)  // [999, 2, 3]
    fmt.Println("s2:", s2)  // [999, 2, 3]
}

append and References

package main
import "fmt"
func main() {
    s1 := []int{1, 2, 3}
    s2 := s1
    
    // Case 1: Sufficient capacity (no reallocation)
    s1 = append(s1[:2], 999)  // len=3, cap=3 → no reallocation
    fmt.Println("s1:", s1)  // [1, 2, 999]
    fmt.Println("s2:", s2)  // [1, 2, 999] ← s2 affected!
    
    // Case 2: Insufficient capacity (reallocation)
    s3 := []int{1, 2, 3}
    s4 := s3
    
    s3 = append(s3, 4)  // cap exceeded → new array
    s3[0] = 999
    
    fmt.Println("s3:", s3)  // [999, 2, 3, 4]
    fmt.Println("s4:", s4)  // [1, 2, 3] ← s4 unaffected
}

This pair of cases is the heart of the “append changed my other slice” bug: whether two slices stay linked after an append depends on spare capacity, which is invisible at the call site and can change as data grows. Code that works in tests with small inputs (where capacity happened to be exact) can start corrupting data in production when a slice created by make(..., 0, 100) is shared. The robust rule is: if you give a slice to someone else and then append to it, or append to a slice you received, assume the backing array may be shared. Either copy first, or cap the slice with a full slice expression — s[:len(s):len(s)] — so the next append is forced to allocate.

When I review Go code, the most common form of this bug is a helper that builds several results from one base slice: a := append(base, x) and b := append(base, y). If base has spare capacity, a and b share the same last element, and whichever append ran second wins in both.

Deep Copy

package main
import "fmt"
func main() {
    s1 := []int{1, 2, 3, 4, 5}
    
    // Method 1: Use copy
    s2 := make([]int, len(s1))
    copy(s2, s1)
    
    // Method 2: Use append
    s3 := append([]int(nil), s1...)
    
    // Method 3: Manual copy
    s4 := make([]int, len(s1))
    for i, v := range s1 {
        s4[i] = v
    }
    
    s1[0] = 999
    
    fmt.Println("s1:", s1)  // [999, 2, 3, 4, 5]
    fmt.Println("s2:", s2)  // [1, 2, 3, 4, 5]
    fmt.Println("s3:", s3)  // [1, 2, 3, 4, 5]
    fmt.Println("s4:", s4)  // [1, 2, 3, 4, 5]
}

Since Go 1.21, slices.Clone(s1) from the standard slices package does the same thing in one call and is the clearest way to express intent. The variants differ in one edge case: append([]int(nil), s1...) returns nil when s1 is empty, while make plus copy returns an empty non-nil slice — which matters for the JSON encoding difference described earlier. copy copies min(len(dst), len(src)) elements and returns that count, so copying into make([]int, 0, n) silently copies nothing; the destination needs length, not just capacity. All of these are shallow copies: if the elements are pointers, maps, or slices themselves, the copies still share what those elements point to.


Performance Optimization

Pre-allocation

package main
import (
    "fmt"
    "time"
)
func withoutPrealloc(n int) time.Duration {
    start := time.Now()
    
    var s []int
    for i := 0; i < n; i++ {
        s = append(s, i)
    }
    
    return time.Since(start)
}
func withPrealloc(n int) time.Duration {
    start := time.Now()
    
    s := make([]int, 0, n)
    for i := 0; i < n; i++ {
        s = append(s, i)
    }
    
    return time.Since(start)
}
func main() {
    n := 1000000
    
    t1 := withoutPrealloc(n)
    t2 := withPrealloc(n)
    
    fmt.Printf("Without prealloc: %v\n", t1)
    fmt.Printf("With prealloc:    %v\n", t2)
    fmt.Printf("Improvement:      %.2fx\n", float64(t1)/float64(t2))
}

Result (illustrative):

Without prealloc: 18.5ms
With prealloc:    4.2ms
Improvement:      4.40x

Slicing Optimization

package main
import "fmt"
func main() {
    data := []byte("Hello, World! This is a long string.")
    
    // ❌ Memory leak risk
    // Need small part but keep entire array
    small := data[0:5]  // "Hello"
    fmt.Println(string(small))
    // small is small but references entire data
    
    // ✅ Copy to allow memory release
    small2 := make([]byte, 5)
    copy(small2, data[0:5])
    // Now data can be GC'd
    
    // ✅ Full slice expression (available since Go 1.2)
    small3 := data[0:5:5]  // [low:high:max]
    // Limit cap to 5 → new array on append (but still references data's array)
}

The two fixes solve different problems, and mixing them up is common. Copying (small2) creates a new 5-byte array, so the large one can be garbage-collected once nothing else refers to it — this is the fix for memory retention. The full slice expression (small3) only limits capacity: it prevents a later append from overwriting data[5:], but small3 still points into the original array and keeps all of it alive. Use data[lo:hi:hi] to protect neighbors from appends, and copy (or slices.Clone, or bytes.Clone for byte slices) to release memory. The retention problem only matters when the original is large and the retained piece lives long — for example, keeping a 20-byte header parsed from a 10 MB request body in a cache.


Practical Patterns

Pattern 1: Filtering

package main
import "fmt"
func filter(s []int, predicate func(int) bool) []int {
    result := make([]int, 0, len(s))  // Pre-allocate
    
    for _, v := range s {
        if predicate(v) {
            result = append(result, v)
        }
    }
    
    return result
}
func main() {
    numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    
    evens := filter(numbers, func(n int) bool {
        return n%2 == 0
    })
    
    fmt.Println("Evens:", evens)  // [2, 4, 6, 8, 10]
}

Pre-allocating len(s) is an upper bound: it avoids reallocations at the cost of possibly reserving more than needed, which is a good trade for short-lived results. An in-place alternative reuses the input’s array — result := s[:0] followed by the same loop — which allocates nothing but overwrites the original slice’s contents, so it is only safe when the caller no longer needs the unfiltered data. The standard library’s slices.DeleteFunc (Go 1.21) implements that in-place version.

Pattern 2: Map Transformation

package main
import "fmt"
func mapSlice[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    
    for i, v := range s {
        result[i] = f(v)
    }
    
    return result
}
func main() {
    numbers := []int{1, 2, 3, 4, 5}
    
    squared := mapSlice(numbers, func(n int) int {
        return n * n
    })
    
    fmt.Println("Squared:", squared)  // [1, 4, 9, 16, 25]
    
    strings := mapSlice(numbers, func(n int) string {
        return fmt.Sprintf("num-%d", n)
    })
    
    fmt.Println("Strings:", strings)  // [num-1, num-2, ...]
}

Pattern 3: Reduce

package main
import "fmt"
func reduce[T, U any](s []T, initial U, f func(U, T) U) U {
    result := initial
    
    for _, v := range s {
        result = f(result, v)
    }
    
    return result
}
func main() {
    numbers := []int{1, 2, 3, 4, 5}
    
    sum := reduce(numbers, 0, func(acc, n int) int {
        return acc + n
    })
    
    fmt.Println("Sum:", sum)  // 15
    
    product := reduce(numbers, 1, func(acc, n int) int {
        return acc * n
    })
    
    fmt.Println("Product:", product)  // 120
}

Generic helpers like mapSlice and reduce are easy to write since Go 1.18, but idiomatic Go still leans toward plain loops for one-off transformations; the standard library deliberately provides slices functions for searching, sorting, and editing rather than a functional pipeline API. The helpers earn their place when the same transformation appears in many places. Note that mapSlice uses make([]U, len(s)) with index assignment rather than append, because the output length is known exactly — the fastest form available.


Pitfalls and Solutions

Pitfall 1: Memory Leak After Slicing

package main
import (
    "fmt"
    "runtime"
)
func printMemory() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("Alloc: %d MB\n", m.Alloc/1024/1024)
}
func main() {
    // Create large slice
    data := make([]byte, 100*1024*1024)  // 100MB
    for i := range data {
        data[i] = byte(i)
    }
    
    printMemory()  // Alloc: 100 MB
    
    // ❌ Use small part but keep entire array
    small := data[0:10]
    data = nil  // data is nil but
    
    runtime.GC()
    printMemory()  // Alloc: 100 MB (still high)
    
    // small still references entire array
    fmt.Println(len(small))
    
    // ✅ Fix with copy (copy from small: data is already nil here)
    small2 := make([]byte, 10)
    copy(small2, small)
    small = nil
    
    runtime.GC()
    printMemory()  // Alloc: ~0 MB
    fmt.Println(len(small2))
}

An earlier version of this example copied from data[0:10] after setting data = nil, which panics with slice bounds out of range [:10] with capacity 0; the fix is to copy from small, which still holds the bytes. The lesson of the example is that the garbage collector frees arrays, not slices: as long as any slice header — however short — points into the 100 MB array, the whole array stays reachable. In real programs this rarely looks like the toy version. It looks like a cache of tokens parsed from large files, a list of substrings (strings share memory the same way), or a bytes.Split result stored long-term. Heap profiles from pprof show the allocation site of the big array, not the small slice keeping it alive, which is what makes these leaks hard to spot; if inuse_space points at a large read that should have been freed, look for sub-slices of it that outlive the request.

Pitfall 2: Slice Pointers in Loops

package main
import "fmt"
func main() {
    s := []int{1, 2, 3}
    
    // ❌ Wrong pattern (before Go 1.22)
    var ptrs []*int
    for _, v := range s {
        ptrs = append(ptrs, &v)  // pre-1.22: v address is always same
    }
    
    for _, ptr := range ptrs {
        fmt.Print(*ptr, " ")  // pre-1.22: 3 3 3; Go 1.22+: 1 2 3 (copies, not elements)
    }
    fmt.Println()
    
    // ✅ Correct pattern
    var ptrs2 []*int
    for i := range s {
        ptrs2 = append(ptrs2, &s[i])
    }
    
    for _, ptr := range ptrs2 {
        fmt.Print(*ptr, " ")  // 1 2 3
    }
    fmt.Println()
}

Go 1.22 changed loop semantics so that each iteration has its own variable, which fixed the “3 3 3” output for modules declaring go 1.22 or later in go.mod (see the FAQ). The underlying point still matters: v is a copy of the element, so &v never points into the slice. &s[i] does — but only until the next reallocation. If you take &s[i] and then append to s beyond its capacity, the pointer keeps referring to the old array, and writes through it are silently lost. Storing pointers to slice elements is safe only when the slice will not grow; otherwise store indexes, or make it a slice of pointers ([]*T) from the start.

Pitfall 3: Function Arguments

package main
import "fmt"
func modifySlice(s []int) {
    s[0] = 999  // Modifies original
    
    s = append(s, 100)  // New array allocated (separated from original)
}
func main() {
    s := []int{1, 2, 3}
    
    modifySlice(s)
    
    fmt.Println(s)  // [999, 2, 3] ← only s[0] changed
    // append doesn't affect original
}

Solution: Pass as pointer

func modifySlicePtr(s *[]int) {
    (*s)[0] = 999
    *s = append(*s, 100)
}
func main() {
    s := []int{1, 2, 3}
    
    modifySlicePtr(&s)
    
    fmt.Println(s)  // [999, 2, 3, 100]
}

The first version’s behavior is subtler than “append allocates a new array”. Here s has len == cap == 3, so the append inside the function reallocates and the caller never sees 100. If the caller’s slice had spare capacity, the append would write 100 into the shared array — and the caller still would not see it, because its header’s len is still 3. The value would sit invisibly in the backing array and appear later if the caller re-slices to a larger length. A function that appends therefore has only one reliable way to communicate the result: return the new slice.

That is why the idiomatic solution is not a pointer but a return value, as append itself does: func addItem(s []int, v int) []int { return append(s, v) } and s = addItem(s, 100). Pointers to slices (*[]int) are legitimate — for example in methods on a named slice type or when a function must modify a slice field in a struct — but they make call sites harder to read, and they are rarely the first choice in Go code.


Advanced Techniques

Slice Pooling

package main
import (
    "fmt"
    "sync"
)
var bufferPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 0, 1024)
    },
}
func processData(data []byte) {
    // Get buffer
    buf := bufferPool.Get().([]byte)
    buf = buf[:0]  // Reset length
    
    // Use
    buf = append(buf, data...)
    fmt.Printf("Processed: %d bytes\n", len(buf))
    
    // Return
    bufferPool.Put(buf)
}
func main() {
    for i := 0; i < 5; i++ {
        data := []byte(fmt.Sprintf("Data %d", i))
        processData(data)
    }
}

sync.Pool reuses buffers across calls so that hot paths — encoding responses, formatting log lines — do not allocate a new buffer each time. This version has a known inefficiency: putting a []byte (a three-word struct) into an interface{} allocates a small object to hold the header on every Put, which partly defeats the purpose; staticcheck reports it as SA6002. Pools usually store *[]byte or *bytes.Buffer instead. Two more rules keep pools safe. Never use a buffer after Put — another goroutine may already be writing to it. And cap what you return: if one request grew a buffer to 50 MB, putting it back keeps 50 MB alive for every future user of the pool, so discard buffers above a size limit. Pools are also emptied during garbage collection, so they are a cache for short-lived objects, not a place to keep state.

Efficient Insert/Delete

package main
import "fmt"
// Insert at middle
func insert[T any](s []T, index int, value T) []T {
    s = append(s[:index], append([]T{value}, s[index:]...)...)
    return s
}
// Delete at middle
func remove[T any](s []T, index int) []T {
    return append(s[:index], s[index+1:]...)
}
// Efficient delete (order doesn't matter)
func removeFast[T any](s []T, index int) []T {
    s[index] = s[len(s)-1]  // Overwrite with last element
    return s[:len(s)-1]
}
func main() {
    s := []int{1, 2, 3, 4, 5}
    
    s = insert(s, 2, 99)
    fmt.Println("Insert:", s)  // [1, 2, 99, 3, 4, 5]
    
    s = remove(s, 2)
    fmt.Println("Remove:", s)  // [1, 2, 3, 4, 5]
    
    s = removeFast(s, 2)
    fmt.Println("Fast remove:", s)  // [1, 2, 5, 4]
}

These helpers all modify the backing array they are given, which is correct only if the caller treats the returned slice as the new value and no other slice shares the array. remove shifts elements left in place, so any other slice over the same array sees the shifted contents; insert builds a temporary one-element slice and may allocate twice. The standard library now covers all three: slices.Insert(s, i, v), slices.Delete(s, i, i+1), and ordering-agnostic removal remains a two-line idiom. One detail matters for slices of pointers or structs containing pointers: after deleting, the old last element is still referenced from the array’s unused tail, so the garbage collector cannot free what it points to. Since Go 1.22, slices.Delete zeroes that tail for you; hand-written versions should do var zero T; s[len(s)-1] = zero before shrinking.


Further reading


Frequently Asked Questions (FAQ)

Q. Does the “3 3 3” loop pointer pitfall still happen in Go 1.22 and later?

A. Not in modules whose go.mod declares go 1.22 or newer. Since Go 1.22 each iteration of a for range loop gets its own variable, so &v yields a different address every time and the example prints 1 2 3. The pointers still refer to per-iteration copies, not to the slice elements, so writing through *ptr does not change s; take &s[i] when you need to point at the element itself, and remember that a later append that reallocates leaves those pointers aimed at the old backing array.