Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 10

Go Maps: Key-Value Storage & Concurrency

Recommended resources

Hello! Welcome to your next lesson on Go's data structures.

In our last session, we mastered slices, Go's powerful tool for managing ordered, dynamic collections. You learned how to create, append to, and copy them—skills essential for handling lists of things like server hostnames or log entries.

Today, we're shifting from ordered lists to key-value pairs. We'll explore maps, Go's built-in data structure for associative data. As a DevOps engineer, you're already familiar with this concept from Python dictionaries or the structure of YAML and JSON files. Maps are fundamental for tasks like storing configuration, tracking resource metadata, or implementing caches.

This lesson will cover how to create and manage maps for key-value storage. Crucially, we'll also tackle how to use them safely in concurrent environments—a common requirement when building performant automation tools.

The Basics of Go Maps

A map in Go is an unordered collection of key-value pairs. All keys in a given map must be of the same type, and all values must be of the same type. The key type must be "comparable," meaning Go can determine if two keys are equal (e.g., strings, integers, booleans, or structs made of comparable types).

Let's dive into the core mechanics of declaring, initializing, and working with maps.

Go maps in action

The official Go blog post 'Go maps in action' is the best place to start. It provides a clear and concise overview of the fundamental operations.

Read the sections 'Declaration and initialization' and 'Working with maps'. Focus on the syntax for creating a map with make, adding elements, retrieving them with the 'comma, ok' idiom, and using delete and range.

Let's summarize the key points from that reading:

  • Initialization: A map is a reference type. Its zero value is nil. You cannot write to a nil map. You must initialize it first, either using make or a map literal.

    // Using make
    serverStatus := make(map[string]string)
    
    // Using a map literal
    ipToHostname := map[string]string{
        "192.168.1.10": "web-01",
        "192.168.1.20": "db-01",
    }
    
  • Adding & Updating: Access is simple and will either add a new key or update an existing one.

    serverStatus["web-01"] = "online"
    serverStatus["db-01"] = "maintenance"
    serverStatus["web-01"] = "offline" // Updates the existing value
    
  • Retrieving & Checking Existence: When you access a key that doesn't exist, you get the zero value for the value type (e.g., "" for a string, 0 for an int). This can be ambiguous. The idiomatic "comma, ok" expression is the correct way to distinguish between a key that is absent and a key that is present with a zero value.

    // Get the status of web-01
    status, ok := serverStatus["web-01"]
    if ok {
        fmt.Printf("Status of web-01: %s\n", status)
    } else {
        fmt.Println("web-01 not found in tracker.")
    }
    
    // Check for a non-existent server
    _, ok = serverStatus["cache-01"]
    if !ok {
        fmt.Println("cache-01 not found.")
    }
    
  • Deleting: The delete built-in function removes a key-value pair. It does nothing if the key doesn't exist.

    delete(serverStatus, "db-01")
    
  • Iteration: Use a for...range loop. The order of iteration is not guaranteed and can change between runs.

    for server, status := range serverStatus {
        fmt.Printf("Server: %s, Status: %s\n", server, status)
    }
    
Test your understanding!

Imagine you are writing a script to track Docker container image versions. You are given a map defined as imageVersions := make(map[string]string).

  1. Add two images: nginx with version 1.21.6 and redis with version 6.2.6.
  2. You check for the version of the postgres image. What will be the two values returned by the "comma, ok" check?
  3. Update the nginx image to version 1.23.0.
  4. Remove the redis image from the tracker.

After these steps, what would be the final content of the imageVersions map?

Show answer
  1. imageVersions["nginx"] = "1.21.6"
    imageVersions["redis"] = "6.2.6"
  2. Checking for "postgres" would return "", false because the key does not exist. The first value is the zero value for a string.
  3. imageVersions["nginx"] = "1.23.0"
  4. delete(imageVersions, "redis")

The final map would be map[string]string{"nginx": "1.23.0"}.


Concurrent Access to Maps

This is a critical topic in Go. A standard Go map (map[T]V) is not safe for concurrent use. If one goroutine is writing to a map while another is reading from or writing to it, you will get a "fatal error: concurrent map read and map write" panic. This is called a race condition.

As you build more complex tools that perform tasks in parallel (like checking the health of multiple servers simultaneously), you will need a way to safely share data between goroutines.

A trick for safely using Golang Maps more efficiently

To see exactly why concurrent access is a problem, this video provides a short, practical demonstration of a race condition with a map.

Watch the first 2 minutes and 12 seconds of this video. The author shows how running a test with the -race flag immediately detects the issue when multiple goroutines write to the same map.

The Solution: Protecting Maps with a Mutex

The standard way to make map access safe is to use a synchronization primitive. The most common one for this job is sync.RWMutex.

  • A Mutex (Mutual Exclusion lock) ensures that only one goroutine can access a piece of code at a time.
  • An RWMutex (Read-Write Mutex) is a more specialized lock that allows for more sophisticated control:
    • Any number of "readers" can hold the lock simultaneously (RLock()).
    • Only one "writer" can hold the lock exclusively (Lock()). A write lock blocks all other readers and writers.

This is perfect for a map that is read from often but written to less frequently.

The best practice is to create a struct that encapsulates both the map and the RWMutex.

Go maps in action

The official Go blog post on maps explains this pattern, and 'Go by Example' provides a complete, runnable program.

First, read the 'Concurrency' section of 'Go maps in action' to understand the concept of embedding a sync.RWMutex in a struct with a map.

Go by Example: Mutexes

Now, let's see a full code implementation in 'Go by Example: Mutexes'. This example uses a sync.Mutex, but the principle is identical for RWMutex.

Study the entire example. Notice how the Container struct holds both the mu sync.Mutex and the map. Pay close attention to the inc method, where c.mu.Lock() is called at the beginning and defer c.mu.Unlock() ensures the mutex is always released.

Here's a DevOps-centric example combining these ideas using RWMutex:

package main

import (
	"fmt"
	"sync"
	"time"
)

// ConcurrentServerStatus creates a thread-safe map for storing server statuses.
// It embeds sync.RWMutex to control access.
type ConcurrentServerStatus struct {
	sync.RWMutex
	statuses map[string]string
}

// NewConcurrentServerStatus creates and returns a new ConcurrentServerStatus.
func NewConcurrentServerStatus() *ConcurrentServerStatus {
	return &ConcurrentServerStatus{
		statuses: make(map[string]string),
	}
}

// SetStatus safely sets the status for a given server (write operation).
func (css *ConcurrentServerStatus) SetStatus(server, status string) {
	css.Lock() // Acquire a write lock
	defer css.Unlock()
	css.statuses[server] = status
}

// GetStatus safely retrieves the status of a given server (read operation).
func (css *ConcurrentServerStatus) GetStatus(server string) (string, bool) {
	css.RLock() // Acquire a read lock
	defer css.RUnlock()
	status, ok := css.statuses[server]
	return status, ok
}

func main() {
	statusTracker := NewConcurrentServerStatus()
	servers := []string{"web-01", "db-01", "api-01", "web-02"}
	var wg sync.WaitGroup

	// Simulate concurrent updates from health checks
	for _, server := range servers {
		wg.Add(1)
		go func(s string) {
			defer wg.Done()
			// Pretend we did a health check
			statusTracker.SetStatus(s, "online")
			fmt.Printf("Updated status for %s\n", s)
		}(server)
	}

	// Wait for all updates to finish
	wg.Wait()

	// Safely read a status
	time.Sleep(10 * time.Millisecond) // Give goroutines time to print
	fmt.Println("---")
	status, ok := statusTracker.GetStatus("db-01")
	if ok {
		fmt.Printf("Final status of db-01: %s\n", status)
	}
}

Run this code. Then, try running it with the race detector (go run -race main.go). You'll see that it reports no race conditions because our access is properly synchronized.


Specialized Concurrent Maps

While the struct + RWMutex pattern is the most common and flexible, the Go standard library also provides a built-in concurrent map type: sync.Map.

sync.Map is highly optimized for a specific use case: when keys are written once but read many times, or when different goroutines tend to work on disjoint sets of keys. A good example is a cache that is populated once and then read from frequently.

#11 Golang - Implementing Caching with Sync Map in Golang

Let's see how sync.Map is used in practice. This video demonstrates its use for a simple caching scenario.

Watch from 01:43 to 02:52. Focus on the cache.Load() and cache.Store() methods, which are the equivalents of the 'comma, ok' read and the write operation m[key] = value.

Key takeaway for sync.Map:

  • Use sync.Map when you have a read-mostly scenario.
  • Use a struct with sync.RWMutex for general-purpose concurrent maps where you have a mix of reads and writes, as it's often more straightforward and can be more performant for write-heavy workloads.

For very high-contention systems, an even more advanced technique called map sharding can be used. This involves splitting a single large map into several smaller maps (shards), each with its own mutex. This reduces lock contention because goroutines accessing different keys are likely to hit different shards and won't block each other.

A trick for safely using Golang Maps more efficiently

This is an advanced but powerful concept. The same video we saw earlier explains how to implement map sharding. You don't need to memorize this, but it's a great pattern to be aware of for future performance-critical work.

Watch from 04:40 to 09:20. Grasp the high-level idea: a key is hashed to determine which shard (and which lock) it belongs to. This distributes the lock contention across multiple locks.


Conclusion

You now have a solid understanding of Go maps, from basic operations to the critical patterns needed for concurrent access. This is a huge step forward in your ability to write robust and efficient backend tools.

Key Takeaways:

  • Maps are Go's fundamental data structure for key-value storage.
  • Always initialize a map before use with make or a map literal.
  • Use the "comma, ok" idiom (value, ok := m[key]) for safe retrieval of values.
  • Standard Go maps are not safe for concurrent use. Accessing them from multiple goroutines without synchronization will cause your program to crash.
  • The idiomatic way to create a general-purpose concurrent map is to encapsulate a map and a sync.RWMutex within a struct.
  • For specialized read-mostly workloads, consider using the built-in sync.Map.

Next Up

In this lesson, we saw a powerful pattern: combining a map and a mutex inside a custom struct. In the next lesson, we will dive deep into structs. You'll learn how to define your own custom data structures to model complex objects, such as the configuration for an automation tool or the structured data returned by a cloud provider's API.