AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 05 — Strings and Maps — Immutable Text and Keyed Lookup

05 — Strings and Maps — Immutable Text and Keyed Lookup

August 13, 20265 min read
Download as Markdown

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

[2] The Go Authors, "Go Maps in Action," The Go Blog, 2013. [Online]. Available: 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/

[4] Medium / @golangda, "Golang Quick Reference: Strings — Introduction," 2024. [Online]. Available: 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

Knowledge check · Question 1 of 5

len("中") in Go returns 3, not 1, because…

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!