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:
- No synchronization needed - Multiple goroutines can safely read without locks
- No copy-on-write - Data can be safely shared without defensive copies
- Better CPU caching - No cache invalidation from mutations
- 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
-
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 } -
Return copies of mutable fields
func (u *User) Tags() []string { copy := make([]string, len(u.tags)) copy(copy, u.tags) return copy } -
Use atomic.Value for config
var config atomic.Value // Stores immutable config -
Prefer strings over []byte for read-only data
func processData(data string) {} // Not func processData(data []byte) -
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.