Maps and Strings
Master Go's map type for key-value storage and understand string internals. Learn map operations, iteration, and string manipulation patterns.
Slices provide ordered sequences accessed by integer indices. But many problems require associating arbitrary keys with values—usernames to user records, file extensions to MIME types, cache keys to cached data. Hash tables solve this, and Go builds them into the language as maps.
Strings are Go's other ubiquitous type. Every program processes text—parsing configuration, formatting output, validating input. Go's string implementation is UTF-8 by default, immutable, and efficient. Understanding strings requires understanding runes, Go's representation of Unicode code points.
Maps
A map is an unordered collection of key-value pairs. Keys must be comparable types (we'll define that shortly), and all keys have the same type. All values have the same type, but keys and values can be different types:
ages := map[string]int{
"Alice": 30,
"Bob": 25,
"Carol": 35,
}
fmt.Println(ages["Alice"]) // 30
The type is map[KeyType]ValueType. Here it's map[string]int—strings map to integers.
Create maps with make:
ages := make(map[string]int)
ages["Alice"] = 30
ages["Bob"] = 25
fmt.Println(ages) // map[Alice:30 Bob:25]
The zero value of a map is nil:
var ages map[string]int
fmt.Println(ages == nil) // true
Reading from a nil map returns the value type's zero value, but writing to a nil map panics:
var ages map[string]int
fmt.Println(ages["Alice"]) // 0 (zero value for int)
ages["Alice"] = 30 // panic: assignment to entry in nil map
Always initialize maps before writing to them. Use make or a map literal.
Accessing Map Elements
Retrieve values by key:
age := ages["Alice"]
fmt.Println(age) // 30
If the key doesn't exist, you get the zero value:
age := ages["Unknown"]
fmt.Println(age) // 0
This creates ambiguity—did "Unknown" map to 0, or was the key absent? The two-value assignment form clarifies:
age, ok := ages["Unknown"]
if ok {
fmt.Println("Age:", age)
} else {
fmt.Println("Not found")
}
The second value is a boolean indicating whether the key exists. This pattern appears throughout Go—the "comma ok" idiom for operations that might fail or find nothing.
Use this pattern to distinguish between a key mapping to zero and a key being absent:
counts := map[string]int{
"Alice": 0,
}
count, ok := counts["Alice"]
fmt.Println(count, ok) // 0 true
count, ok = counts["Bob"]
fmt.Println(count, ok) // 0 false
Both return 0, but ok reveals whether the key exists.
Modifying Maps
Add or update entries by assignment:
ages["Alice"] = 31 // update
ages["Dave"] = 40 // add
There's no distinction between adding and updating—assignment handles both.
Delete entries with delete:
delete(ages, "Bob")
fmt.Println(ages) // map[Alice:31 Carol:35 Dave:40]
Deleting a non-existent key is safe—it does nothing. No error, no panic.
Iterating Over Maps
Range over maps provides key and value:
for name, age := range ages {
fmt.Println(name, age)
}
Iteration order is randomized. Run the same code twice and you'll get different orders:
Alice 30
Carol 35
Bob 25
Bob 25
Alice 30
Carol 35
This randomization is intentional. It prevents code from depending on iteration order. Map implementations don't guarantee order, so Go deliberately randomizes it to catch bugs early.
If you need sorted keys, extract them to a slice:
import "sort"
keys := make([]string, 0, len(ages))
for name := range ages {
keys = append(keys, name)
}
sort.Strings(keys)
for _, name := range keys {
fmt.Println(name, ages[name])
}
Maps Are Reference Types
Maps behave like slices in one important way—they're references to underlying data structures. Assigning a map to another variable doesn't copy the data:
ages := map[string]int{"Alice": 30}
other := ages
other["Alice"] = 31
fmt.Println(ages["Alice"]) // 31
Both ages and other reference the same underlying hash table. Changes through one are visible through the other.
Passing maps to functions works the same way:
func updateAge(m map[string]int, name string, age int) {
m[name] = age
}
ages := map[string]int{"Alice": 30}
updateAge(ages, "Alice", 31)
fmt.Println(ages["Alice"]) // 31
The function modifies the map that the caller holds. No pointer syntax is needed—maps are already references.
Key Types
Map keys must be comparable—you can use == to check equality. Basic types like integers, floats, strings, and booleans work:
var m1 map[int]string
var m2 map[float64]string
var m3 map[bool]string
Structs whose fields are all comparable work:
type Point struct {
X, Y int
}
locations := make(map[Point]string)
locations[Point{0, 0}] = "origin"
Slices, maps, and functions are not comparable and cannot be map keys:
var m map[[]int]string // Compile error
var m map[map[string]int]string // Compile error
If you need a slice as a key, convert it to a string or use a different approach. One common pattern is to create a composite key:
type cacheKey struct {
userID int
query string
}
cache := make(map[cacheKey]Result)
cache[cacheKey{123, "SELECT *"}] = result
Map Patterns
Counting occurrences:
text := "hello world"
counts := make(map[rune]int)
for _, char := range text {
counts[char]++
}
fmt.Println(counts) // map[32:1 100:1 101:1 104:1 108:3 111:2 114:1 119:1]
Incrementing a non-existent key works because the zero value for int is 0, and 0 + 1 gives 1.
Grouping by key:
type Person struct {
Name string
Age int
}
people := []Person{
{"Alice", 30},
{"Bob", 25},
{"Carol", 30},
}
byAge := make(map[int][]Person)
for _, p := range people {
byAge[p.Age] = append(byAge[p.Age], p)
}
fmt.Println(byAge[30]) // [{Alice 30} {Carol 30}]
Checking membership (set):
valid := map[string]bool{
"admin": true,
"user": true,
"guest": true,
}
if valid["admin"] {
fmt.Println("Valid role")
}
For sets, the value type is often bool or empty struct struct{}. The empty struct uses zero bytes:
valid := make(map[string]struct{})
valid["admin"] = struct{}{}
valid["user"] = struct{}{}
if _, ok := valid["admin"]; ok {
fmt.Println("Valid role")
}
Map Performance
Maps are hash tables with expected O(1) lookup, insertion, and deletion. Go's map implementation uses open addressing and grows automatically when load factor exceeds a threshold.
Pre-sizing maps helps performance when you know the approximate size:
// Bad: multiple rehashes as map grows
m := make(map[string]int)
for i := 0; i < 1000; i++ {
m[fmt.Sprintf("key%d", i)] = i
}
// Good: single allocation
m := make(map[string]int, 1000)
for i := 0; i < 1000; i++ {
m[fmt.Sprintf("key%d", i)] = i
}
The capacity hint prevents rehashing as the map grows.
Maps are not safe for concurrent access. If multiple goroutines read and write a map simultaneously, you'll get data races. Use sync.Mutex or sync.RWMutex to protect concurrent access, or use sync.Map for specific concurrent scenarios. We'll explore concurrency and synchronization in later articles.
Strings
Strings in Go are immutable sequences of bytes. The bytes represent UTF-8 encoded text:
s := "Hello, 世界"
fmt.Println(len(s)) // 13
The length is 13 bytes, not 9 characters. The Chinese characters "世界" each take 3 bytes in UTF-8. The len function returns bytes, not characters.
Strings are immutable. You cannot change individual bytes:
s := "hello"
s[0] = 'H' // Compile error: cannot assign to s[0]
To modify strings, convert to a byte slice, modify, and convert back:
s := "hello"
bytes := []byte(s)
bytes[0] = 'H'
s = string(bytes)
fmt.Println(s) // Hello
Each conversion allocates. For building strings incrementally, use strings.Builder:
import "strings"
var builder strings.Builder
for i := 0; i < 100; i++ {
builder.WriteString("x")
}
result := builder.String()
The builder amortizes allocations—it grows an internal buffer as needed, similar to how slices grow.
String Indexing
Indexing a string returns bytes, not characters:
s := "Hello"
fmt.Println(s[0]) // 72 (ASCII value of 'H')
The result is a byte (which is an alias for uint8). To get the character:
fmt.Printf("%c\n", s[0]) // H
Slicing strings produces substrings:
s := "Hello, World!"
fmt.Println(s[7:12]) // World
Slice indices are byte positions. For ASCII text, this works intuitively. For UTF-8 text with multi-byte characters, you can slice in the middle of a character:
s := "世界"
fmt.Println(s[0:1]) // � (invalid UTF-8 sequence)
You sliced between bytes of the first character, producing invalid UTF-8. To work with characters, use runes.
Runes
A rune represents a Unicode code point. It's an alias for int32:
var r rune = '世'
fmt.Println(r) // 19990 (Unicode code point)
Rune literals use single quotes. String literals use double quotes.
Convert strings to rune slices to work with characters:
s := "Hello, 世界"
runes := []rune(s)
fmt.Println(len(runes)) // 9 (characters)
runes[0] = 'h'
s = string(runes)
fmt.Println(s) // hello, 世界
This conversion allocates and decodes UTF-8. For large strings processed in hot loops, it's expensive. Often you can avoid conversion by using range, which decodes UTF-8 automatically:
s := "Hello, 世界"
for i, r := range s {
fmt.Printf("%d: %c\n", i, r)
}
Output:
0: H
1: e
2: l
3: l
4: o
5: ,
6:
7: 世
10: 界
The index jumps from 7 to 10 because "世" is 3 bytes. Range gives you the byte position and the decoded rune.
String Operations
The strings package provides string manipulation functions:
import "strings"
s := "Hello, World!"
fmt.Println(strings.Contains(s, "World")) // true
fmt.Println(strings.HasPrefix(s, "Hello")) // true
fmt.Println(strings.HasSuffix(s, "!")) // true
fmt.Println(strings.Index(s, "World")) // 7
fmt.Println(strings.Count(s, "l")) // 3
fmt.Println(strings.ToLower(s)) // hello, world!
fmt.Println(strings.ToUpper(s)) // HELLO, WORLD!
fmt.Println(strings.Replace(s, "World", "Go", 1)) // Hello, Go!
Splitting and joining:
s := "a,b,c,d"
parts := strings.Split(s, ",")
fmt.Println(parts) // [a b c d]
joined := strings.Join(parts, "-")
fmt.Println(joined) // a-b-c-d
Trimming whitespace:
s := " hello "
fmt.Println(strings.TrimSpace(s)) // "hello"
These functions don't modify the original string—they return new strings. Strings are immutable.
String Concatenation
The + operator concatenates strings:
first := "Hello"
last := "World"
full := first + " " + last
fmt.Println(full) // Hello World
Each concatenation allocates a new string. In loops, this is inefficient:
// Bad: allocates on every iteration
var result string
for i := 0; i < 1000; i++ {
result += "x"
}
Use strings.Builder:
var builder strings.Builder
for i := 0; i < 1000; i++ {
builder.WriteString("x")
}
result := builder.String()
Or for simple cases, use strings.Repeat:
result := strings.Repeat("x", 1000)
Strings as Bytes
Sometimes you need raw bytes instead of UTF-8 strings. Convert to []byte:
s := "hello"
bytes := []byte(s)
fmt.Println(bytes) // [104 101 108 108 111]
This conversion copies the string's bytes into a new slice. Modifying the slice doesn't affect the original string (strings are immutable, but the conversion creates a separate copy anyway).
Convert back to string:
bytes := []byte{72, 101, 108, 108, 111}
s := string(bytes)
fmt.Println(s) // Hello
Working with bytes is common for binary protocols, file I/O, and network communication where you're not dealing with text.
String Formatting
The fmt package formats values into strings:
name := "Alice"
age := 30
s := fmt.Sprintf("Name: %s, Age: %d", name, age)
fmt.Println(s) // Name: Alice, Age: 30
Common format verbs:
%s- string%d- decimal integer%f- floating point%t- boolean%v- default format for any value%+v- struct with field names%#v- Go syntax representation%T- type of value
Example:
type Point struct {
X, Y int
}
p := Point{10, 20}
fmt.Printf("%v\n", p) // {10 20}
fmt.Printf("%+v\n", p) // {X:10 Y:20}
fmt.Printf("%#v\n", p) // main.Point{X:10, Y:20}
fmt.Printf("%T\n", p) // main.Point
Comparing Strings
Strings compare lexicographically using ==, <, >:
fmt.Println("apple" < "banana") // true
fmt.Println("abc" == "abc") // true
Comparison works byte-by-byte. For case-insensitive comparison:
strings.EqualFold("Hello", "hello") // true
String Internals
Strings are implemented as a struct with two fields: a pointer to the byte data and a length. This is similar to the slice header but without capacity.
type string struct {
ptr *byte
len int
}
This makes strings cheap to pass—you're copying 16 bytes on 64-bit systems, not the entire string data. The actual bytes are shared between copies:
s1 := "hello world"
s2 := s1
// Both s1 and s2 point to the same underlying bytes
Since strings are immutable, this sharing is safe. You can't modify the bytes through either string.
Slicing a string creates a new string header pointing into the same byte array:
s := "hello world"
sub := s[0:5] // "hello"
The substring sub shares bytes with s. This is efficient but means the substring keeps the entire original string in memory. For large strings where you only need a small part, copy to a new string:
sub := string([]byte(s[0:5]))
This allocates new bytes for just the substring.
What's Next
Maps and strings are fundamental types you'll use constantly. Maps provide fast key-value lookups with hash table performance. Strings handle text efficiently with UTF-8 encoding and immutability guarantees.
The next step is organizing these types into structs—Go's mechanism for creating custom data types. Structs group related values together, and combined with methods, they provide Go's approach to structuring programs without classes or inheritance.
Understanding maps, strings, and the upcoming structs gives you Go's data modeling tools. Then we'll explore functions more deeply—variadic parameters, closures, and higher-order functions that enable abstraction and code reuse.
Should we move on to structs and composition?
