Go Performance Guide
I/O & Data Handling

Immutable Data Patterns

Leverage immutability for lock-free concurrency, zero-copy sharing, and better performance

Immutable Data Patterns in Go

Immutability is a powerful tool for building fast, concurrent systems. When data cannot change, synchronization becomes unnecessary, and sharing becomes safe. This article explores immutability patterns in Go and their performance implications.

Why Immutability Helps Performance

Immutable data has several advantages:

  1. No synchronization needed - Multiple goroutines can safely read without locks
  2. No copy-on-write - Data can be safely shared without defensive copies
  3. Better CPU caching - No cache invalidation from mutations
  4. Simplified reasoning - Easier to understand data flow

Consider this mutable pattern:

package main

import (
	"sync"
)

type Config struct {
	mu    sync.RWMutex
	value int
}

func (c *Config) Get() int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.value
}

func (c *Config) Set(v int) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value = v
}

func main() {
	cfg := &Config{}

	// Every read requires lock acquisition (expensive!)
	for i := 0; i < 1000000; i++ {
		_ = cfg.Get()
	}
}

With immutability:

package main

import (
	"sync/atomic"
	"unsafe"
)

type Config struct {
	value int
}

var cfg atomic.Pointer[Config]

func getConfig() *Config {
	return cfg.Load()
}

func setConfig(c *Config) {
	cfg.Store(c)
}

func main() {
	cfg := &Config{value: 0}

	// No locks needed for reads!
	for i := 0; i < 1000000; i++ {
		_ = getConfig()
	}

	// Update is atomic, but doesn't affect readers
	setConfig(&Config{value: 1})
}

The second pattern is orders of magnitude faster for read-heavy workloads.

Copy-on-Write Patterns

Copy-on-write (COW) makes mutable data more efficient by deferring copies:

package main

type CowValue struct {
	data []int
	ref  int  // reference count (simplified)
}

// Create a copy - initially shares data
func (cv CowValue) Copy() CowValue {
	// Doesn't copy data yet, just shares reference
	return CowValue{
		data: cv.data,
		ref:  cv.ref + 1,
	}
}

// Modify - copies only when necessary
func (cv *CowValue) Modify(idx int, val int) {
	// If we're the only reference, modify in place
	// If we have other references, copy first
	if cv.ref > 1 {
		// Copy before modifying
		newData := make([]int, len(cv.data))
		copy(newData, cv.data)
		cv.data = newData
		cv.ref = 1
	}

	cv.data[idx] = val
}

Go uses COW internally for strings when converted to byte slices:

package main

func main() {
	str1 := "hello world"

	// Sharing without copying
	str2 := str1  // No copy!

	// Even with slicing, original memory is shared
	bytes := []byte(str1)  // This DOES copy (safety)
}

Frozen Structs with Unexported Fields

The most Go-idiomatic immutable pattern uses unexported fields:

package main

type Point struct {
	x, y float64  // unexported - cannot be modified by others
}

func NewPoint(x, y float64) Point {
	return Point{x, y}
}

// Getter methods
func (p Point) X() float64 { return p.x }
func (p Point) Y() float64 { return p.y }

func main() {
	p1 := NewPoint(1, 2)

	// Cannot modify - type system enforces immutability
	// p1.x = 5  // Compile error!

	// Reading is always safe and fast
	x := p1.X()
	y := p1.Y()
}

For larger structures:

package main

import (
	"time"
)

type UserProfile struct {
	id        string
	name      string
	email     string
	createdAt time.Time
	tags      []string
}

// Constructor ensures valid state
func NewUserProfile(id, name, email string, tags []string) *UserProfile {
	// Defensive copy of tags
	tagsCopy := make([]string, len(tags))
	copy(tagsCopy, tags)

	return &UserProfile{
		id:        id,
		name:      name,
		email:     email,
		createdAt: time.Now(),
		tags:      tagsCopy,
	}
}

// Getters only - no setters
func (up *UserProfile) ID() string       { return up.id }
func (up *UserProfile) Name() string     { return up.name }
func (up *UserProfile) Email() string    { return up.email }
func (up *UserProfile) CreatedAt() time.Time { return up.createdAt }
func (up *UserProfile) Tags() []string {
	// Return copy to prevent external modification
	result := make([]string, len(up.tags))
	copy(result, up.tags)
	return result
}

// For updates, return new instance
func (up *UserProfile) WithName(name string) *UserProfile {
	return &UserProfile{
		id:        up.id,
		name:      name,
		email:     up.email,
		createdAt: up.createdAt,
		tags:      up.tags,  // Safe to share - immutable
	}
}

func main() {
	user := NewUserProfile("123", "Alice", "[email protected]", []string{"admin"})

	// Safe to share across goroutines
	updatedUser := user.WithName("Alicia")

	// Original unchanged
	_ = user.Name()
	_ = updatedUser.Name()
}

Immutable Slices via Copy

Slices are mutable by design, but copying creates immutable data:

package main

// ImmutableSlice wraps a slice for safe sharing
type ImmutableSlice struct {
	data []int
}

// Constructor copies the slice
func NewImmutableSlice(src []int) ImmutableSlice {
	data := make([]int, len(src))
	copy(data, src)
	return ImmutableSlice{data}
}

// Getter returns a copy
func (is ImmutableSlice) Get(idx int) int {
	return is.data[idx]
}

func (is ImmutableSlice) Len() int {
	return len(is.data)
}

// All getters return copies or read-only views
func (is ImmutableSlice) GetRange(start, end int) []int {
	result := make([]int, end-start)
	copy(result, is.data[start:end])
	return result
}

func main() {
	original := []int{1, 2, 3, 4, 5}

	// Create immutable copy
	immutable := NewImmutableSlice(original)

	// Safe to share across goroutines
	// original changes won't affect immutable
	original[0] = 999

	// immutable still has original data
	_ = immutable.Get(0)  // Returns 1
}

For better ergonomics, use custom types:

package main

type StringList struct {
	items []string
}

func NewStringList(items ...string) StringList {
	result := make([]string, len(items))
	copy(result, items)
	return StringList{result}
}

func (sl StringList) Get(i int) string {
	return sl.items[i]
}

func (sl StringList) Len() int {
	return len(sl.items)
}

// Return read-only view (still can't modify from outside)
func (sl StringList) AsSlice() []string {
	return sl.items  // Safe - underlying data is immutable
}

func (sl StringList) WithAppended(s string) StringList {
	newItems := make([]string, len(sl.items)+1)
	copy(newItems, sl.items)
	newItems[len(sl.items)] = s
	return StringList{newItems}
}

Strings (Immutable) vs []byte (Mutable)

Go strings are immutable by design - this helps performance:

package main

import (
	"strings"
)

func main() {
	// Strings are immutable - safe to share
	str := "hello world"
	str1 := str
	str2 := str

	// Both reference the same underlying data
	// No copying needed for assignment or passing

	// Mutable []byte requires copying
	bytes := []byte("hello world")
	bytes1 := make([]byte, len(bytes))
	copy(bytes1, bytes)  // Must copy for safety
}

Use strings when you don't need to modify:

// Good: string is immutable
func processConfig(config string) {
	// Safe to share, no copies needed
	subConfig := config[0:10]
	// config[0] = 'x'  // Compile error - string is immutable
}

// Bad: []byte is mutable
func processConfigUnsafe(config []byte) {
	// Must copy if you don't want external modifications
	subConfig := make([]byte, 10)
	copy(subConfig, config[:10])
	// External code could still modify config
}

For performance in string-heavy code, avoid conversions:

// Good: stay in string domain
func countPattern(data string, pattern string) int {
	return strings.Count(data, pattern)
}

// Bad: unnecessary conversions
func countPatternBad(data []byte, pattern []byte) int {
	dataStr := string(data)        // Copy
	patternStr := string(pattern)  // Copy
	return strings.Count(dataStr, patternStr)
}

atomic.Value for Concurrent Read-Heavy Configs

atomic.Value provides lock-free concurrent access to immutable data:

package main

import (
	"sync/atomic"
	"time"
)

type AppConfig struct {
	DatabaseURL string
	CacheSize   int
	Timeout     time.Duration
}

var globalConfig atomic.Value

func init() {
	globalConfig.Store(&AppConfig{
		DatabaseURL: "postgres://localhost",
		CacheSize:   1000,
		Timeout:     30 * time.Second,
	})
}

// Read config - no locks, extremely fast
func GetConfig() *AppConfig {
	return globalConfig.Load().(*AppConfig)
}

// Update config - creates new instance
func UpdateConfig(dbURL string, cacheSize int, timeout time.Duration) {
	newConfig := &AppConfig{
		DatabaseURL: dbURL,
		CacheSize:   cacheSize,
		Timeout:     timeout,
	}
	globalConfig.Store(newConfig)
}

func main() {
	// Extremely fast reads from many goroutines
	go func() {
		for i := 0; i < 1000000; i++ {
			config := GetConfig()
			_ = config.DatabaseURL
		}
	}()

	// Occasional updates
	go func() {
		time.Sleep(100 * time.Millisecond)
		UpdateConfig("postgres://newhost", 2000, 60*time.Second)
	}()

	time.Sleep(200 * time.Millisecond)
}

This pattern is ideal for:

  • Configuration that changes rarely
  • Read-heavy workloads
  • Low-latency requirements

Functional Options Pattern for Immutable Config

The functional options pattern creates immutable configs cleanly:

package main

import (
	"time"
)

type HTTPServer struct {
	host         string
	port         int
	readTimeout  time.Duration
	writeTimeout time.Duration
	maxBodySize  int64
}

// Option function type
type ServerOption func(*HTTPServer)

// Constructor with sensible defaults
func NewHTTPServer(host string, port int, opts ...ServerOption) *HTTPServer {
	server := &HTTPServer{
		host:         host,
		port:         port,
		readTimeout:  30 * time.Second,
		writeTimeout: 30 * time.Second,
		maxBodySize:  1 << 20,  // 1MB
	}

	// Apply options
	for _, opt := range opts {
		opt(server)
	}

	return server
}

// Option functions - each is immutable from outside
func WithReadTimeout(t time.Duration) ServerOption {
	return func(s *HTTPServer) {
		s.readTimeout = t
	}
}

func WithWriteTimeout(t time.Duration) ServerOption {
	return func(s *HTTPServer) {
		s.writeTimeout = t
	}
}

func WithMaxBodySize(size int64) ServerOption {
	return func(s *HTTPServer) {
		s.maxBodySize = size
	}
}

// Once created, server is immutable
func (s *HTTPServer) Host() string           { return s.host }
func (s *HTTPServer) Port() int              { return s.port }
func (s *HTTPServer) ReadTimeout() time.Duration  { return s.readTimeout }
func (s *HTTPServer) WriteTimeout() time.Duration { return s.writeTimeout }
func (s *HTTPServer) MaxBodySize() int64     { return s.maxBodySize }

func main() {
	// Create with custom options
	server := NewHTTPServer(
		"localhost",
		8080,
		WithReadTimeout(60*time.Second),
		WithMaxBodySize(10<<20),  // 10MB
	)

	// Server is immutable, safe to share
	_ = server
}

Concurrent Read Performance: Immutable vs Mutable+Mutex

Comprehensive benchmark showing immutability benefits:

package main

import (
	"sync"
	"sync/atomic"
	"testing"
)

type MutableConfig struct {
	mu      sync.RWMutex
	value   int
	name    string
	timeout int
}

func (mc *MutableConfig) Get() (int, string, int) {
	mc.mu.RLock()
	defer mc.mu.RUnlock()
	return mc.value, mc.name, mc.timeout
}

type ImmutableConfig struct {
	value   int
	name    string
	timeout int
}

var immutableAtomic atomic.Pointer[ImmutableConfig]

func init() {
	immutableAtomic.Store(&ImmutableConfig{
		value:   42,
		name:    "config",
		timeout: 30,
	})
}

func getImmutableConfig() *ImmutableConfig {
	return immutableAtomic.Load()
}

func BenchmarkMutableConfigRead(b *testing.B) {
	cfg := &MutableConfig{value: 42, name: "config", timeout: 30}

	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			cfg.Get()
		}
	})
}

func BenchmarkImmutableConfigRead(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			getImmutableConfig()
		}
	})
}

func BenchmarkMutableConfigReadWrite(b *testing.B) {
	cfg := &MutableConfig{value: 42, name: "config", timeout: 30}

	b.RunParallel(func(pb *testing.PB) {
		readCount := 0
		for pb.Next() {
			_, _, _ = cfg.Get()
			readCount++

			if readCount%100 == 0 {
				cfg.mu.Lock()
				cfg.value++
				cfg.mu.Unlock()
			}
		}
	})
}

func BenchmarkImmutableConfigReadWrite(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		readCount := 0
		for pb.Next() {
			_ = getImmutableConfig()
			readCount++

			if readCount%100 == 0 {
				newCfg := getImmutableConfig()
				immutableAtomic.Store(&ImmutableConfig{
					value:   newCfg.value + 1,
					name:    newCfg.name,
					timeout: newCfg.timeout,
				})
			}
		}
	})
}

Expected results on multi-core systems:

  • Mutable RWMutex: Baseline, contention at high concurrency
  • Immutable atomic: 10-100x faster for reads, lock-free
  • Mutable with writes: Significant contention
  • Immutable with writes: No read contention, writes are cheap

Best Practices for Immutable Data

  1. Use unexported fields with getters

    type User struct {
        id   int
        name string
    }
    
    func (u *User) ID() int    { return u.id }
    func (u *User) Name() string { return u.name }
  2. Return copies of mutable fields

    func (u *User) Tags() []string {
        copy := make([]string, len(u.tags))
        copy(copy, u.tags)
        return copy
    }
  3. Use atomic.Value for config

    var config atomic.Value  // Stores immutable config
  4. Prefer strings over []byte for read-only data

    func processData(data string) {}  // Not func processData(data []byte)
  5. Use functional options for construction

    NewServer(opts ...ServerOption)

Key Takeaways

  • Immutability eliminates synchronization - No locks needed for concurrent reads
  • No defensive copying - Data can be safely shared without copies
  • Better cache behavior - No cache invalidation from mutations
  • atomic.Value enables lock-free config - Extremely fast for read-heavy workloads
  • Functional options pattern - Clean, idiomatic way to build immutable objects
  • Measure concurrency - Immutability shines under contention; profile first
  • Use unexported fields - Go's type system enforces immutability from outside

Immutability is a powerful tool for building fast, scalable concurrent systems in Go.

On this page