Go Best Practices and Idioms

Writing idiomatic Go means following community conventions and the language's philosophy.

Code Style

Use go fmt

Always format your code. No debates about style.

go fmt ./...
# Or use gofmt -w .

Use goimports

Automatically manage imports:

goimports -w .

Naming Conventions

// Package names: short, lowercase, no underscores
package http
package user
package userservice  // Not user_service

// Variables and functions: camelCase
var userName string
func getUserByID(id int) *User

// Exported names: PascalCase
type User struct {}
func NewUser() *User

// Acronyms: keep consistent case
var userID string   // Not userId
var httpClient      // Not hTTPClient
type HTTPHandler    // Not HttpHandler

// Interface names: often end in -er
type Reader interface
type Validator interface
type UserService interface  // OK for larger interfaces

// Getter/Setter: just Name(), not GetName()
func (u *User) Name() string     // Not GetName()
func (u *User) SetName(n string) // Setter needs Set prefix

Comments

// Package-level comments
// Package http provides HTTP client and server implementations.
package http

// Exported function comments start with the function name
// NewServer creates a new HTTP server with the given address.
func NewServer(addr string) *Server

// Good comment: explains WHY
// We retry 3 times because the upstream API has transient failures
for i := 0; i < 3; i++ {

// Bad comment: explains WHAT (obvious from code)
// Increment i by 1
i++

Error Handling

Always Handle Errors

// Bad: ignoring errors
data, _ := json.Marshal(user)

// Good: handle or propagate
data, err := json.Marshal(user)
if err != nil {
    return fmt.Errorf("marshaling user: %w", err)
}

Handle Once

// Bad: handling multiple times
if err != nil {
    log.Printf("error: %v", err)  // Logged here
    return err                      // And caller logs again
}

// Good: handle at the top
func main() {
    if err := run(); err != nil {
        log.Fatal(err)  // Single place
    }
}

func run() error {
    if err := doThing(); err != nil {
        return fmt.Errorf("doing thing: %w", err)  // Just propagate
    }
    return nil
}

Add Context to Errors

// Bad: loses context
return err

// Good: adds context
return fmt.Errorf("reading config file %s: %w", path, err)

Check Specific Errors

// Good: use errors.Is for sentinel errors
if errors.Is(err, os.ErrNotExist) {
    // File doesn't exist
}

// Good: use errors.As for error types
var pathErr *os.PathError
if errors.As(err, &pathErr) {
    log.Printf("path: %s", pathErr.Path)
}

Function Design

Accept Interfaces, Return Structs

// Good: flexible input
func Process(r io.Reader) error

// Good: concrete output
func NewBuffer() *bytes.Buffer

Keep Functions Focused

// Bad: does too much
func ProcessUserAndSendEmail(u *User) error

// Good: single responsibility
func ProcessUser(u *User) error
func SendWelcomeEmail(email string) error

Use Named Parameters for Clarity

// Confusing
createUser(true, false, 100)

// Better: use named types
type CreateUserOptions struct {
    IsAdmin   bool
    SendEmail bool
    Quota     int
}
createUser(CreateUserOptions{IsAdmin: true, Quota: 100})

// Or for simple cases, use named variables
isAdmin := true
sendEmail := false
createUser(isAdmin, sendEmail, 100)

Struct Design

Use Constructors

type Server struct {
    addr    string
    timeout time.Duration
}

// Don't allow direct construction if validation needed
func NewServer(addr string) (*Server, error) {
    if addr == "" {
        return nil, errors.New("addr required")
    }
    return &Server{
        addr:    addr,
        timeout: 30 * time.Second,  // Default
    }, nil
}

Make Zero Values Useful

// Good: zero value is ready to use
type Counter struct {
    mu    sync.Mutex
    count int  // Zero is valid
}

// Good: sync.Mutex zero value is an unlocked mutex
var mu sync.Mutex  // Ready to use

// Good: bytes.Buffer zero value is empty buffer
var buf bytes.Buffer  // Ready to use

Embed for Behavior, Not Just Fields

// Good: embedding adds behavior
type CountingWriter struct {
    io.Writer
    count int
}

// Now CountingWriter has all Writer methods

Concurrency

Use Channels for Communication

// Good: share by communicating
resultChan := make(chan Result)
go func() {
    result := expensiveOperation()
    resultChan <- result
}()
result := <-resultChan

// Avoid: communicating by sharing
var result Result
var done bool
go func() {
    result = expensiveOperation()
    done = true
}()
for !done { /* spin */ }  // Bad!

Always Cancel Contexts

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()  // Always call cancel

Avoid Goroutine Leaks

// Bad: goroutine leaks if nobody receives
func leaky() {
    ch := make(chan int)
    go func() {
        ch <- expensiveOperation()  // Blocked forever
    }()
    return  // Leaked goroutine!
}

// Good: use context for cancellation
func safe(ctx context.Context) {
    ch := make(chan int)
    go func() {
        select {
        case ch <- expensiveOperation():
        case <-ctx.Done():
            return
        }
    }()
}

Use sync.WaitGroup for Goroutine Coordination

var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    go func(item Item) {
        defer wg.Done()
        process(item)
    }(item)
}
wg.Wait()

Testing

Use Table-Driven Tests

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -1, -2},
        {"zero", 0, 0, 0},
    }

    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)
            }
        })
    }
}

Use Interfaces for Testability

// Production
type UserStore interface {
    GetUser(id int) (*User, error)
}

type Service struct {
    store UserStore
}

// Test
type MockStore struct {
    users map[int]*User
}

func (m *MockStore) GetUser(id int) (*User, error) {
    return m.users[id], nil
}

Use t.Helper() for Test Helpers

func assertEqual(t *testing.T, got, want interface{}) {
    t.Helper()  // Points errors to caller, not this function
    if got != want {
        t.Errorf("got %v; want %v", got, want)
    }
}

Performance

Preallocate Slices When Possible

// Bad: grows slice multiple times
var result []int
for i := 0; i < n; i++ {
    result = append(result, i)
}

// Good: preallocate
result := make([]int, 0, n)
for i := 0; i < n; i++ {
    result = append(result, i)
}

Use strings.Builder for Concatenation

// Bad: creates many intermediate strings
s := ""
for _, word := range words {
    s += word + " "
}

// Good: efficient string building
var b strings.Builder
for _, word := range words {
    b.WriteString(word)
    b.WriteString(" ")
}
s := b.String()

Avoid Premature Optimization

// Start simple
func process(items []Item) {
    for _, item := range items {
        handle(item)
    }
}

// Only optimize after profiling shows it's a bottleneck
// go test -bench=. -cpuprofile=cpu.out
// go tool pprof cpu.out

Common Patterns

Functional Options

type Server struct {
    addr    string
    timeout time.Duration
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option {
    return func(s *Server) {
        s.timeout = d
    }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{addr: addr, timeout: 30 * time.Second}
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// Usage
server := NewServer("localhost:8080", WithTimeout(60*time.Second))

Dependency Injection

// Define interfaces where used
type UserService struct {
    db     Database
    cache  Cache
    logger Logger
}

func NewUserService(db Database, cache Cache, logger Logger) *UserService {
    return &UserService{db: db, cache: cache, logger: logger}
}

Graceful Shutdown

func main() {
    server := &http.Server{Addr: ":8080"}

    go func() {
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    // Graceful shutdown with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatal(err)
    }
}

Anti-Patterns to Avoid

Don't Use init() for Complex Logic

// Bad: hidden initialization
func init() {
    db = connectToDatabase()  // What if this fails?
}

// Good: explicit initialization
func main() {
    db, err := connectToDatabase()
    if err != nil {
        log.Fatal(err)
    }
}

Don't Panic in Libraries

// Bad: library panics
func ParseConfig(data []byte) *Config {
    var c Config
    if err := json.Unmarshal(data, &c); err != nil {
        panic(err)  // Caller can't handle this!
    }
    return &c
}

// Good: return error
func ParseConfig(data []byte) (*Config, error) {
    var c Config
    if err := json.Unmarshal(data, &c); err != nil {
        return nil, err
    }
    return &c, nil
}

Don't Overuse Empty Interface

// Bad: loses type safety
func Process(data interface{}) interface{}

// Good: use generics or specific types
func Process[T any](data T) T
func ProcessUser(u *User) *Result

Don't Return Nil Interfaces

// Surprising behavior
type Writer interface {
    Write([]byte) (int, error)
}

func GetWriter() Writer {
    var w *bytes.Buffer  // nil
    return w  // Returns non-nil interface with nil value!
}

w := GetWriter()
if w != nil {  // true!
    w.Write([]byte("oops"))  // panic!
}

// Good: return nil explicitly
func GetWriter() Writer {
    var w *bytes.Buffer
    if w == nil {
        return nil  // Nil interface
    }
    return w
}

Go Proverbs

From Rob Pike's Go Proverbs:

  1. Don't communicate by sharing memory, share memory by communicating
  2. Concurrency is not parallelism
  3. Channels orchestrate; mutexes serialize
  4. The bigger the interface, the weaker the abstraction
  5. Make the zero value useful
  6. A little copying is better than a little dependency
  7. Clear is better than clever
  8. Errors are values
  9. Don't just check errors, handle them gracefully
  10. Documentation is for users

Resources

Where to Go From Here

You have the language. The next step is building something real. A few projects that exercise everything in this tutorial:

  • A CLI tool with subcommands using flag or cobra. Forces you to think about packaging and error messages.
  • A small REST API on net/http with JSON, structured logging via log/slog, and graceful shutdown. Touches concurrency, interfaces, and the standard library at once.
  • A worker pool that fans out jobs to goroutines, collects results through channels, and respects a context for cancellation. Where Go's concurrency model earns its keep.
  • A package others can import, with a clean API, table-driven tests, and at least one Example function. Teaches you what the language wants you to write.

Read code, not just articles. The Go standard library and well-regarded projects (Caddy, Hugo, kubectl) are how idioms travel. When something looks strange, hold it up against gofmt, go vet, and the proverbs in the previous section. They almost always answer the question.