Slices, Maps & Strings
Irbisa · cheatsheetSeptember 19, 2026

Slices, Maps & Strings

Slice headers, append aliasing, map internals, runes and strings.Builder

Junior Developer16 itemscompressed for a skim
  1. 01

    Your search endpoint answers {"items": null} when nothing matches and the mobile client crashes on it. The handler starts with var results []Product and never appends. How does that differ from results := []Product{}?

    Easy

    A nil slice and an empty slice behave identically in Go and differently on the wire: var results []Product marshals to null, []Product{} marshals to [].

    var a []Product      // nil header: ptr nil, len 0, cap 0
    b := []Product{}     // non-nil ptr, len 0, cap 0
    fmt.Println(a == nil, b == nil, len(a) == len(b)) // true false true
    
    a = append(a, p)     // append allocates for a nil slice: no initialisation needed
    …
  2. 02

    A validator rejects the username Дмитрий as 14 characters long, and name[0] prints 208 instead of a letter. What is len counting?

    Easy

    A Go string is an immutable slice of bytes holding UTF-8, so len is a byte count and s[i] is a single byteДмитрий is 7 characters stored in 14 bytes.

    name := "Дмитрий"
    
    fmt.Println(len(name))                       // 14 — bytes
    fmt.Println(utf8.RuneCountInString(name))    // 7  — code points
    fmt.Println(name[0])                         // 208 — one byte, not "Д"
    …
  3. 03

    if orders[userID] == 0 { return ErrNoSuchUser } reports a missing user for someone who genuinely has zero orders, and a lookup for a key nobody ever inserted returns 0 rather than failing. How do you ask a map whether a key is there?

    Easy

    Indexing a map always succeeds and returns the value type's zero value, so presence is a separate question answered by the comma-ok form v, ok := m[k].

    orders := map[string]int{"ada": 0} // ada exists and has zero orders
    
    fmt.Println(orders["grace"])       // 0 — missing key, no panic
    n, ok := orders["ada"]
    fmt.Println(n, ok)                 // 0 true — stored zero, not absent
    …
  4. 04

    Passing a [32]byte to a helper that overwrites it leaves the caller's copy untouched; change the parameter to []byte and the caller's data really does change. What is different about the two types?

    Easy

    An array is a value whose length is part of its type, so passing one copies every byte; a slice is a three-word header — pointer, length, capacity — so passing one copies the header and shares the array it points at.

    func zeroArray(a [4]byte) { a[0] = 0 } // operates on a copy
    func zeroSlice(s []byte)  { s[0] = 0 } // operates on the caller's array
    
    arr := [4]byte{1, 2, 3, 4}
    zeroArray(arr)
    fmt.Println(arr) // [1 2 3 4] — untouched
    …
  5. 05

    func addDefaults(tags []string) appends two entries and the caller still sees its original three. A colleague changes the parameter to *[]string and it "works". What does append do to the slice you handed over?

    Medium

    The callee receives a copy of the three-word header, so append updates that copy's length and the caller's header never moves — which is why append returns a slice and its result must be assigned.

    // ❌ appends into a copy of the header; the caller sees nothing
    func addDefaults(tags []string) {
    	tags = append(tags, "env:prod", "team:core")
    }
    
    // ✅ take and return, the way append itself is shaped
    …
  6. 06

    A converter starts with out := make([]Row, len(in)), appends inside the loop, and returns twice as many rows as it was given — the first half of them zeroed. What is make's second argument?

    Medium

    make([]T, n) builds a slice already containing n zero values, and append adds after them; preallocation without the zeros is make([]T, 0, n).

    in := []string{"a", "b", "c"}
    
    // ❌ three zero Rows first, then the three real ones
    out := make([]Row, len(in))
    for _, v := range in {
    	out = append(out, Row{Key: v})
    …
  7. 07

    Two goroutines get their arguments from one base slice — x := append(base, "x") and then y := append(base, "y") — and x comes out holding "y". What do the two results share?

    Medium

    Both appends wrote into the same spare capacity of base's backing array, so the second overwrote the first: a slice expression never copies, it points a new header at memory that somebody else still owns.

    base := make([]string, 0, 4)
    base = append(base, "a", "b") // len 2, cap 4
    
    x := append(base, "x")
    y := append(base, "y")        // writes the same slot as x did
    fmt.Println(x, y, &x[0] == &y[0]) // [a b y] [a b y] true
    …
  8. 08

    Each request reads a 4 MB body and keeps only body[:64] from it as a fingerprint, yet memory climbs all day. What is retained is 64 bytes. Where did the megabytes go?

    Medium

    A subslice keeps the entire backing array alive: the garbage collector frees an array only when no slice header points anywhere into it, so 64 bytes of interest pin all 4 MB.

    // ❌ fingerprint is 64 bytes and keeps 4 MB reachable
    func (c *Cache) note(body []byte) {
    	c.prints = append(c.prints, body[:64])
    }
    
    // ✅ copy out what you keep; the body can be collected
    …
  9. 09

    Rendering a settings map straight into the response body passes locally and fails one CI run in five against the golden file. What is not stable?

    Medium

    Map iteration order is randomised by design — the runtime picks a fresh starting point for every range statement, so the order is not reproducible between runs, between statements, or even between two loops over the same untouched map.

    m := map[string]int{"a": 1, "b": 2, "c": 3}
    
    // ❌ a different byte sequence on every run
    var b strings.Builder
    for k, v := range m {
    	fmt.Fprintf(&b, "%s=%d;", k, v)
    …
  10. 10

    stats[key].Count++ refuses to compile with cannot assign to struct field stats[key].Count in map, while rows[i].Count++ over a slice of the same struct is fine. What is different about a map element?

    Medium

    A map element is not addressable: the table may move entries as it grows, so the language refuses to hand out a pointer into it — a slice element lives in a backing array that never moves under you, so it is addressable and assignable in place.

    type Stat struct{ Count int }
    
    stats := map[string]Stat{}
    
    stats["a"].Count++ // ❌ cannot assign to struct field stats["a"].Count in map
    p := &stats["a"]   // ❌ cannot take the address of stats["a"]
    …
  11. 11

    One cleanup loop deletes expired sessions while ranging the map; a second re-inserts renewed ones in the same loop and returns a different count on every run. A reviewer calls both undefined. Which one actually is?

    Medium

    Deleting during a range is explicitly safe; inserting is not — the spec says an entry removed during iteration will not be produced, while an entry created during iteration may be produced or may be skipped.

    // ✅ defined by the spec: a deleted entry will not be produced
    for k, s := range sessions {
    	if s.ExpiresAt.Before(now) {
    		delete(sessions, k)
    	}
    }
    …
  12. 12

    Assembling a 100 KB report with s += line in a loop takes 40 ms and allocates half a gigabyte for a 100 KB result. Where do the other 530 MB come from?

    Medium

    Strings are immutable, so s += line allocates a new string and copies both operands every time — n appends copy O(n^2) bytes in total; strings.Builder appends into one growing buffer and copies nothing at the end.

    // ❌ 41 ms, 531 MB allocated, 10051 allocations for a 100 KB result
    s := ""
    for _, line := range lines {
    	s += line
    }
    …
  13. 13

    A cache built on a plain map survives a week of staging and then takes the whole process down in production with fatal error: concurrent map writes — and the recover() in the HTTP middleware never runs. Why is it not a panic?

    Hard

    It is a runtime throw, not a panic: the map implementation notices two goroutines inside it at once and kills the process on purpose, because a half-written hash table cannot be unwound safely.

    // ❌ two requests writing the same map is a coin flip away from
    // "fatal error: concurrent map writes" — no recover() will save it
    type Cache struct {
    	entries map[string]Entry
    }
    …
  14. 14

    Emptying a two-million-entry lookup map — delete for every key, then clear(m) — leaves the heap exactly where it was. len(m) is 0. What is still allocated?

    Hard

    A Go map never shrinks. delete and clear empty the slots but keep every bucket the table ever grew, so the only way to give the memory back is to drop the map itself and let the GC take the whole thing.

    func heapMB() uint64 {
    	runtime.GC()
    	var ms runtime.MemStats
    	runtime.ReadMemStats(&ms)
    	return ms.HeapInuse >> 20
    }
    …
  15. 15

    An alloc profile of a hot parser is topped by runtime.slicebytetostring, yet the identical string(b) used as a map key two lines above never appears in it. Which conversions actually allocate?

    Hard

    A []byte to string conversion allocates and copies, because the result must be immutable — unless the compiler can prove the string does not outlive the expression, which it can for map lookups, comparisons and a few other shapes.

    var header = []byte("this-is-a-fairly-long-header-value-over-32-bytes")
    
    var sinkS string
    var sinkB []byte
    
    // measured with testing.AllocsPerRun:
    …
  16. 16

    sessions = append(sessions[:i], sessions[i+1:]...) removes the right element, but a heap profile still shows the removed *Session reachable and another slice over the same array now reports the last element twice. What did the append do?

    Hard

    The append shifted the tail down inside the same backing array: the removed pointer is still sitting in the now-unused final slot, and every other header over that array sees the shifted contents.

    // ❌ shifts in place, leaves the last pointer live past len(s)
    sessions = append(sessions[:i], sessions[i+1:]...)
    fmt.Println(sessions[:len(sessions)+1]) // the removed *Session, still reachable
    
    // ✅ same shift, vacated slots zeroed
    sessions = slices.Delete(sessions, i, i+1)
    …