Skip to main content

Introduction

Effective Go provides guidance for writing clear, idiomatic Go code. This document demonstrates best practices and common patterns that have emerged from the Go community and the language designers.
Go is not just about getting the syntax right. It’s about writing code that is clear, idiomatic, and maintainable. This guide will help you write code that feels natural to other Go programmers.

Formatting

Formatting is handled automatically by the gofmt tool (also available as go fmt). This eliminates debates about style and ensures consistency across all Go code.
Always run gofmt on your code before committing. Many editors can be configured to run it automatically on save.
Key formatting conventions:
  • Use tabs for indentation
  • No line length limit (but be reasonable)
  • Less parentheses than C/Java - operator precedence is clearer
  • Comments are formatted by gofmt along with code

Commentary

Go provides C-style block comments /* */ and C++-style line comments //. Line comments are the norm; block comments appear mostly as package comments.

Package Comments

Every package should have a package comment, a block comment preceding the package clause:
// Package regexp implements a simple library for regular expressions.
//
// The syntax of the regular expressions accepted is:
//
//     regexp:
//         concatenation { '|' concatenation }
package regexp

Doc Comments

Every exported name should have a doc comment. The first sentence should be a summary that starts with the name being declared:
// Compile parses a regular expression and returns, if successful,
// a Regexp that can be used to match against text.
func Compile(str string) (*Regexp, error) {
    // implementation
}
Doc comments work best as complete sentences, which allow for a wide variety of automated presentations. The first sentence should be a one-sentence summary that starts with the name being declared.

Names

Names are as important in Go as in any other language. They even have semantic effect: the visibility of a name outside a package is determined by whether its first character is uppercase.

Package Names

1

Short and clear

Package names should be short, concise, and evocative. By convention, packages are given lowercase, single-word names.
2

No underscores

Don’t use underscores or mixedCaps. The package name is part of the type name when using it.
3

Avoid redundancy

Don’t repeat the package name in the exported names. For example, bufio.Reader, not bufio.BufReader.
// Good
bufio.Reader
ring.New

// Bad
bufio.BufReader  // stutters with package name
ring.NewRing     // redundant

Interface Names

By convention, one-method interfaces are named by the method name plus an -er suffix: Reader, Writer, Formatter, CloseNotifier, etc.
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

MixedCaps

The convention in Go is to use MixedCaps or mixedCaps rather than underscores for multi-word names.
// Good
var maxLength int
type ServerHTTP struct{}

// Bad
var max_length int
type Server_HTTP struct{}

Control Structures

If

In Go, a simple if can include an initialization statement:
if err := file.Chmod(0664); err != nil {
    log.Print(err)
    return err
}
Use this form when the variable is only needed within the if statement and its else clause. This keeps the scope narrow and makes the code clearer.

For

Go has only one looping construct: the for loop. It has three forms:
for init; condition; post {
    // loop body
}

Switch

Go’s switch is more flexible than C’s. The expressions need not be constants or even integers:
switch {  // no expression means "true"
case hour < 12:
    return "morning"
case hour < 17:
    return "afternoon"
default:
    return "evening"
}
Unlike C, Go’s switch cases don’t fall through by default. Use fallthrough explicitly if you need that behavior.

Functions

Multiple Return Values

Go’s unusual feature of multiple return values is used extensively, especially for returning both a result and an error:
func ReadFull(r Reader, buf []byte) (n int, err error) {
    for len(buf) > 0 && err == nil {
        var nr int
        nr, err = r.Read(buf)
        n += nr
        buf = buf[nr:]
    }
    return
}

Named Result Parameters

The return parameters of a Go function can be named and used as regular variables. When named, they are initialized to the zero values for their types when the function begins:
func ReadFull(r Reader, buf []byte) (n int, err error) {
    // n and err are initialized to 0 and nil
    // can use naked return
    return
}
Use named result parameters judiciously. They can improve documentation but can also clutter the code if overused. They’re most useful when a function has multiple return values of the same type.

Defer

Go’s defer statement schedules a function call to be run after the function completes:
func CopyFile(dstName, srcName string) (written int64, err error) {
    src, err := os.Open(srcName)
    if err != nil {
        return
    }
    defer src.Close()

    dst, err := os.Create(dstName)
    if err != nil {
        return
    }
    defer dst.Close()

    return io.Copy(dst, src)
}
Deferred functions are executed in LIFO order, which is perfect for cleanup code.

Data

Allocation with new

Go has two allocation primitives: new and make. new(T) allocates zeroed storage for a new item of type T and returns its address:
p := new(SyncedBuffer)  // type *SyncedBuffer

Allocation with make

make(T, args) creates slices, maps, and channels only, and returns an initialized (not zeroed) value of type T (not *T):
v := make([]int, 100)              // slice with len=100, cap=100
m := make(map[string]int)          // map
c := make(chan int, 10)            // buffered channel
Remember: make returns initialized values, while new returns pointers to zeroed values. Use make for slices, maps, and channels; use new for other types when you need a pointer.

Slices

Slices hold references to an underlying array. If you pass a slice to a function, changes to the slice’s elements will be visible to the caller:
func Append(slice, data []byte) []byte {
    // grow slice if necessary
    if len(slice)+len(data) > cap(slice) {
        // allocate new slice
        newSlice := make([]byte, (len(slice)+len(data))*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:len(slice)+len(data)]
    copy(slice[len(slice)-len(data):], data)
    return slice
}
Use the built-in append function instead of writing your own. It’s optimized and handles growth efficiently.

Maps

Maps are a convenient and powerful built-in data structure that associates values of one type (the key) with values of another type (the element or value):
var timeZone = map[string]int{
    "UTC":  0*60*60,
    "EST": -5*60*60,
    "CST": -6*60*60,
    "MST": -7*60*60,
    "PST": -8*60*60,
}
Test for presence with the “comma ok” idiom:
if offset, ok := timeZone["EST"]; ok {
    // offset is valid
}

Initialization

Constants

Constants in Go are just that—constant. They are created at compile time and can only be numbers, characters, strings, or booleans:
const (
    Sunday = iota
    Monday
    Tuesday
    Wednesday
    Thursday
    Friday
    Saturday
)

The init function

Each source file can define its own init function to set up state. init is called after all variable declarations have been evaluated:
func init() {
    // initialization code
}

Methods

Pointers vs. Values

The rule about pointers vs. values for receivers is that value methods can be invoked on pointers and values, but pointer methods can only be invoked on pointers:
type ByteSlice []byte

// Value receiver
func (b ByteSlice) Append(data []byte) []byte {
    // ...
}

// Pointer receiver
func (p *ByteSlice) Write(data []byte) (n int, err error) {
    slice := *p
    slice = append(slice, data...)
    *p = slice
    return len(data), nil
}
Use pointer receivers when the method needs to modify the receiver, when the receiver is a large struct (to avoid copying), or when consistency requires it (if some methods have pointer receivers, all should).

Interfaces and Other Types

Interfaces

Interfaces in Go provide a way to specify the behavior of an object. A type implements an interface by implementing its methods—no explicit declaration required:
type Sequence []int

// Methods required by sort.Interface
func (s Sequence) Len() int {
    return len(s)
}
func (s Sequence) Less(i, j int) bool {
    return s[i] < s[j]
}
func (s Sequence) Swap(i, j int) {
    s[i], s[j] = s[j], s[i]
}

Type Assertions

A type assertion provides access to an interface value’s underlying concrete value:
if str, ok := value.(string); ok {
    // value is a string
}

Type Switches

A type switch is like a regular switch but compares types instead of values:
switch v := value.(type) {
case string:
    // v is a string
case int, int64:
    // v is an int or int64
default:
    // unknown type
}

Concurrency

Share by Communicating

Do not communicate by sharing memory; instead, share memory by communicating. This philosophy is embodied in Go’s channels:
func serve(queue chan *Request) {
    for req := range queue {
        go handle(req)  // handle request concurrently
    }
}

Goroutines

Goroutines are lightweight threads managed by the Go runtime:
go func() {
    // concurrent execution
}()
Goroutines are very cheap. It’s practical to have thousands or even hundreds of thousands of goroutines in a single program.

Channels

Channels provide synchronization and communication:
ci := make(chan int)            // unbuffered channel of integers
cj := make(chan int, 0)         // unbuffered channel of integers
cs := make(chan *os.File, 100)  // buffered channel of pointers to Files
Unbuffered channels combine communication with synchronization, guaranteeing that two goroutines are in a known state.

Errors

Error Handling

Go programs express error conditions with error values:
type error interface {
    Error() string
}
Libraries that return errors should document what error conditions callers should expect:
f, err := os.Open("filename.txt")
if err != nil {
    log.Fatal(err)
}
// use f
f.Close()

Panic

The built-in panic function stops ordinary control flow and begins panicking. Use it for unrecoverable errors:
if user == nil {
    panic("no user specified")
}
Don’t use panic for normal error handling. Use error return values instead. Reserve panic for truly exceptional situations.

Recover

The recover function regains control of a panicking goroutine:
func server(workChan <-chan *Work) {
    for work := range workChan {
        go safelyDo(work)
    }
}

func safelyDo(work *Work) {
    defer func() {
        if err := recover(); err != nil {
            log.Println("work failed:", err)
        }
    }()
    do(work)
}

Best Practices Summary

Format with gofmt

Always run gofmt on your code. Consistent formatting is non-negotiable.

Write clear comments

Every exported name needs a doc comment. Make them complete sentences.

Keep it simple

Go is designed for simplicity. Don’t over-engineer solutions.

Handle errors

Check every error. Don’t ignore error return values.

Use concurrency wisely

Goroutines and channels are powerful, but use them when they add clarity.

Follow conventions

MixedCaps for names, -er suffix for interfaces, short package names.

Language Specification

Complete Go language reference

Memory Model

Understanding concurrency guarantees

GODEBUG

Backwards compatibility settings

Build docs developers (and LLMs) love