Go is one of the easiest languages to write beginner code in. The syntax is minimal, the compiler is fast, and the standard library covers most use cases. But there is a gap between “code that compiles and works” and “idiomatic Go that is maintainable at scale,” and that gap shows up clearly in code reviews.
Here are the patterns that come up most.
Error Handling: Wrapping vs Discarding
Junior Go code:
func getUser(id string) (*User, error) {
user, err := db.Query(id)
if err != nil {
return nil, err
}
return user, nil
}
Senior Go code:
func getUser(id string) (*User, error) {
user, err := db.Query(id)
if err != nil {
return nil, fmt.Errorf("getUser %s: %w", id, err)
}
return user, nil
}
The %w verb wraps the error, preserving it for errors.Is() and errors.As() checks up the call stack. Without wrapping, you lose context about where the error originated. The calling code sees a bare database error with no indication of which function or which user ID caused it.
The pattern is: every function that propagates an error should add context. The extra three characters save hours of debugging.
Interfaces: Small and Behavior-Focused
Juniors tend to write interfaces that match the concrete type they are using:
// Too large - this is a class, not a behavior
type UserRepository interface {
GetByID(id string) (*User, error)
GetByEmail(email string) (*User, error)
Create(user *User) error
Update(user *User) error
Delete(id string) error
List(filters UserFilters) ([]*User, error)
Count(filters UserFilters) (int, error)
}
Senior Go engineers define interfaces at the point of use, sized to what the consumer actually needs:
// In the email service package
type UserGetter interface {
GetByID(id string) (*User, error)
}
func SendWelcomeEmail(users UserGetter, userID string) error {
user, err := users.GetByID(userID)
// ...
}
Small interfaces are easier to implement, easier to mock, and explicitly communicate what a function cares about. The io.Reader and io.Writer interfaces in the standard library are the canonical examples - one method each, used everywhere.
Goroutine Lifecycle Management
Junior code spawns goroutines without managing their lifecycle:
func processItems(items []Item) {
for _, item := range items {
go processItem(item) // No way to know when this finishes
}
// Function returns, goroutines may still be running
}
Senior Go code uses WaitGroups or channels to manage goroutine lifetimes:
func processItems(ctx context.Context, items []Item) error {
var wg sync.WaitGroup
errCh := make(chan error, len(items))
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
if err := processItem(ctx, item); err != nil {
errCh <- err
}
}(item)
}
wg.Wait()
close(errCh)
for err := range errCh {
return err // Return first error
}
return nil
}
Unmanaged goroutines are goroutine leaks waiting to happen. Every goroutine should have a clear termination condition.
Context Propagation
Juniors pass context through some functions and not others:
func handleRequest(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r.Context(), r.URL.Query().Get("id"))
if err != nil { ... }
// Context lost here - downstream operations cannot be cancelled
data, err := fetchExternalData(user.ID)
}
Senior engineers thread context through every I/O operation:
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, err := getUser(ctx, r.URL.Query().Get("id"))
if err != nil { ... }
data, err := fetchExternalData(ctx, user.ID)
}
Context propagation means request cancellation (when a user closes the browser), timeouts, and trace IDs propagate correctly through the entire call chain.
Table-Driven Tests
Junior tests:
func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("expected 3")
}
if Add(-1, 1) != 0 {
t.Error("expected 0")
}
}
Senior Go tests use table-driven format:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 1, 2, 3},
{"negative and positive", -1, 1, 0},
{"both negative", -2, -3, -5},
{"zero identity", 0, 5, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.expected {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
}
})
}
}
Table-driven tests are easier to extend, self-documenting, and produce clearer failure messages.
Struct Embedding vs Inheritance
Juniors sometimes reach for complex struct hierarchies. Senior Go engineers use embedding for composition:
type BaseHandler struct {
logger *slog.Logger
tracer trace.Tracer
}
func (b *BaseHandler) log(msg string, args ...any) {
b.logger.Info(msg, args...)
}
type UserHandler struct {
BaseHandler
users UserRepository
}
// UserHandler gets log() for free via embedding
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
h.log("getting user", "id", r.PathValue("id"))
// ...
}
The defer Trap
A subtle one that trips up developers from other languages:
// Bug: defer evaluates arguments immediately
func processFiles(files []string) error {
for _, f := range files {
file, err := os.Open(f)
if err != nil {
return err
}
defer file.Close() // This defers until the FUNCTION returns, not the loop iteration
}
// All files are open here, only closed when processFiles returns
}
The fix:
func processFiles(files []string) error {
for _, f := range files {
if err := processFile(f); err != nil {
return err
}
}
return nil
}
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // Now correctly scoped to this function
// ...
return nil
}
Bottom Line
Go’s simplicity is deceptive. The language is easy to learn but the patterns for writing reliable, maintainable Go are learned through production experience and code review feedback. Error wrapping, small interfaces, goroutine lifecycle management, and context propagation are the fundamentals that separate Go code that works from Go code that you can confidently operate.
Review your codebase for these patterns. The places where they are missing are where your most annoying bugs live.
Comments