Go Programming: Best Practices, Anti-Patterns, and Refactoring

Go with the Flow: Understanding and Overcoming Common Golang Issues

golang golang-anti-patterns|300

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 variable b in a new goroutine and a read of b in the main goroutine can cause data races. Compilers and CPUs may reorder instructions, leading to situations where a condition b == true doesn’t guarantee that another variable a is not nil, potentially causing panics if a 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
    19
    package 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 be nil 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
    11
    package 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 that a is initialized before it’s accessed.

  • Using time.Sleep Calls to Do Synchronizations
    Relying on time.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
    13
    package 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 to x might happen after the read. Similarly, when sending a value via a channel, time.Sleep cannot guarantee when the expression for the value (n in c <- 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 a nil 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
    10
    func 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 than 4, four goroutines will hang indefinitely trying to send values. To avoid this, the channel capacity must be at least 4.

  • Copying Values of Types in the sync Standard Package
    Values of types from the sync package (except Locker interface values) should never be copied. Only pointers to such values should be copied. Copying sync.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
    13
    import "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 takes Counter by value, causing the Mutex field to be copied unsynchronized, potentially corrupting it. Even if not corrupted, it protects a copied field n, which is generally meaningless.
    Best Practice: Change the receiver type to a pointer type (*Counter) to avoid copying sync.Mutex values. The go vet command can report such potential bad copies.

  • Calling the sync.WaitGroup.Add Method at Wrong Places
    A sync.WaitGroup maintains an internal counter, which, when zero, allows Wait to return. The Add method call must happen before the Wait 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
    20
    package 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, because Add calls are not guaranteed to happen before Wait.
    Best Practice: Move Add 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
    2
    ca, 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
    The time.After function creates a new time.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 many Timer 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
    14
    import (
    "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 single time.Timer value.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    func 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).
    Incorrect time.Timer usage can lead to data races or unexpected behavior. Calling Reset 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 a time.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
    4
    type unexportedType string
    func ExportedFunc() unexportedType {
    return unexportedType("some string")
    }

    Recommended:

    1
    2
    3
    4
    type 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
    3
    for _ = range sequence { run() }
    x, _ := someMap[key]
    _ = <- ch

    Recommended:

    1
    2
    3
    for range something { run() }
    x := someMap[key]
    <- ch
  • Using Loop/Multiple appends to Concatenate Two Slices
    Instead of iterating and appending elements one by one, use the variadic append function to concatenate slices in a single statement.
    Bad Practice:

    1
    2
    3
    for _, v := range sliceTwo {
    sliceOne = append(sliceOne, v)
    }

    Recommended:

    1
    sliceOne = append(sliceOne, sliceTwo...)
  • Redundant Arguments in make Calls
    The make 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
    2
    ch = make(chan int, 0)
    sl = make([]int, 1, 1)

    Recommended:

    1
    2
    ch = 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 a return statement as the final statement in functions that do not return a value.
    Bad Practice:

    1
    2
    3
    4
    func alwaysPrintFoofoo() {
    fmt.Println("foofoo")
    return
    }

    Recommended:

    1
    2
    3
    func alwaysPrintFoo() {
    fmt.Println("foofoo")
    }

    This does not apply to named returns, where return explicitly returns the named result.

  • Useless break Statements in switch
    Go switch statements do not have automatic fallthrough by default, unlike C. Therefore, break statements at the end of a case block are redundant.
    Bad Practice:

    1
    2
    3
    4
    5
    6
    7
    switch s {
    case 1:
    fmt.Println("case one")
    break
    case 2:
    fmt.Println("case two")
    }

    Recommended:

    1
    2
    3
    4
    5
    6
    switch s {
    case 1:
    fmt.Println("case one")
    case 2:
    fmt.Println("case two")
    }

    If fallthrough is desired, the fallthrough statement must be explicitly used.

  • Not Using Helper Functions for Common Tasks
    Use helper functions like sync.WaitGroup.Done() for clarity and efficiency.
    Bad Practice:

    1
    2
    3
    wg.Add(1)
    // ...some code
    wg.Add(-1)

    Recommended:

    1
    2
    3
    wg.Add(1)
    // ...some code
    wg.Done()
  • Redundant nil Checks on Slices
    The length of a nil slice is zero, so a nil 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
    A select statement is used for waiting on multiple communication operations. If there’s only one case, a direct send or receive operation is sufficient. A default case should be added if the intent is to try a send/receive without blocking.
    Bad Practice:

    1
    2
    3
    4
    select {
    case x := <- ch:
    fmt.Println(x)
    }

    Recommended:

    1
    2
    x := <- ch
    fmt.Println(x)
  • context.Context Should Be the First Parameter of the Function
    It’s a strong convention in Go that context.Context should be the first parameter, typically named ctx. 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
    11
    func 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 experimental GOEXPERIMENT=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
    11
    type 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 modified v2.0 version, or if the pointer itself was replaced, it might continue to use the old v1.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 classic for 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 up recover 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 a recover, 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
    12
    type 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
    6
    func 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
    9
    type 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.


More

Recent Articles:

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

Go 1.24 Map Improvements: Swiss Tables & Performance Gains The Enduring Debate Over Error Handling in Go

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×