DEV Community

Cover image for The comma-ok Idiom in Go: One Pattern, Four Places It Shows Up
Gabriel Anhaia
Gabriel Anhaia

Posted on

The comma-ok Idiom in Go: One Pattern, Four Places It Shows Up


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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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.
  2. 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.
  3. <-ch reads and for range ch loops. Confirm every channel a consumer ranges over gets closed by exactly one producer. A range with no matching close is 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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)