A sync.Once initializer panicked on its first call; why does every later caller get an unusable value?
answer
- the done flag is set either way
- the panic does not buy a second attempt
- check whether the init log line ever repeats
- a nil value, not a retry
- a Once cannot be re-armed
basics
~20 sOnce.Do treats a panicking function as having returned, so the sync.Once is marked done and no later call re-runs it. Every caller after the panic gets whatever the aborted initializer left behind, usually a nil pointer.
solid answer
~50 s`sync.Once` records that its function ran even when that function panicked: the documented behaviour is that `Do` considers a panicking `f` to have returned, so future calls return without calling `f` again. The first caller sees the panic — and if it happened inside an HTTP handler, `net/http` recovers it and the process keeps serving. Every caller after that takes `Do`'s fast path and reads a package variable the initializer never finished assigning: a nil client, then nil dereferences far from the cause. The signature in the logs is unmistakable — one panic stack from the initializer, no further init line ever, then an unending stream of nil-pointer panics. The fix is not to catch the panic: return an error instead (or use `sync.OnceValues`), and if the failure can be transient do not use a `Once` at all, because it cannot be retried.
code
go · 15 linesvar (
once sync.Once
client *apiClient
)
func get() *apiClient {
once.Do(func() {
addr := os.Getenv("API_ADDR")
if addr == "" {
panic("API_ADDR is empty") // the Once is still marked done
}
client = dial(addr)
})
return client // nil for every caller after the panic
}go deeper
Remember the one rule behind this: a function that panics under once.Do still counts as having run, so it will never be tried again. Expect a nil value from then on.
Explain the mechanics — the done state is set as Do unwinds, later calls take the fast path, and the package variable keeps whatever the aborted function had assigned. Contrast that with the Go 1.21 helpers, which re-panic instead.
Walk the diagnosis: the init log line that never repeats, one panic stack from the initializer, then nil dereferences elsewhere; and give the real fix, which is returning an error rather than panicking, plus validating configuration where it can stop the deploy.
Own the policy question. Decide what a service is allowed to initialise lazily at all, and where configuration must be validated so a misconfigured instance never reports itself ready rather than failing on the first user's request.
## The rule From the `sync` package's own contract: if the function passed to `Do` panics, `Do` considers it to have returned. The `Once` is done. Future calls to `Do` return immediately, without calling the function. That single sentence explains the whole incident. The done flag is set on the way out of `f` regardless of *how* control left it, so a panic does not roll back the fact that the one and only attempt has been spent. ## How it plays out in a service ```go var ( once sync.Once client *apiClient ) func get() *apiClient { once.Do(func() { addr := os.Getenv("API_ADDR") if addr == "" { panic("API_ADDR is empty") } log.Printf("api client initialised: %s", addr) client = dial(addr) }) return client } ``` Deploy this with the variable missing and the sequence is: 1. The first request calls `get()`. `Do` takes the lock, runs the closure, and it panics. `client` is still nil. 2. `Do` unwinds, marking the `Once` done as it goes, and the panic continues up the stack. If the caller is an HTTP handler, `net/http` recovers panics in handlers, logs the stack and drops that one connection — so **the process survives**, which is precisely why this is confusing. 3. Request two calls `get()`. `Do` sees a completed `Once`, returns instantly, and `get()` returns nil. 4. Every downstream use of that nil client panics with a nil-pointer dereference, in a dozen different places, none of them the initializer. By the time anyone reads the logs, the original panic is thousands of lines back and the visible symptom names files that have nothing to do with configuration. ## The diagnostic The distinguishing signal is the initializer's log line. Under a healthy lazy singleton it appears exactly once, on the first real use, and never again no matter how many callers arrive. Under this failure it also appears at most once — but there is no successful line at all, only the panic that followed it, and then nothing. So: - **One init line, then normal traffic** — working as intended. - **No init line, one panic stack from inside the initializer, then repeated nil-pointer panics elsewhere** — the `Once` is burnt and every caller is getting the zero value. - **An init line on every request** — the guard is not doing its job at all; someone is constructing a fresh `Once` per call, or copying the struct that holds it. A goroutine dump or a heap profile will not show this; the process looks perfectly healthy because it is. The evidence is entirely in the log ordering. ## Why the obvious fixes are wrong **"Restart the process."** It works, and it hides the defect until the next deploy with the same configuration. **"Re-arm the Once after a failure."** There is no supported way. A `Once` has no reset, and assigning a fresh one over a variable other goroutines are calling `Do` on is itself a data race. **"Check for nil at every call site."** That spreads the symptom's handling across the codebase instead of fixing where the failure is reported. ## What to do instead **Do not panic in an initializer that can fail on ordinary bad input.** A missing environment variable is not an impossible state; it is a configuration error, and the initializer should return it: ```go var apiClientOnce = sync.OnceValues(func() (*apiClient, error) { addr := os.Getenv("API_ADDR") if addr == "" { return nil, errors.New("API_ADDR is empty") } return dial(addr), nil }) ``` Now every caller after the failure receives a real error naming the cause, instead of a nil value. Note the contrast in panic handling too: the Go 1.21 helpers (`OnceFunc`, `OnceValue`, `OnceValues`) panic with the same value on every subsequent call if the wrapped function panicked, so even the panic path stays loud, where `Once.Do` goes quiet. **Validate configuration where it can stop the deployment.** Read and check the settings in `main` at start-up, so a missing variable prevents the instance from ever reporting itself ready, rather than being discovered by the first user. **If the failure can be transient, do not memoize it.** A dial that might succeed on the second attempt does not belong behind any once-only primitive, helper or not: a cached error is permanent for the life of the process. Use state you control — a mutex guarding a value, an error and a ready flag that you can clear — or build the dependency eagerly and let a supervisor restart the process. ## The general lesson `sync.Once` guarantees *at most one attempt*, not *one success*. Every design built on it has to answer the question "what if that single attempt fails?" before it ships, because the primitive itself has no answer.
- Why did the process keep serving instead of crashing on that first panic?Because the panic happened inside an HTTP handler goroutine, and `net/http` recovers panics in handlers — it logs the stack and closes that connection, leaving the server up. The process survives with a permanently broken singleton, which is worse than a crash: a crash would have been noticed and restarted with the configuration fixed.
- Can you re-arm a sync.Once after a failed attempt?Not safely. There is no reset method, and overwriting the variable with a fresh `sync.Once` races with any goroutine currently in `Do`. If initialisation can fail and should be retried, replace the `Once` with explicit state — a mutex guarding the value, the error and a ready flag — so you decide when to clear it.
- How would the same failure look if the initializer had been written with sync.OnceValue instead?Loud rather than silent. If the wrapped function panics, the accessor returned by `OnceValue` panics with that same value on every subsequent call, so each caller fails at the point of use with the original message instead of receiving a zero value. Better still, return an error from an `OnceValues` function so no caller has to handle a panic at all.
- What would you change so this fails at deploy time instead?Read and validate the configuration in `main` before the server starts listening, and treat a missing value as fatal there. If you want to keep the lazy accessor, call it once from `main` after configuration is loaded and fail on its error — the `Once` still runs the work exactly once, but you have chosen the moment.
saying these in an interview costs you the question
- Says Do runs the function again because the first attempt panicked
- Thinks a sync.Once can be reset after a failure
- Assumes any panic must have killed the whole process
- Blames the nil dereference site instead of the burnt initializer
- Treats a missing environment variable as a legitimate reason to panic