---
title: "05 — Strings and Maps — Immutable Text and Keyed Lookup"
uid: strings-and-maps
tags: ["golang", "unicode", "comma-ok", "strings", "roadmap:golang", "maps", "fundamentals"]
excerpt: "A string is an immutable byte slice you walk by rune; a map is a reference-typed hash table you probe with the comma-ok idiom. Two surprises from familiar-looking types."
date: 2026-08-13T03:28:12+0000
source: https://www.aveshina.my.id/en/blog/strings-and-maps
---

Strings and maps looked familiar from other languages, and then both surprised me. The model that made both click: **a string is an immutable sequence of bytes that you walk by rune when you care about characters, and a map is a reference-typed hash table whose lookups return two values on purpose, so you never confuse "missing key" with "zero value."** [1][2] Once I internalized the byte-vs-rune split for strings and the comma-ok pattern for maps, the two most common sources of subtle bugs — broken Unicode handling and mistaking 0 for "absent" — stopped showing up.

## Strings are immutable byte sequences

A Go string is a read-only sequence of bytes — typically UTF-8 encoded text, but the type itself makes no encoding guarantee [1]. Three properties shape everything:

- **Immutable.** You cannot change a string in place. Every "modification" (concatenation, replacement) builds a new string.
- **Bytes, not characters.** Indexing s[i] returns a byte, the i-th byte — not the i-th human character. Operations create new strings rather than mutating.
- **UTF-8 by convention.** String literals are UTF-8, and most Go code assumes it, but the length of a string is its byte count, not its character count.

That last point is where naive code breaks. A string holding "中" has len(s) == 3 (three UTF-8 bytes), not 1. To count characters or index by character, you must decode runes.

## Walking a string: bytes vs runes

Go gives you two ways to iterate a string, and the choice determines what you see [1]:

```
s := "Go中"

// Byte-by-byte (indexing)
for i := 0; i < len(s); i++ {
    fmt.Printf("%d: %x\n", i, s[i])   // 0: 47, 1: 6f, 2: e4, 3: b8, 4: ad
}

// Rune-by-rune (for range)
for i, r := range s {
    fmt.Printf("%d: %c\n", i, r)      // 0: G, 1: o, 2: 中
}
```

The for range form automatically decodes UTF-8 as it walks, yielding each rune and the _byte offset_ where it begins (note i jumps from 1 to 2, skipping to where the multi-byte character started). For random access by character index, convert once: runes := []rune(s), then index runes[2]. The rule I keep is: byte work uses []byte(s) and indexing; character work uses for range or []rune [4]. Mixing them up is the classic source of "my string handling works in English and breaks on emoji."

## Maps — reference-typed hash tables

A map is Go's associative array — keys to values, backed by a hash table [2]:

```
ages := make(map[string]int)
ages["alice"] = 30
ages["bob"] = 25

// or with a literal
ages := map[string]int{"alice": 30, "bob": 25}
```

Keys must be a comparable type (anything that supports == — strings, numbers, bools, pointers, interfaces, structs, arrays of those). Values can be any type, including other maps or slices. Maps are **reference types**: passing a map to a function does not copy the data, and modifying it inside the function affects the caller's map [2].

Two practical gotchas. A nil map reads fine — a missing key returns the zero value — but writing to a nil map panics, so you must make it first. And map iteration order is **deliberately randomized** by the runtime, so you never rely on it; if you need ordered output, extract and sort the keys [5].

## The comma-ok idiom

The single most important map pattern is the **comma-ok** lookup, which disambiguates "the key is absent" from "the key exists and its value is the zero value" [3]:

```
ages := map[string]int{"alice": 30}

v := ages["charlie"]      // v == 0  — but is charlie absent, or present with age 0?
v, ok := ages["charlie"]  // v == 0, ok == false — definitively absent
v, ok = ages["alice"]     // v == 30, ok == true
```

Without the second return value, there is no way to tell ages["charlie"] (missing → 0) from a hypothetical ages["charlie"] = 0 (present with 0). The ok boolean resolves it. The same value, ok := shape recurs throughout Go — type assertions and channel receives use the identical idiom — so it is worth recognizing it as a language-wide convention rather than a one-off [3].

Deleting a key is delete(m, key), which is a no-op if the key is absent. There is no "clear a map" built-in; you either iterate and delete, or reassign the variable to a fresh make.

## How I use this

Two habits crystallized from these notes. For any string handling that crosses outside ASCII — filenames, user input, display text — I default to for range or []rune and treat raw byte indexing as a smell. For map lookups, I make the comma-ok form the _default_ rather than the exception: a bare v := m[k] is something I write only when I genuinely do not care whether the key was present, because every "I assumed the zero value meant absent" bug traces back to skipping ok. The immutable-byte-slice picture for strings and the always-two-value probe for maps are the two ways of thinking that stopped the leaks.

## References

[1] The Go Authors, "Strings, bytes, runes and characters in Go," The Go Blog, 2013. [Online]. Available: [https://go.dev/blog/strings](https://go.dev/blog/strings)

[2] The Go Authors, "Go Maps in Action," The Go Blog, 2013. [Online]. Available: [https://go.dev/blog/maps](https://go.dev/blog/maps)

[3] freeCodeCamp, "How the Comma Ok Idiom and Package System Work in Go," 2024. [Online]. Available: [https://www.freecodecamp.org/news/how-the-comma-ok-idiom-and-package-system-work-in-go/](https://www.freecodecamp.org/news/how-the-comma-ok-idiom-and-package-system-work-in-go/)

[4] Medium / @golangda, "Golang Quick Reference: Strings — Introduction," 2024. [Online]. Available: [https://medium.com/@golangda/golang-quick-reference-strings-0d68bb036c29](https://medium.com/@golangda/golang-quick-reference-strings-0d68bb036c29)

[5] Leapcell, "Iterating Over Maps in Go: Methods, Order, and Best Practices," 2024. [Online]. Available: [https://leapcell.io/blog/iterating-over-maps-in-go-methods-order-and-best-practices](https://leapcell.io/blog/iterating-over-maps-in-go-methods-order-and-best-practices)

```quiz
Q: len("中") in Go returns 3, not 1, because…
- the string holds a single rune but len counts bytes, and 中 is 3 bytes in UTF-8
- len counts characters, and 中 counts as 3 for historical reasons
correct: 0
explain: A Go string is a byte sequence and len returns the byte count. 中 is three bytes in UTF-8. Use utf8.RuneCountInString(s) or a for-range loop to count characters.

Q: What does a for-range loop over a string yield per iteration?
- the byte index and the byte value
- the byte offset where the rune starts, and the decoded rune value
correct: 1
explain: for range over a string decodes UTF-8 as it walks. The first value is the byte offset of the current rune (which jumps for multi-byte characters), the second is the rune itself.

Q: A nil map (declared but not made)…
- panics on both reads and writes
- returns the zero value on reads, but panics on writes
correct: 1
explain: Reading a missing key from a nil map returns the value type's zero value. But writing to a nil map panics — you must make(map[...]...) it first.

Q: Why does Go map lookup offer the comma-ok form, `v, ok := m[key]`?
- because maps can store multiple values per key
- to distinguish a missing key from a key whose value is the zero value
correct: 1
explain: Without ok, you cannot tell whether `m[key]` returned 0 because the key is absent or because it is present with value 0. ok is true only when the key exists.

Q: Map iteration order in Go is…
- insertion order
- deliberately randomized by the runtime
correct: 1
explain: The runtime randomizes map iteration order. Code must never depend on it; sort the keys explicitly when order matters.
```
