Go is a language that rewards simplicity, but its very design can lead developers into subtle traps. We have seen teams adopt Go with enthusiasm, only to hit performance regressions, mysterious panics, or code that is harder to maintain than the Java or Python it replaced. The mistakes are not random—they cluster around a few core patterns that look right at first glance but break under real workloads. This guide walks through five frequent mistakes we see in Go projects, explains why each one fails, and offers concrete fixes with code examples. Whether you are new to Go or have been writing it for years, understanding these pitfalls will help you write cleaner, more idiomatic Go that scales well under load.
1. Overusing Goroutines Without Understanding Lifecycle Management
One of the first things developers love about Go is how easy it is to launch a goroutine with go func(). But that ease becomes a trap when goroutines are spawned without a clear plan for when they should stop. We have seen services that start hundreds of goroutines per request, each doing a small task, and then wonder why memory grows until the OOM killer steps in. The problem is not concurrency itself—it is that goroutines that outlive their usefulness or leak due to blocked channels consume resources indefinitely.
Consider a typical pattern: a handler that fans out work to several goroutines and expects to collect results via a channel. If the handler returns early due to an error, those goroutines may still be running, waiting to send on a channel that no one is reading. They never exit, and their stack frames and heap allocations stay alive. Over time, this leaks memory and degrades throughput. The fix is to always have a mechanism for cancellation—either via context.Context or a dedicated stop channel—and to use sync.WaitGroup to ensure all goroutines complete before the function returns.
Using Context for Graceful Shutdown
The standard library's context package is the idiomatic way to propagate cancellation signals. Every goroutine that performs blocking operations should select on a context's Done channel. For example, instead of a bare channel receive, use a select with a default case that checks context cancellation. This allows the goroutine to exit promptly when the parent cancels. We also recommend passing context as the first parameter to any function that may need to be cancelled, following Go's convention.
Worker Pools Instead of Per-Request Goroutines
For high-throughput services, a fixed worker pool is often better than spawning goroutines per task. You create a bounded number of workers that pull jobs from a buffered channel. This limits resource usage and makes backpressure explicit. If the channel is full, the sender blocks or drops the job, giving the system a natural throttle. We have seen teams reduce memory usage by 60% just by switching from per-request goroutines to a pool of 50 workers.
Another common mistake is using go inside a loop without capturing the loop variable correctly. Before Go 1.22, the loop variable was reused, so all goroutines would see the same final value. Always pass the variable as an argument to the goroutine, or use a local copy. In Go 1.22+, the loop variable is per-iteration, but older code still needs the fix. We recommend enabling the new loop semantics via GOEXPERIMENT=loopvar or upgrading to Go 1.22+ to avoid this class of bug entirely.
2. Misusing Channels as Synchronization Primitives
Channels are Go's primary communication mechanism, but they are often misapplied as a general-purpose synchronization tool. We see code where channels are used purely to signal completion, with no data being passed. While this works, it is often less efficient and less readable than using sync.WaitGroup or sync.Mutex. Channels have overhead—they involve copying data, managing internal buffers, and scheduling goroutines. Using a channel as a binary semaphore is like using a forklift to move a pencil.
When to Use sync.WaitGroup Instead
If you only need to wait for a set of goroutines to finish, sync.WaitGroup is the right tool. It is lightweight, non-blocking (except for Wait()), and clearly expresses intent. A common anti-pattern is creating a channel of struct{} and having each goroutine send on it when done, then looping to receive N times. This works but introduces unnecessary channel operations and can mask errors if a goroutine forgets to send. With WaitGroup, you call Add(1) before launching, Done() in the goroutine, and Wait() to block until all complete.
Avoiding Deadlocks with Unbuffered Channels
Unbuffered channels require both a sender and a receiver to be ready at the same time. This can lead to deadlocks if the send or receive is not in a goroutine. A classic mistake: reading from an unbuffered channel in the same function that writes to it, without a goroutine. The program deadlocks immediately. Always ensure that sends and receives happen in separate goroutines, or use a buffered channel with capacity that matches the expected number of messages. We recommend starting with a small buffer (e.g., 1 or 10) unless you have measured the exact concurrency pattern.
3. Ignoring Error Handling and Not Wrapping Errors
Go's explicit error handling is one of its strengths, but many developers treat errors as an afterthought. The most common mistake is simply ignoring errors with _ = doSomething() or logging them without returning. This can lead to silent data corruption or partial failures that are hard to diagnose. Another frequent issue is not wrapping errors with context. In Go 1.13+, the fmt.Errorf function with %w verb allows you to create error chains. Without wrapping, you lose the original error, making debugging much harder.
Always Check and Propagate Errors
Every function that can fail should return an error, and callers should check it. There is no excuse for swallowing errors. If you are writing a library, return errors to the caller; do not log and continue. If you are writing an application, log the error with enough context (request ID, operation) and then either retry or fail gracefully. We also recommend using errors.Is and errors.As to inspect error chains rather than comparing error strings.
Using Custom Error Types for Structured Information
For complex applications, consider defining custom error types that carry additional fields like HTTP status codes, error codes, or user messages. This makes it easier to handle errors at different layers. For example, a validation error might include a field name and a reason. The standard library's errors package is sufficient for simple cases, but for larger projects, third-party packages like pkg/errors or cockroachdb/errors provide stack traces and richer wrapping. However, we caution against over-engineering: start with basic wrapping and add structure only when you need it.
4. Overusing the Empty Interface and Type Assertions
The empty interface interface{} (or any in Go 1.18+) is a powerful escape hatch, but it is often overused to avoid defining proper types. We have seen codebases where every function accepts any and then does type assertions to figure out what it received. This defeats the purpose of Go's static type system and leads to runtime panics when the assertion fails. The fix is to define explicit interfaces that describe the behavior you need, or use generics (Go 1.18+) to write type-safe polymorphic functions.
Prefer Specific Interfaces Over any
Instead of func Process(data any) error, define an interface like type Processor interface { Process() error } and have your types implement it. This makes the contract clear at compile time and eliminates runtime type checks. If you need to handle multiple types, consider a union interface with a type switch, but keep the number of cases small. For truly heterogeneous data (e.g., JSON decoding), any is appropriate, but limit its scope to the boundary where data enters your system.
Generics as a Safer Alternative
Go 1.18 introduced generics, which allow you to write functions that work on multiple types without sacrificing type safety. For example, a Map function that transforms a slice can be generic: func Map[T, U any](s []T, f func(T) U) []U. This is much cleaner than using any and type assertions. We recommend using generics for container-like operations (maps, slices, channels) and for algorithms that are type-agnostic. However, avoid overusing generics for simple cases where a specific interface would be clearer.
5. Not Profiling or Benchmarking Before Optimizing
Go is fast, but it is not magic. Many developers assume that because Go compiles to native code, their code will be performant by default. They then spend hours micro-optimizing without measuring. The most common mistake is optimizing the wrong thing—like using a sync.Pool for allocations that are negligible, while ignoring a slow database query or a hot loop with unnecessary allocations. The fix is to profile first, using the built-in pprof package, and to write benchmarks for critical paths.
Using the Go Profiling Tools
The net/http/pprof package can be imported to expose profiling endpoints. Once your service is running, you can collect CPU and heap profiles with go tool pprof. Look for functions that consume high CPU or allocate a lot of memory. Common findings include: excessive garbage collection due to many small allocations, goroutine leaks, and lock contention. We recommend running profiles under realistic load, not just idle, because bottlenecks often appear under concurrency.
Writing Benchmarks with the testing Package
Go's testing package supports benchmarks natively. Write a benchmark function that exercises the code in a loop, then run go test -bench=. -benchmem. This gives you allocations per operation, which is often more important than raw speed. A common mistake is to benchmark in isolation without considering the real-world context—for example, benchmarking a function that never blocks, while in production it blocks on I/O. Always benchmark the full path, not just the computational part.
6. When Not to Use These Patterns
Not every project needs fine-grained error wrapping or a worker pool. For small scripts or prototypes, ignoring errors temporarily might be acceptable if you are the only user and you know the code will not be reused. Similarly, using any in a quick script that reads JSON from a file is fine—the risk is low and the code is short-lived. The key is to recognize when a project will grow. If you are writing a library that others will depend on, or a service that must run 24/7, then the patterns above become critical.
When Channels Are Still the Right Choice
Channels are ideal when you need to coordinate multiple goroutines with data flow—for example, a pipeline where each stage processes data and sends it to the next. In that case, channels provide backpressure and natural synchronization. Do not replace them with mutexes and condition variables just because channels have overhead; the clarity and safety often outweigh the cost. Benchmark both approaches if you are unsure.
When to Skip Profiling
For a new project that is not yet in production, premature profiling can be a waste of time. Focus on writing correct, readable code first. Once you have a working version and you see performance issues in production, then profile. The 90/10 rule applies: 90% of the performance gain comes from fixing 10% of the code. Do not optimize everything upfront.
7. Frequently Asked Questions
Q: Should I always use context.Background() or context.TODO()?
Use context.Background() only at the top level of your application (e.g., in main() or in tests). For library functions, accept a context parameter from the caller. context.TODO() is a placeholder for when you have not yet decided which context to use; it should be replaced before production.
Q: Is it safe to use go inside a loop in Go 1.22+?
Yes, because the loop variable is now per-iteration. However, if you are using a closure that modifies the variable, you still need to be careful. The safest pattern is to pass the variable as an argument to the goroutine, which works in all Go versions.
Q: How do I choose between a buffered and unbuffered channel?
Use unbuffered channels when you want synchronization—the sender blocks until the receiver is ready. Use buffered channels when you want decoupling and a limited queue. The buffer size should be based on your expected throughput and acceptable latency. Start with a small buffer (1 or 10) and measure.
Q: What is the best way to handle panics in goroutines?
Never let a panic crash your program. In a goroutine, use defer recover() to catch panics and log them. Then either restart the goroutine or propagate the error via a channel. The standard library's net/http server already recovers from panics in handlers, but for custom goroutines, you need to add recovery yourself.
8. Summary and Next Steps
Avoiding these five mistakes will make your Go code more reliable, maintainable, and performant. Start by reviewing your current project for goroutine leaks: run a heap profile and look for goroutines that are stuck in chan send or chan receive. Next, audit your error handling: search for if err != nil { return nil } without wrapping, and add context with fmt.Errorf. Then, look for places where you use any and see if you can replace it with a specific interface or a generic function.
For your next project, adopt these practices from the start:
- Always pass a context to functions that may block.
- Use
sync.WaitGroupfor waiting on goroutines, not channels. - Wrap errors with
%wto preserve the error chain. - Define small, focused interfaces instead of
any. - Profile before optimizing—use
pprofandtesting.B.
Finally, share these patterns with your team. Code reviews are the best place to catch these mistakes early. If you see a goroutine without a cancellation mechanism or an error that is silently ignored, flag it. Over time, these habits become second nature, and your Go code will be a joy to maintain.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!