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.

"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:
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())
}ada, linus <nil>
No usersIt works, and every line of it will draw a comment from an experienced reviewer. Here is the version I would write:
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))
}ada, linus <nil>
no usersBoth 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:
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)
}=== RUN TestAllocs
concatenation: 99
append and Join: 7
preallocated and Join: 2
--- PASS: TestAllocs (0.00s)
PASS
ok example.test/lab 0.255sThe 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:
| Habit | What it buys |
|---|---|
gofmt, always | No style arguments and clean diffs |
| Return early | Flat code, visible exits |
| Errors as values, checked on the next line | No hidden control flow |
| Small interfaces at the consumer | Easy fakes, fewer cycles |
| Accept interfaces, return structs | Callers keep every method |
| Useful zero values | No constructor needed |
| Plain fields until a method is needed | Less code to read |
defer for cleanup | Cleanup survives every exit |
context.Context first | Cancellation plumbing that composes |
| Short names in short scopes, clear names in long ones | Readable 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.


