The error vanishes into the void
You are building a service that aggregates data from three external APIs. You spin up a goroutine for each call so they run in parallel. Two finish fine. The third hits a timeout and returns an error. Your main goroutine prints "Aggregation complete" and exits. The error never gets logged. The user sees partial data and assumes everything is correct.
This is the classic goroutine trap. Go does not have exceptions that bubble up across goroutines. A panic in a goroutine crashes the entire process. An error returned from a function stays inside that goroutine unless you explicitly move it. Goroutines are isolated workers. If they fail, the failure dies with them unless you build a bridge back to the caller.
Errors are values, not signals
In languages with exceptions, an error interrupts the call stack and jumps to a handler. Go treats errors as ordinary values. You return them, check them, and pass them around. This discipline applies to goroutines too. A goroutine cannot "throw" an error to the main flow. It must send the error value through a communication channel.
Think of goroutines as workers in soundproof rooms. If a worker drops a hammer, the boss outside does not hear it. The worker has to tap on the wall to signal a problem. In Go, the tap is a channel. You create a channel, hand it to the goroutine, and the goroutine sends the error value through that channel. The main goroutine reads from the channel and reacts.
This pattern turns concurrency into explicit data flow. You decide where errors go. You decide when to wait for them. You decide whether one failure stops everything or just logs a warning.
Minimal example: channel as return path
Here is the simplest pattern. A goroutine does work and sends the result back over a channel. The main goroutine receives the value and checks it.
package main
import (
"fmt"
"log"
)
// fetchData simulates a blocking operation that might fail.
func fetchData(id int, resultCh chan<- error) {
// channel direction chan<- error restricts this function to sending only
// this prevents accidental reads inside the worker
if id == 2 {
// simulate a failure for id 2
resultCh <- fmt.Errorf("fetch failed for id %d", id)
return
}
// send nil to indicate success
resultCh <- nil
}
func main() {
// unbuffered channel synchronizes sender and receiver
// main blocks on receive until the goroutine sends
ch := make(chan error)
go fetchData(1, ch)
// receive blocks here until fetchData sends a value
err := <-ch
if err != nil {
log.Fatal(err)
}
fmt.Println("Success")
}
The channel acts as the return path. The chan<- error type annotation tells the compiler this function can only send to the channel. This is a safety feature. If you accidentally try to read from the channel inside the worker, the compiler rejects the code with invalid operation: cannot receive from send-only channel.
The main goroutine blocks on <-ch until the worker sends a value. If the worker never sends, the main goroutine hangs forever. This is a goroutine leak waiting to happen. Always ensure the goroutine sends a value before it exits, or use a timeout.
Realistic example: errgroup for parallel work
When you need to run multiple goroutines and fail fast on the first error, manual channel management gets messy. You have to track how many goroutines are running, collect errors, and handle cancellation. The standard library does not include a built-in group helper, but the community standard is golang.org/x/sync/errgroup.
errgroup wraps a sync.WaitGroup and a shared error channel. It provides a WithContext method that links cancellation across all goroutines. When one goroutine returns an error, the group cancels the context for all other goroutines and returns the first error.
Here is how you use it to fetch multiple URLs in parallel.
package main
import (
"context"
"fmt"
"log"
"net/http"
"golang.org/x/sync/errgroup"
)
// fetchURL performs an HTTP GET and returns an error if it fails.
func fetchURL(ctx context.Context, url string) error {
// context as first parameter follows Go convention
// ctx enables cancellation propagation from the caller
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
// defer closes the body to prevent resource leaks
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", resp.Status)
}
return nil
}
func main() {
urls := []string{
"https://example.com/api/1",
"https://example.com/api/2",
"https://example.com/api/3",
}
// WithContext creates a group that cancels all goroutines on first error
// the returned context is derived from the parent and linked to the group
g, ctx := errgroup.WithContext(context.Background())
for _, url := range urls {
u := url
// closure captures loop variable u to avoid race conditions
// in Go 1.22+ loop variables are per-iteration, but capturing is still safe practice
g.Go(func() error {
return fetchURL(ctx, u)
})
}
// Wait blocks until all goroutines finish or one returns an error
// if an error occurs, the context is cancelled and remaining goroutines stop
err := g.Wait()
if err != nil {
log.Fatalf("aggregation failed: %v", err)
}
fmt.Println("All requests succeeded")
}
The g.Go call spawns a goroutine and tracks it internally. The Wait call blocks until all goroutines complete. If any goroutine returns a non-nil error, Wait returns immediately with that error and cancels the context. Other goroutines should check ctx.Err() and exit early.
The error wrapping with %w preserves the error chain. You can later unwrap the error to inspect the root cause. This follows the Go error handling convention: wrap errors at each layer to add context, but keep the chain intact for debugging.
Panics are not errors
Go distinguishes between errors and panics. Errors are expected failures. A network timeout, a missing file, a validation failure. These are part of normal operation. You return errors and handle them.
Panics are bugs. A nil pointer dereference, an out-of-bounds slice access, a division by zero. These indicate something is fundamentally wrong. A panic crashes the entire program unless you recover from it.
You can recover from a panic inside a goroutine using defer and recover. This converts the panic into an error value so you can send it back through a channel. Use this pattern only when you need to log a stack trace or keep the process alive after a bug. Do not use panic for control flow.
func safeWorker(id int, ch chan<- error) {
// defer runs when the function returns, even after a panic
defer func() {
// recover returns the panic value if one occurred, or nil
if r := recover(); r != nil {
// convert panic to error and send it back
ch <- fmt.Errorf("goroutine %d panicked: %v", id, r)
}
}()
// simulate a panic
if id == 5 {
panic("unexpected state")
}
ch <- nil
}
The recover call must be inside a deferred function. It catches the panic and returns the value passed to panic. You can then wrap it in an error and send it through the channel. The goroutine exits normally instead of crashing the process.
Recovering from panics is rare in application code. Libraries sometimes use it to protect against bad inputs. Top-level HTTP handlers might recover to return a 500 response instead of crashing the server. In most cases, let the panic crash the program. A crash forces you to fix the bug. Swallowing panics hides bugs and makes debugging harder.
Pitfalls and compiler traps
Goroutine error handling introduces specific failure modes. The compiler catches some mistakes, but others only appear at runtime.
If you forget to use a variable, the compiler rejects the program with declared and not used. This applies to error variables too. You cannot ignore an error by simply not assigning it. You must assign it to _ to discard it intentionally. result, _ := doWork() tells the compiler you considered the error and chose to drop it. Use this sparingly with errors. Ignoring errors is a common source of subtle bugs.
If you try to send to a closed channel, the runtime panics with panic: send on closed channel. This happens when you close a channel and then a goroutine tries to write to it. Always close channels from the sender side, never the receiver side. The receiver should read until the channel is closed or the context is cancelled.
If you spawn a goroutine and never read from its channel, the goroutine blocks forever. This is a goroutine leak. The goroutine consumes memory and CPU resources until the program exits. Use context with deadlines to limit how long a goroutine can run. Check ctx.Err() periodically and return early if the context is cancelled.
The compiler does not check for goroutine leaks. You need to reason about the lifecycle of every goroutine. Ask yourself: what happens if the main goroutine exits before the worker finishes? What happens if the channel fills up? What happens if the worker panics?
Convention asides
Go has strong conventions around error handling and concurrency. Following them makes your code readable to other Go developers.
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not try to hide errors behind helper functions that swallow them. Return errors explicitly.
Functions that take a context.Context should always have it as the first parameter, conventionally named ctx. The context carries deadlines, cancellation signals, and request-scoped values. Pass the context through every long-lived call site. If a goroutine does work, it should accept a context and check for cancellation.
Receiver names in methods are usually one or two letters matching the type. (b *Buffer) Write(...) is idiomatic. (this *Buffer) or (self *Buffer) are not. This convention keeps method signatures clean and consistent.
Public names start with a capital letter. Private names start lowercase. There are no public or private keywords. Visibility is controlled by capitalization. Export only what needs to be visible outside the package.
Decision matrix
Choose the right tool based on your concurrency pattern. Each approach has a specific use case.
Use a single channel when one goroutine produces a result for the main flow. This is the simplest pattern. The goroutine sends the value, the main goroutine receives it. Synchronization happens automatically.
Use errgroup when you need to run multiple goroutines and fail fast on the first error. The group manages the wait group, error collection, and context cancellation. This is the idiomatic way to handle parallel work with error propagation.
Use sync.WaitGroup with a channel when you need to collect results from many goroutines without failing on the first error. You track completion with the wait group and aggregate results manually. This pattern works when all goroutines must finish and you want to report all errors at the end.
Use panic and recover only when a goroutine hits an unrecoverable bug and you need to log the stack trace before the process dies. This is a safety net, not a control flow mechanism. Reserve it for library code or top-level handlers where crashing is unacceptable.
Use sequential code when the operations are fast and do not benefit from concurrency. Concurrency adds complexity. If the work takes microseconds, the overhead of goroutines and channels outweighs the benefit. The simplest thing that works is usually the right thing.
Where to go next
- How to Handle Errors in gRPC with Go
- How to Use errors.As in Go
- How to Implement Graceful Degradation in Go Services
Errors are values. Pass them like you pass strings. A goroutine that panics takes down the process. Treat panics as fatal bugs, not control flow. Close channels on the sender side. Always.