2 min read

Why Immutable Data Matters in Concurrent Programming

Here is a question that separates junior from senior engineers: "Two goroutines are reading the same variable. One of them writes. What happens?"

The safe answer: "I do not know." And that is the problem.

When multiple threads or goroutines access the same data and one of them writes, the result is undefined behavior. The program might work for a million runs and crash on the million-and-first.

Immutability is the simplest way to eliminate this entire class of bugs.

Key sources: "Java Concurrency in Practice" by Brian Goetz, "Concurrency in Go" by Katherine Cox-Buday, and "Effective Java" by Joshua Bloch.


The Core Insight

Mutable state is the root of all concurrency issues.

```go // Mutable — dangerous in concurrent code type Counter struct { value int }

// Two goroutines calling Increment() — race condition func (c *Counter) Increment() { c.value++ // Read, add, write — not atomic } ```

The fix is either: - Synchronization (mutexes, channels) — hard to get right - Immutability — make the data impossible to change

```go // Immutable — safe in concurrent code type Counter struct { value int }

// No way to change it. Create a new one instead. func (c Counter) Increment() Counter { return Counter{value: c.value + 1} } ```

The immutable version can be shared across 1,000 goroutines without a single lock. Because no one can change it.


Why Immutability Is Thread-Safe by Default

Thread safety problems come from one scenario: multiple threads reading AND writing the same data.

If data is immutable (read-only after creation):

| Problem | Mutable | Immutable | |---------|:-------:|:---------:| | Race condition | Yes | No | | Data corruption | Yes | No | | Need locks | Yes | No | | Stale reads | Possible | No | | Deadlocks | Possible | Impossible |

Immutability does not just reduce concurrency bugs. It eliminates an entire category of them.


The Performance Trade-off

"But is creating new objects slower?" The answer is yes, but that is not the full picture.

Cost of mutability

go mu.Lock() data[0] = newValue // Danger zone mu.Unlock()

Hidden costs: Lock contention, cache invalidation, thread parking, context switching.

Cost of immutability

go newData := append([]int{}, data...) // Copy newData[0] = newValue

Hidden savings: No locks, no contention, safe sharing, no cache line bouncing.

For shared data in highly concurrent systems, immutable copying is often faster than lock-based mutation.


Real-World Examples

Go strings

Go strings are immutable. That is why s[0] = 'a' does not compile. The designers chose immutability because strings are frequently shared across goroutines.

Java String class

Java Strings are immutable. You can pass a string to 100 threads without any synchronization.

Functional programming

Clojure, Haskell, and Elixir use persistent data structures. When you "modify" a collection, you get a new one. The old one still exists unchanged. This makes concurrent programming dramatically simpler.

Event sourcing

Instead of updating a database row, you append an event. The current state is the sum of all past events. This is immutable by design.


How to Apply Immutability in Practice

1. Make fields private and const

go type Point struct { X int // Bad — anyone can change this y int // Good — unexported, controlled access }

2. Do not expose internal references

```go type Team struct { members []string // Returning a slice exposes the backing array }

func (t *Team) Members() []string { // Return a copy, not the original result := make([]string, len(t.members)) copy(result, t.members) return result } ```

3. Use builder patterns for complex objects

go user := UserBuilder(). WithName("Alice"). WithEmail("[email protected]"). Build() // User is now immutable. No setters.

4. Use immutable data structures

  • Go: no native support, use defensive copying
  • Java: List.copyOf(), Collections.unmodifiableList()
  • Clojure: all collections are immutable by default

Key Takeaways

  1. Immutable data is thread-safe by definition. No one can change it.
  2. No locks needed. Eliminates race conditions, deadlocks, and data corruption.
  3. Copying is not always slower. Lock contention is often more expensive.
  4. Use defensive copying. Never return internal references directly.
  5. Start with immutability. You can always optimize to mutation later.

Design principle: "Make objects immutable unless you have a really good reason not to." — Joshua Bloch, Effective Java. This advice is over 20 years old and it remains the best concurrency advice you will get.