- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've written this Go bug. Almost everybody has.
cfg := map[string]int{"retries": 0}
n := cfg["retries"]
if n == 0 {
n = 3 // "not set, use default"
}
The intent is "if the key is missing, default to 3." What the
code actually says is "if the value is zero, default to 3." Those
are different questions, and here they collide. retries is set
to 0 on purpose, but the zero value of an int is also 0, so
you can't tell "absent" from "present and zero" by looking at the
value alone.
Go has one answer to this, and it's the same answer in four
different corners of the language. It's called the comma-ok idiom:
the second return value that tells you whether the first one means
anything. Learn it in one place and you've learned it in all four.
The shape
Every version has the same silhouette. An expression that could
either succeed or come up empty returns a second boolean:
value, ok := something()
if !ok {
// the value is a zero value you should not trust
}
The ok is the whole point. Without it, Go hands you a zero value
on failure and stays silent about whether that zero is real data or
a placeholder. The two-value form makes the language tell you which
one you got. Four constructs use it. Here they are.
Place 1: map lookup
Back to the opening bug. The single-value read gives you the value
or the zero value, with no way to tell them apart:
n := cfg["retries"] // 0 if absent OR if set to 0
The comma-ok read adds the boolean that resolves the ambiguity:
n, ok := cfg["retries"]
if !ok {
n = 3 // genuinely absent, apply default
}
// if ok, n is whatever was stored, including 0
Now "present and zero" and "absent" are two different states, and
your code reads the one you meant. This matters most for maps whose
value type has a meaningful zero: int, bool, string, or a
pointer that could legitimately be nil.
The trap is worst with map[string]bool used as a set-with-flags.
if flags["enabled"] returns false both when the flag is off and
when it was never set. If those two cases should behave differently,
you need the ok:
enabled, ok := flags["enabled"]
switch {
case !ok:
// never configured
case enabled:
// explicitly on
default:
// explicitly off
}
Place 2: type assertion
A type assertion pulls a concrete type back out of an interface.
The single-value form is a landmine:
var x any = "hello"
n := x.(int) // panic: interface conversion
That panics at runtime because x holds a string, not an int.
In a request handler decoding untrusted input, that's a crash you
handed to whoever sends the wrong shape.
The comma-ok form never panics. It reports success through the
boolean instead:
n, ok := x.(int)
if !ok {
// x wasn't an int; n is 0, don't use it
return fmt.Errorf("expected int, got %T", x)
}
Same pattern, same ok, same meaning: the first value is only
trustworthy when the second is true. This is the form you want
in almost every type assertion that touches data you didn't
construct yourself. Reserve the panicking single-value form for the
cases where the wrong type is genuinely a programmer bug you want
to fail loudly on.
It works with interface targets too, which is how you feature-detect
an optional capability. Given an http.ResponseWriter w, you can
ask whether it also supports flushing:
// w is an http.ResponseWriter
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
If w implements http.Flusher, you flush. If it doesn't, you skip
it without a crash. No reflection, no panic recovery.
Place 3: channel receive
Receiving from a channel returns the value. The single-value form
hides one fact you often need: whether the channel is still open.
v := <-ch // v is a zero value if ch is closed
A closed channel doesn't block and doesn't panic on receive. It
returns the element type's zero value, immediately, forever. Range
over a producer that closed early and you'll process a flood of
zero-value "messages" that were never sent.
The comma-ok receive tells you the difference between a real value
and a closed channel:
v, ok := <-ch
if !ok {
// channel closed and drained; stop
return
}
process(v)
ok is true for a value that was actually sent, false once the
channel is closed and empty. This is the correct way to drain a
channel to completion in a for loop without a separate done
signal:
for {
v, ok := <-ch
if !ok {
return
}
process(v)
}
Same idiom. The boolean tells you whether the first value is a real
send or the aftermath of a close.
Place 4: range over a channel
for range over a channel is the loop above with the boilerplate
removed. You don't write the comma-ok yourself, but the language
runs it for you: the loop reads with the two-value receive under the
hood and stops the moment ok is false.
for v := range ch {
process(v)
}
// loop exits when ch is closed and drained
That's why ranging over a channel needs the producer to close(ch).
The close is what flips the hidden ok to false and ends the
loop. Forget to close, and the range blocks forever waiting for a
value or a close that never comes. Classic goroutine leak.
func produce(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // this is what ends the range
for i := 0; i < n; i++ {
out <- i
}
}()
return out
}
The consumer's for v := range produce(3) runs three times and
exits cleanly, because close(out) sets the internal ok to
false after the third value. The comma-ok you learned in Place 3
is doing the work here, just where you can't see it.
Two things for range does not give you: the ok value itself
(you can't branch on close inside the loop body) and the ability to
tell an intentionally-sent zero from a close. If you need either,
drop back to the explicit v, ok := <-ch form from Place 3.
Why one idiom covers a class of bugs
Look at what these four have in common. A map might not hold your
key. An interface might not hold your type. A channel might be
closed. Each is an operation that can either give you real data or
come up empty, and in every case Go's failure value is a plain zero
value that looks exactly like legitimate data.
The single-value form throws that ambiguity away. You get a 0, a
"", a nil, or a false, and no way to know whether it's your
value or the absence of one. Some of those absences panic (type
assertion). Some silently feed you garbage (map, channel). All of
them come from the same missing question: did this actually
succeed?
The comma-ok idiom is Go's answer, and it's deliberately the same
answer everywhere. The ok is the language handing you back the
one bit the zero value can't carry. Once you read value, ok :=
as "the value, plus whether to trust it," you spot the missing
ok on sight, in all four places, no matter which one you're
looking at.
The grep for Monday
Three searches on the code you already have.
- Map reads assigned with
:=a single value, where the value type's zero (0,"",false,nil) is a real possible value. Ask whether "absent" and "zero" should differ. - Single-value type assertions
x.(T)on data you didn't construct. Each is a potential runtime panic; the comma-ok form turns it into a handled error. -
<-chreads andfor range chloops. Confirm every channel a consumer ranges over gets closed by exactly one producer. A range with no matchingcloseis a leak.
Each is a fast search. Each can surface a bug that's already
shipping.
If this was useful
The comma-ok idiom is a small window onto a bigger idea in Go: the
language keeps giving you one consistent shape for "this might not
be here," and the reward for learning it once is reading it
everywhere. The Complete Guide to Go Programming works through the
map, interface, and channel internals underneath all four forms, so
the idiom stops being a rule you memorize and becomes something you
predict. Hexagonal Architecture in Go is where you decide which
layer owns the "is it present" question, so the check lives at the
boundary instead of leaking through every function inside it.

Top comments (0)