1. Why “once” is needed in concurrent code
In almost every application there is a “preparation” step: create a cache, prepare a map, load config, build an index, open a connection, warm up some data. In single-threaded code we simply do it at the beginning of main() and don’t think about it. But as soon as goroutines appear, it suddenly turns out that “preparation” can be triggered from different places at the same time: one request handler, a second, a third — and all of them decided they were first.
The main danger here isn’t even that initialization will run twice (although that can also be unpleasant), but that someone will start using a partially initialized state. It’s like one person has already put the pot on the stove and says: “the soup is almost ready,” and the second person is already pouring it into bowls because “well, the pot is there.” In the best case it’s weird; in the worst case you get a panic or incorrect data.
Naive lazy initialization and races
A very natural beginner thought sounds like this: “come on, I’ll just check, and if it’s not initialized — I’ll initialize it.” In a single goroutine that’s fine. With multiple goroutines, it’s an almost guaranteed source of races.
Here is a typical “lazy initialization” of a global map:
package main
import "fmt"
var cache map[string]int
func getCache() map[string]int {
if cache == nil {
cache = make(map[string]int)
}
return cache
}
func main() {
m := getCache()
m["x"] = 1
fmt.Println(m["x"]) // 1
}
As long as you call getCache() from a single place, everything is fine. But if two goroutines simultaneously see cache == nil, they will both try to write to cache. And even if you “get lucky” and no panic happens, you’ll have a data race: the program becomes non-deterministic, meaning it can behave differently with the same input.
This is that classic moment when “sometimes it works” is a bad sign, not a reason to celebrate.
2. sync.Once: the contract and important nuances
When you need to “run exactly once” in concurrent code, in Go you almost always think of sync.Once. It’s a small object that ensures: the initialization function will be executed once, and all other calls will wait (if needed) and then continue.
The core Do contract
Once.Do(f) calls f only on the first call for a given Once object; subsequent calls to Do will not run f again, even if you pass a “different” function. This is stated directly in the sync package documentation.
Once must not be copied after use begins
It’s also important that Once must not be copied after use begins. The documentation states it as: “must not be copied after first use”.
Recursive Do and deadlock
One more nuance: if f calls Do again (for example, during initialization you accidentally call a method that again tries to “ensure-init”), you will get a deadlock: Do waits for f to finish, while f waits for Do to “complete.” The documentation explicitly warns about this.
Panic inside Do
If f panics, Do considers f “finished,” and future calls will not attempt to run the initialization again. That is: a panic during init is not “we’ll try again later,” it’s “that attempt is final.”
Practical takeaway: initialization inside Once.Do should be as “boring” and predictable as possible. If you have a potential panic there (for example, you’re doing something that can crash), it’s better to rework the code so it returns an error instead of panicking.
Memory visibility: why you can trust data after Do
In concurrent programming there is a special “magical” category of problems: even if everything seems to have executed, another thread/goroutine may not observe the changes due to reordering and CPU caches. This is not a myth or a “scary story for students,” it’s real.
That’s why the sync.Once documentation separately mentions a relation from the Go memory model: the return from f “synchronizes before” the return from any once.Do(f). In plain English, this means: if one goroutine performed initialization inside Do, then other goroutines that returned from Do will see the results of that initialization in a correct state (not “partial,” and not “depending on luck”).
You can sketch it as a mini diagram:
sequenceDiagram
participant G1 as goroutine A
participant G2 as goroutine B
G1->>G1: once.Do(init)
Note over G1: init() executes
G2->>G2: once.Do(init)
Note over G2: waits for init() to finish
Note over G1,G2: after Do returns, both goroutines see the initialized state
Why Once does not replace Mutex
At this point it’s tempting to ask: “Can I just put Once everywhere and forget about locks?” Unfortunately (or fortunately) — no.
Once solves the “one-time execution” task. It’s perfect for make(map[...]), lazy configuration loading, compiling a regexp, warming up a cache. But as soon as the data continues to change after initialization, you need regular synchronization again: Mutex, RWMutex, channels — depending on the design.
To make it easier to navigate, here is a compact “which tool is for what” table:
| Tool | What it is meant for | Typical example |
|---|---|---|
|
run a piece of code once | lazily create a map, load config |
|
protect an invariant during read/write | map, slices, multiple related fields |
|
a simple independent variable | counter, “ready” flag |
The behavior of sync.Once.Do as “run exactly once,” and the details about deadlock/panic, are not guesses — they are explicitly described in the documentation contract.
4. Examples of using sync.Once
Minimal example: create a map exactly once
Let’s rewrite the example with cache so that it is safe under concurrent calls.
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
cache map[string]int
)
func getCache() map[string]int {
once.Do(func() {
cache = make(map[string]int)
})
return cache
}
func main() {
m := getCache()
m["x"] = 1
fmt.Println(m["x"]) // 1
}
The key here is once.Do(...): initialization of cache = make(...) will happen once. It’s important to internalize the idea correctly: sync.Once is not a “flag check,” but a ready-made safe pattern for the concurrent scenario.
If you want to see this idea in a “production-like” fragment, in an example from Go tracing materials you can find a pattern where once.Do is used to take a snapshot exactly once, even if multiple goroutines try to do it at the same time.
sync.Once inside a struct: lazy initialization as part of the type
When you write a real application, global variables quickly become inconvenient: testing is awkward, extending is awkward, and it simply “smells.” It’s much nicer to keep Once next to the state it initializes: “data + synchronization primitive side by side.”
Imagine that in a learning app (a conditional task manager) we have an in-memory task store. We want it to be “lazy”: for example, we want to be able to create TaskStore as a zero value, and perform the first real initialization on first use.
A model skeleton (very simple):
package main
type Task struct {
ID int
Title string
}
Now the store:
package main
import "sync"
type TaskStore struct {
once sync.Once
mu sync.Mutex
nextID int
tasks map[int]Task
}
func (s *TaskStore) ensureInit() {
s.once.Do(func() {
s.nextID = 1
s.tasks = make(map[int]Task)
})
}
Notice the combination: once is responsible only for “initialize fields once,” while mu is responsible for safe work with the map and the counter afterward. sync.Once does not “make the whole object thread-safe”; it solves only the narrow task of “initialize once.”
Let’s add a task creation method:
package main
func (s *TaskStore) Create(title string) Task {
s.ensureInit()
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title}
s.tasks[t.ID] = t
s.nextID++
return t
}
And a read method:
package main
func (s *TaskStore) Get(id int) (Task, bool) {
s.ensureInit()
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
return t, ok
}
Yes, using Lock() even for reads is fine for a learning example. We discussed optimizations via RWMutex in a neighboring topic, but the meaning of Once does not change because of that.
Where to store the result if Do returns nothing
After the first encounter with sync.Once, a question almost always comes up: “OK, Do runs the function, but how do I return a value from it? Why does Do return nothing?”
The answer is simple: Once is a synchronization primitive. It’s not about “return a value,” it’s about “guarantee it ran once.” Therefore, you store the result in an external variable (usually a struct field), and return it after Do.
Let’s make an example: we want to compute “maximum number of tasks” once from an environment variable (or some configuration). Suppose the variable is named APP_MAX_TASKS. Parsing can fail, and we need to store that error as well.
package main
import (
"os"
"strconv"
"sync"
)
type Limits struct {
once sync.Once
n int
err error
}
func (l *Limits) MaxTasks() (int, error) {
l.once.Do(func() {
raw := os.Getenv("APP_MAX_TASKS")
if raw == "" {
l.n = 100
return
}
l.n, l.err = strconv.Atoi(raw)
})
return l.n, l.err
}
Two things matter here.
First: we store both n and err in struct fields, because Do returns nothing.
Second: if parsing fails once, that becomes a “fixed fact”: subsequent calls to MaxTasks() will return the same error, because Do will not call the function again. This is part of Once behavior: it does not “retry.”
5. Common mistakes when working with sync.Once
Error #1: expecting Once to “retry” if an error happened.
This expectation appears when initialization does something “unreliable”: reads a file, calls the network, parses config. But Once does not retry — it just makes one call to f, and then never touches it again. If you need a retry, that’s a different design (for example, a separate Reload() method under a Mutex). The Do contract as “first time — run, others — no” is explicitly described.
Error #2: calling once.Do inside the function you passed to once.Do.
Sometimes this happens indirectly: inside init() you call another method, and that method also does ensureInit() and again goes into once.Do. The result is a deadlock, because Do does not release waiters until f completes. This is not a rare “academic” issue: in real codebases, this is how hangs get caught. The documentation warns about this directly.
Error #3: thinking Once makes the entire struct thread-safe.
Once solves only “initialize exactly once.” But if after that you mutate a map or a slice, you’re back in the risk zone: you cannot write to a map concurrently without synchronization. That’s why the normal combination is Once for init and Mutex/RWMutex for the rest of the data’s life.
Error #4: not storing the initialization result/error and trying to “return from Do.”
Do returns nothing, and that is intentional. So the correct pattern is to store the result in a struct field (or an external variable) and return it after Do. If the result is a (value, error), store both. This isn’t just style — it’s the only way to make the code readable and avoid inventing weird workarounds.
Error #5: copying a struct in which sync.Once has already been used.
Copying structs with synchronization primitives almost always ends in pain: you get two copies with “different locks” and different internal state, and then behavior becomes unpredictable. For Once this is stated directly: “must not be copied after first use”.
GO TO FULL VERSION