Skip to content
gohacks.dev
Basics

What Idiomatic Go Actually Means in Day to Day Practice

Idiomatic Go is code that does what the language and standard library expect. See one function written both ways and what each habit buys.

6 min read
Summarize in AI
Flat illustration of an editor window full of code.

"Write idiomatic Go" is the review comment that is easy to give and hard to act on. It sounds like an aesthetic, and it is mostly not. The language and its tools make assumptions: that errors are values you check, that a zero value is usable, that interfaces are small, that gofmt decides layout. Code that follows those assumptions is easier to read, to test and to run than code that fights them. This article takes one small function written the way someone coming from another language might write it, rewrites it, and measures what each change bought. It is a tour of habits more than a rule set, and the habits link to the articles that go deeper. It belongs in the Go basics path and reads best after functions and interfaces.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The same job, written twice

The job: given a list of users, return the names of the active adults as one comma-separated string, or an error if the list is empty. First the version that comes from other languages:

main.goGo
package main

import (
	"errors"
	"fmt"
)

type User struct {
	name   string
	age    int
	active bool
}

func (u *User) GetName() string { return u.name }
func (u *User) GetAge() int     { return u.age }
func (u *User) IsActive() bool  { return u.active }

type UserManager struct {
	users []*User
}

func NewUserManager(users []*User) *UserManager {
	m := new(UserManager)
	m.users = users
	return m
}

func (m *UserManager) ActiveAdultNames() (string, error) {
	result := ""
	if len(m.users) > 0 {
		for i := 0; i < len(m.users); i++ {
			u := m.users[i]
			if u.IsActive() == true {
				if u.GetAge() >= 18 {
					if result != "" {
						result = result + ", " + u.GetName()
					} else {
						result = u.GetName()
					}
				}
			}
		}
		return result, nil
	} else {
		return "", errors.New("No users")
	}
}

func main() {
	m := NewUserManager([]*User{{"ada", 36, true}, {"kid", 9, true}, {"grace", 45, false}, {"linus", 28, true}})
	fmt.Println(m.ActiveAdultNames())
	fmt.Println(NewUserManager(nil).ActiveAdultNames())
}
Text
ada, linus <nil>
 No users

It works, and every line of it will draw a comment from an experienced reviewer. Here is the version I would write:

main.goGo
package main

import (
	"errors"
	"fmt"
	"strings"
)

type User struct {
	Name   string
	Age    int
	Active bool
}

var ErrNoUsers = errors.New("no users")

func ActiveAdultNames(users []User) (string, error) {
	if len(users) == 0 {
		return "", ErrNoUsers
	}
	var names []string
	for _, u := range users {
		if u.Active && u.Age >= 18 {
			names = append(names, u.Name)
		}
	}
	return strings.Join(names, ", "), nil
}

func main() {
	users := []User{{"ada", 36, true}, {"kid", 9, true}, {"grace", 45, false}, {"linus", 28, true}}
	fmt.Println(ActiveAdultNames(users))
	fmt.Println(ActiveAdultNames(nil))
}
Text
ada, linus <nil>
 no users

Both programs print ada, linus <nil> and then the empty-list error. The second is about half the length, and each difference is a habit with a reason.

What each change buys

A function instead of a class. UserManager held a slice and had one method. In Go a type with one job and no state to protect is a function, and ActiveAdultNames(users) is easier to call and to test than constructing a manager first. A struct earns its existence by holding state that several methods share.

Exported fields, no getters. GetName() returns a field and adds nothing. Plain fields are simpler, and if you later need logic you add a method then, since callers of a field and of a method look the same only in other languages. The convention when a getter is justified is Name(), not GetName(), as the naming conventions table says.

Early return for the error. The first version nested the whole body inside if len > 0 and put the error in an else after a return. The idiom is to handle the exceptional case first and return, which keeps the happy path at the left margin and makes it obvious that nothing runs after the return. The linter flags else after return for the same reason.

A sentinel error, lowercase. errors.New("No users") has a capital and no way for a caller to recognize it. ErrNoUsers is a package variable that callers compare with errors.Is, and the message is lowercase, because errors are joined into longer messages, as error message style explains.

range, not an index loop. for _, u := range users has no off-by-one risk, no m.users[i] repeated, and in Go 1.22 and later each iteration has its own u. Boolean comparisons like == true disappear, and the nested ifs collapse into one condition with &&.

strings.Join, not string building by hand. The first version concatenates a string in a loop, which creates a new string on every iteration, as strings are bytes measured. The idiomatic version collects the pieces and joins them once. Measured on 100 active adults:

main_test.goGo
package main

import (
	"fmt"
	"strings"
	"testing"
)

var sink string

func TestAllocs(t *testing.T) {
	names := make([]string, 100)
	for i := range names {
		names[i] = fmt.Sprintf("user%03d", i)
	}

	concat := testing.AllocsPerRun(20, func() {
		result := ""
		for _, n := range names {
			if result != "" {
				result = result + ", " + n
			} else {
				result = n
			}
		}
		sink = result
	})

	join := testing.AllocsPerRun(20, func() {
		var out []string
		for _, n := range names {
			out = append(out, n)
		}
		sink = strings.Join(out, ", ")
	})

	preallocated := testing.AllocsPerRun(20, func() {
		out := make([]string, 0, len(names))
		for _, n := range names {
			out = append(out, n)
		}
		sink = strings.Join(out, ", ")
	})

	fmt.Println("concatenation:", concat)
	fmt.Println("append and Join:", join)
	fmt.Println("preallocated and Join:", preallocated)
}
Text
=== RUN   TestAllocs
concatenation: 99
append and Join: 7
preallocated and Join: 2
--- PASS: TestAllocs (0.00s)
PASS
ok  	example.test/lab	0.255s

The numbers are the argument. Concatenating in the loop allocated 99 times for 100 names, once per name after the first. Collecting and joining took 7, from the slice growing as it filled, and preallocating the slice took 2. The function above does not preallocate, since the filter makes the final size unknown, and the extra allocations are the price of that simplicity. The difference is not noticeable for ten users and it is for ten thousand per request.

The habits as a list

None of these is a rule to apply mechanically. They are the shapes that the compiler, the linters and other people's expectations are tuned for:

HabitWhat it buys
gofmt, alwaysNo style arguments and clean diffs
Return earlyFlat code, visible exits
Errors as values, checked on the next lineNo hidden control flow
Small interfaces at the consumerEasy fakes, fewer cycles
Accept interfaces, return structsCallers keep every method
Useful zero valuesNo constructor needed
Plain fields until a method is neededLess code to read
defer for cleanupCleanup survives every exit
context.Context firstCancellation plumbing that composes
Short names in short scopes, clear names in long onesReadable at both ends

Log or return, never both

One more habit shows up in reviews of error-heavy code. When a function hits an error, it either handles it, by retrying or falling back, or returns it to the caller, adding context. It does not log the error and then also return it. If every layer logs and returns, the same failure appears five times in the logs with five stack depths, and nobody can tell how many things went wrong. The idiom is to return the error with fmt.Errorf("load config: %w", err) at each layer, and to log it once at the top, in main or the request handler, where the whole chain is visible. Logging errors once goes through the pattern and the exceptions, and the wrapping side is in wrapping errors with percent w.

When to break an idiom

Idioms are defaults. A struct with a constructor is right when the zero value is not usable. A getter is right when the field is computed or must stay private. A class-shaped type with several methods sharing state, such as a connection pool, is not "un-Go". An else after a return is fine in a one-line function where it reads better. The test is whether the departure costs the reader something: if you can say why in a comment, do, and if the reason is "I am used to it", reconsider. The standard library is the best reference for what is normal, and reading it, as in how to read Go code quickly, teaches more than any style guide. The official statements are Effective Go and the Go Code Review Comments, and they are worth a reread every year.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.