Go with the Flow: Understanding and Overcoming Common Golang Issues
Note: The core content was generated by an LLM, with human fact-checking and structural refinement.
Writing robust, efficient, and maintainable Go code requires an understanding of common pitfalls and the adoption of best practices. This article outlines prevalent mistakes, anti-patterns, and effective optimization and refactoring techniques in Go programming.
Common Concurrent Programming Mistakes
Go provides built-in support for concurrent programming using goroutines and channels, making concurrency easy and flexible. However, it doesn’t prevent programmers from making mistakes due to carelessness or lack of experience.
No Synchronizations When Synchronizations Are Needed
Code lines might not be executed in their appearance order. For example, a write to a variableb
in a new goroutine and a read ofb
in the main goroutine can cause data races. Compilers and CPUs may reorder instructions, leading to situations where a conditionb == true
doesn’t guarantee that another variablea
is notnil
, potentially causing panics ifa
is accessed before initialization.Bad Practice Example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19package main
import (
"time"
"runtime"
)
func main() {
var a []int // nil
var b bool // false
// a new goroutine
go func () {
a = make([]int, 3)
b = true // write b
}()
for !b { // read b
time.Sleep(time.Second)
runtime.Gosched()
}
a, a, a = 0, 1, 2 // might panic
}This program might panic because
a
could still benil
when its elements are modified.Best Practice: Use channels or synchronization techniques from the
sync
package to ensure memory orders.1
2
3
4
5
6
7
8
9
10
11package main
func main() {
var a []int = nil
c := make(chan struct{})
go func () {
a = make([]int, 3)
c <- struct{}{}
}()
<-c // The next line will not panic for sure.
a, a, a = 0, 1, 2
}Here, the channel
c
ensures thata
is initialized before it’s accessed.Using
time.Sleep
Calls to Do Synchronizations
Relying ontime.Sleep
for synchronization is a common mistake in formal projects. While a program might seem to work most of the time, Go runtime does not guarantee the order of operations.
Bad Practice Example:1
2
3
4
5
6
7
8
9
10
11
12
13package main
import (
"fmt"
"time"
)
func main() {
var x = 123
go func() {
x = 789 // write x
}()
time.Sleep(time.Second)
fmt.Println(x) // read x
}This code is expected to print
789
and usually does, but it’s not guaranteed. Under certain conditions, the write tox
might happen after the read. Similarly, when sending a value via a channel,time.Sleep
cannot guarantee when the expression for the value (n
inc <- n + 0
) is evaluated relative to other operations (n = 789
), leading to data races and compiler-dependent output.Best Practice: Store values in temporary variables before creating goroutines if a snapshot is needed, or use proper synchronization primitives.
Leaving Goroutines Hanging
Hanging goroutines are those that remain in a blocking state indefinitely, consuming resources. Common reasons include attempting to receive from a channel that no other goroutines will send to, sending to anil
channel, or being part of a deadlock.
Example of Hanging Goroutines due to insufficient channel capacity:1
2
3
4
5
6
7
8
9
10func request() int {
c := make(chan int) // Unbuffered channel, or capacity less than 4
for i := 0; i < 5; i++ {
i := i // create a new 'i' for each goroutine
go func() {
c <- i // 4 goroutines will hang here if capacity is 0 or too small
}()
}
return <-c
}In this scenario, if channel
c
is unbuffered, or its capacity is less than4
, four goroutines will hang indefinitely trying to send values. To avoid this, the channel capacity must be at least4
.Copying Values of Types in the
sync
Standard Package
Values of types from thesync
package (exceptLocker
interface values) should never be copied. Only pointers to such values should be copied. Copyingsync.Mutex
as part of a receiver value can lead to corruption or meaningless protection.
Bad Practice Example:1
2
3
4
5
6
7
8
9
10
11
12
13import "sync"
type Counter struct {
sync.Mutex
n int64
}
// The method is bad. When it is called,
// the Counter receiver value will be copied.
func (c Counter) Value() (r int64) {
c.Lock()
r = c.n
c.Unlock()
return
}The
Value()
method takesCounter
by value, causing theMutex
field to be copied unsynchronized, potentially corrupting it. Even if not corrupted, it protects a copied fieldn
, which is generally meaningless.
Best Practice: Change the receiver type to a pointer type (*Counter
) to avoid copyingsync.Mutex
values. Thego vet
command can report such potential bad copies.Calling the
sync.WaitGroup.Add
Method at Wrong Places
Async.WaitGroup
maintains an internal counter, which, when zero, allowsWait
to return. TheAdd
method call must happen before theWait
method call if the counter is zero.
Bad Practice Example:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
var x int32 = 0
for i := 0; i < 100; i++ {
go func() {
wg.Add(1) // Called inside goroutine
atomic.AddInt32(&x, 1)
wg.Done()
}()
}
fmt.Println("Wait ...")
wg.Wait()
fmt.Println(atomic.LoadInt32(&x))
}This program’s final printed number may be arbitrary, not always
100
, becauseAdd
calls are not guaranteed to happen beforeWait
.
Best Practice: MoveAdd
calls out of the goroutines.1
2
3
4
5
6
7
8
9// ...
for i := 0; i < 100; i++ {
wg.Add(1) // Called outside goroutine
go func() {
atomic.AddInt32(&x, 1)
wg.Done()
}()
}
// ...Using Channels as Futures/Promises Improperly
When functions return channels as futures, accessing them in a single line can cause sequential processing instead of concurrent.
Bad Practice:1
doSomethingWithFutureArguments(<-fa(), <-fb())
Best Practice: Process future generations concurrently by assigning them to variables first.
1
2ca, cb := fa(), fb()
doSomethingWithFutureArguments(<-ca, <-cb)Close Channels Not From the Last Active Sender Goroutine
Closing a channel while other goroutines might still send values to it can cause a panic. This mistake has occurred in projects like Kubernetes. It is crucial to safely and gracefully close channels, typically only when no more senders will be active.Do 64-bit Atomic Operations on Values Which Are Not Guaranteed to Be 8-byte Aligned
For non-method 64-bit atomic operations, the value’s address must be 8-byte aligned, especially on 32-bit architectures, to avoid panics. Since Go 1.19, 64-bit method atomic operations can avoid this drawback.Not Pay Attention to Too Many Resources Are Consumed by Calls to the
time.After
Function
Thetime.After
function creates a newtime.Timer
value with each call, which remained alive for its duration before Go 1.23. Frequent calls (e.g., millions of messages per minute) could accumulate manyTimer
values, consuming significant memory and computation.
Bad Practice Example (before Go 1.23):1
2
3
4
5
6
7
8
9
10
11
12
13
14import (
"fmt"
"time"
)
func longRunning(messages <-chan string) {
for {
select {
case <-time.After(time.Minute): // Creates a new Timer each time
return
case msg := <-messages:
fmt.Println(msg)
}
}
}Note: This specific problem has been resolved since Go 1.23, as
Timer
values can now be garbage collected without expiring or being stopped.
Best Practice (before Go 1.23, or for general resource reuse): Reuse a singletime.Timer
value.1
2
3
4
5
6
7
8
9
10
11
12
13
14func longRunning(messages <-chan string) {
timer := time.NewTimer(time.Minute)
defer timer.Stop() // Still good practice to stop
for {
select {
case <-timer.C: // expires (timeout)
return
case msg := <-messages:
fmt.Println(msg)
// Before Go 1.23: if !timer.Stop() { <-timer.C } // Drain if already fired
}
timer.Reset(time.Minute) // Reset for reuse
}
}Since Go 1.23,
Timer.Reset
automatically drains stale notifications, simplifying the code further.Use
time.Timer
Values Incorrectly
(Note: Problems in this section mostly existed before Go 1.23).
Incorrecttime.Timer
usage can lead to data races or unexpected behavior. CallingReset
must be done when the timer has expired or been stopped. Not draining a pending notification after stopping a timer and before resetting it can cause an immediate fire in the next loop. Using atime.Timer
concurrently among multiple goroutines is bug-prone and not recommended.
Common Go Anti-Patterns
Anti-patterns are solutions that seem appropriate initially but lead to obscurity and technical debt as the codebase scales.
Returning Value of Unexported Type from an Exported Function
Exported names in Go start with an uppercase letter to be visible to other packages. Returning an unexported type from an exported function can be frustrating for callers, as they might have to redefine the type.
Bad Practice:1
2
3
4type unexportedType string
func ExportedFunc() unexportedType {
return unexportedType("some string")
}Recommended:
1
2
3
4type ExportedType string
func ExportedFunc() ExportedType {
return ExportedType("some string")
}Unnecessary Use of Blank Identifier
Assigning values to a blank identifier (_
) is often unnecessary. For range loops where only values are needed, the blank identifier for the index can be omitted.
Bad Practice:1
2
3for _ = range sequence { run() }
x, _ := someMap[key]
_ = <- chRecommended:
1
2
3for range something { run() }
x := someMap[key]
<- chUsing Loop/Multiple
append
s to Concatenate Two Slices
Instead of iterating and appending elements one by one, use the variadicappend
function to concatenate slices in a single statement.
Bad Practice:1
2
3for _, v := range sliceTwo {
sliceOne = append(sliceOne, v)
}Recommended:
1
sliceOne = append(sliceOne, sliceTwo...)
Redundant Arguments in
make
Calls
Themake
function for channels, maps, and slices has default values for buffer capacity or initial size. Explicitly providing zero capacity for an unbuffered channel or capacity equal to length for a slice is redundant.
Bad Practice:1
2ch = make(chan int, 0)
sl = make([]int, 1, 1)Recommended:
1
2ch = make(chan int)
sl = make([]int, 1)Using named constants for channel buffer capacity, even if zero, is acceptable for debugging or specific platform needs.
Useless
return
in Functions
Avoid areturn
statement as the final statement in functions that do not return a value.
Bad Practice:1
2
3
4func alwaysPrintFoofoo() {
fmt.Println("foofoo")
return
}Recommended:
1
2
3func alwaysPrintFoo() {
fmt.Println("foofoo")
}This does not apply to named returns, where
return
explicitly returns the named result.Useless
break
Statements inswitch
Goswitch
statements do not have automaticfallthrough
by default, unlike C. Therefore,break
statements at the end of acase
block are redundant.
Bad Practice:1
2
3
4
5
6
7switch s {
case 1:
fmt.Println("case one")
break
case 2:
fmt.Println("case two")
}Recommended:
1
2
3
4
5
6switch s {
case 1:
fmt.Println("case one")
case 2:
fmt.Println("case two")
}If
fallthrough
is desired, thefallthrough
statement must be explicitly used.Not Using Helper Functions for Common Tasks
Use helper functions likesync.WaitGroup.Done()
for clarity and efficiency.
Bad Practice:1
2
3wg.Add(1)
// ...some code
wg.Add(-1)Recommended:
1
2
3wg.Add(1)
// ...some code
wg.Done()Redundant
nil
Checks on Slices
The length of anil
slice is zero, so anil
check before checking length is unnecessary.
Bad Practice:1
if x != nil && len(x) != 0 { /* do something */ }
Recommended:
1
if len(x) != 0 { /* do something */ }
Too Complex Function Literals
Function literals that only call a single function are redundant and can be simplified by directly referencing the inner function.
Bad Practice:1
fn := func(x int, y int) int { return add(x, y) }
Recommended:
1
fn := add
Using
select
Statement with a Single Case
Aselect
statement is used for waiting on multiple communication operations. If there’s only one case, a direct send or receive operation is sufficient. Adefault
case should be added if the intent is to try a send/receive without blocking.
Bad Practice:1
2
3
4select {
case x := <- ch:
fmt.Println(x)
}Recommended:
1
2x := <- ch
fmt.Println(x)context.Context
Should Be the First Parameter of the Function
It’s a strong convention in Go thatcontext.Context
should be the first parameter, typically namedctx
. This consistency helps developers remember to include the argument and is common across various projects.
Bad Practice:1
func badPatternFunc(k favContextKey, ctx context.Context) { /* ... */ }
Recommended:
1
func goodPatternFunc(ctx context.Context, k favContextKey) { /* ... */ }
Golang Code Refactoring Best Practices
Refactoring is a crucial process for achieving clean, maintainable, and tested code.
- Refactoring Techniques
- Red-Green Refactor: A three-step process where you first write failing tests (red phase), then make changes to pass them (green phase), and finally refactor the code (refactor phase). This cycle ensures that new changes don’t break existing functionality and constantly improves code quality.
- Preparatory Refactoring: Refactoring performed during the implementation of a new feature to improve existing functionalities that the new feature will use or depend on. This involves gradually introducing new implementation details into older code.
- Refactoring by Abstraction: Stepping back to consider the architecture and structure of classes and methods to reduce redundancy and duplicated code. Techniques like Pull-Up and Push-Down involve moving methods and fields to appropriate levels (superclass, subclass, separate function).
- General Tips for Inherited Go Refactoring
When inheriting existing code, especially without documentation or matching acceptance criteria:- Make it run: Get the code functioning quickly to understand its behavior and identify initial bugs.
- Make continuous but small changes: Avoid large, hard-to-review changes.
- Get to know the software and development process: Understand the tool’s features and common user scenarios.
- Add comments and TODOs: Clarify ambiguous code blocks and note areas for improvement.
- Write unit tests: Limit technical debt by testing crucial parts of the code.
- Golang-Specific Tips
- Use linters: Static checks significantly increase readability.
- Don’t reinvent the wheel: Use existing packages for common tasks, but avoid adding new packages for minor functionalities easily implemented in-house.
- Get to know the packages used in the project: Familiarity with documentation of existing frameworks (like Ginkgo) helps in identifying areas for improvement.
- Actively think of project structure: While Go doesn’t have an official standard, common project pattern layouts can be leveraged to organize code for maintainability and clarity, particularly regarding exported vs. internal functionalities.
Go Coding Smells
Coding “smells” are indicators of deeper problems in the code, leading to issues like logic confusion, performance degradation, data inconsistency, or system crashes.
Asynchronous Timing Confusion (“I clearly updated it, why is it still old?”)
In high-concurrency scenarios, using goroutines for asynchronous operations without understanding atomicity and order can lead to traps. For instance, an asynchronous notification might read a “pending” status before the main process updates it to “paid,” causing business logic errors. This occurs when a goroutine captures a pointer to shared data that is later modified.
Problematic Scenario Example:1
2
3
4
5
6
7
8
9
10
11
12
13// Main goroutine updates order status, async goroutine reads it before update
func main() {
order := &Order{ID: "123", Status: "pending"}
var wg sync.WaitGroup
wg.Add(1)
go func(o *Order) { // captures order pointer
defer wg.Done()
asyncSendNotification(o) // Might read "pending"
}(order)
time.Sleep(500 * time.Millisecond) // Allow async to start
updateOrderStatusInDB(order, "paid") // Then update to "paid"
wg.Wait()
}Mitigation: Ensure synchronization for critical operations, use
sync.WaitGroup
or channels, pass value copies if a snapshot is needed, or re-fetch the latest state in async callbacks.
Corrected Example Idea (passing copy):1
2
3
4
5
6
7
8
9
10
11func main() {
order := &Order{ID: "123", Status: "pending"}
// ...
updateOrderStatusInDB(order, "paid") // Update first
wg.Add(1)
go func(o Order) { // Pass a copy of the *updated* order
defer wg.Done()
asyncSendNotification(&o) // Uses the "paid" status copy
}(*order) // Dereference to pass value copy
wg.Wait()
}Pointers and Closures (“I thought it hadn’t changed, but it ran away!”)
When a closure captures a pointer, and the data pointed to (or the pointer itself) is modified by external code while the goroutine is executing, it can lead to unexpected concurrent behavior. Even with Go 1.22+’s experimentalGOEXPERIMENT=loopvar
addressing classic loop variable traps, considerations for shared mutable state with pointers and closures remain vital.
Problematic Scenario Example:1
2
3
4
5
6
7
8
9
10
11type Config struct { Version string; Timeout time.Duration }
func watchConfig(cfg *Config, wg *sync.WaitGroup) { /* ... uses cfg ... */ }
func main() {
currentConfig := &Config{Version: "v1.0", Timeout: 5 * time.Second}
wg.Add(1)
go watchConfig(currentConfig, &wg) // captures currentConfig pointer
time.Sleep(10 * time.Millisecond)
currentConfig.Version = "v2.0" // Modifies content pointed to by currentConfig
// Or: currentConfig = &Config{Version: "v3.0", ...} // Modifies the pointer itself
wg.Wait()
}The
watchConfig
goroutine might observe the modifiedv2.0
version, or if the pointer itself was replaced, it might continue to use the oldv1.0
object, depending on intent.
Mitigation: Explicitly decide if a goroutine needs a data snapshot or shared state. If a snapshot, pass a value copy. If shared state is needed, use synchronization mechanisms (mutex
,channel
,atomic
) to protect access. Be cautious when capturing mutable pointers, understanding their lifecycle and concurrent modification risks. For the classicfor
loop closure issue in Go 1.21 and earlier, explicitly pass loop variables as function arguments to the goroutine.Error Handling Philosophy (“If it’s a Bug, Let It Crash!” Is it Really Good?)
While “fail fast” is often advocated in development, in mission-critical systems (e.g., financial, air traffic control), an unexpected panic leading to a full crash can cause severe losses.
Problematic Scenario (HTTP Handler without recover):1
2
3
4
5
6
7
8// handleRequestVersion1 - no recover
func handleRequestVersion1(processor *CriticalDataProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// ...
err := processor.Process(dataID, payload) // If this panics, whole HTTP server goroutine crashes
// ...
}
}A panic in a request-handling goroutine, if unrecovered, can terminate the entire HTTP server process, impacting service availability.
Mitigation: Set uprecover
mechanisms at the top level of critical service request handlers or goroutines.1
2
3
4
5
6
7
8
9
10
11
12
13// handleRequestVersion2 - with recover
func handleRequestVersion2(processor *CriticalDataProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
fmt.Fprintf(os.Stderr, "!!!!!!!!!!!!!! PANIC 捕获 !!!!!!!!!!!!!!\n")
// Log error, stack trace, return generic 500 to client
http.Error(w, "服务器内部错误,请稍后重试", http.StatusInternalServerError)
}
}()
// ... normal request processing, including calls to processor.Process
}
}Recovering allows for logging, returning graceful errors to clients, and prevents a single bug from taking down the entire service. However, do not abuse
panic/recover
for normal error handling; it’s for unexpected, catastrophic errors. After arecover
, handle the system state with extreme caution.API Design “Missing Documentation” (“What do these parameters mean? Guess!”)
Lack of clear, accurate documentation for API parameters, return values, error codes, and behavior semantics forces users to guess or read source code, leading to misuse and high integration costs.
Problematic Scenario (Undocumented NamingClient interface):1
2
3
4
5
6
7
8
9
10
11
12type NamingClient interface {
// GetInstance 获取服务实例。
// Critical questions:
// 1. serviceName: need namespace/group? format?
// 2. clusters: optional? multiple means random, specific strategy?
// 3. healthyOnly: filters unhealthy? what if no healthy?
// 4. instance: what struct? nil/error if not found?
// 5. error types? how to distinguish?
// 6. Blocking? Timeout?
// 7. Local cache? Refresh strategy?
GetInstance(serviceName string, clusters []string, healthyOnly bool) (instance interface{}, err error)
}Without clear answers to these questions, users will resort to guessing, making the API fragile and hard to debug.
Mitigation (for API designers): Provide clear, accurate, and detailed documentation. This includes parameter semantics, value ranges, default behaviors, edge cases, error types, side effects, and clear examples. Prioritize the API user’s perspective.Anonymous Function Type Signatures “Clumsiness” (“These function parameters are dazzling!”)
When complex anonymous function type signatures are directly embedded in a function definition, it significantly reduces code readability and can appear redundant.
Bad Practice Example:1
2
3
4
5
6func processData(
data []string,
filterFunc func(string) bool, // Long, complex signature
transformFunc func(string) (string, error),
aggregatorFunc func([]string) string,
) (string, error) { /* ... */ }Mitigation: Use the
type
keyword to define function type aliases. This is the most recommended and idiomatic way to improve readability.
Recommended:1
2
3
4
5
6
7
8
9type StringFilter func(string) bool
type StringTransformer func(string) (string, error)
type StringAggregator func([]string) string
func processDataWithTypeAlias(
data []string,
filter StringFilter, // Much cleaner with aliases
transform StringTransformer,
aggregate StringAggregator,
) (string, error) { /* ... */ }While Go emphasizes explicit types, aliases provide readability without sacrificing type clarity.
References and Links:
- Common Concurrent Programming Mistakes - Go 101: This article draws heavily from “Excerpts from ‘Common Concurrent Programming Mistakes - Go 101’”.
- **Common anti-patterns in Go - DeepSource:** This section is based on “Excerpts from ‘Common anti-patterns in Go - DeepSource’”.
- Golang code refactoring: Best practices and a practical use case - CodiLime: Content for this section is derived from “Excerpts from ‘Golang code refactoring: Best practices and a practical use case - CodiLime’”.
- “这代码迟早出事!”——复盘线上问题:六个让你头痛的Go编码坏味道 - Tony Bai: This section’s insights are from “Excerpts from ‘“这代码迟早出事!”——复盘线上问题:六个让你头痛的Go编码坏味道 - Tony Bai’”.
More
Recent Articles:
- The Enduring Debate Over Error Handling in Go on Medium on Website
- How to Make Your Go Code Truly Discoverable on Medium on Website
Random Article:
More Series Articles about You Should Know In Golang:
https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a
And I’m Wesley, delighted to share knowledge from the world of programming.
Don’t forget to follow me for more informative content, or feel free to share this with others who may also find it beneficial. It would be a great help to me.
Give me some free applauds, highlights, or replies, and I’ll pay attention to those reactions, which will determine whether I continue to post this type of article.
See you in the next article. 👋
中文文章: https://programmerscareer.com/zh-cn/golang-anti-patterns/
Author: Medium,LinkedIn,Twitter
Note: Originally written at https://programmerscareer.com/golang-anti-patterns/ at 2025-07-20 17:09.
Copyright: BY-NC-ND 3.0
Comments