Back to blog

Sunday, October 25, 2026

Why Is Writing Good Idiomatic Go Harder Than Writing Go That Compiles?

Title card for 'Why Is Writing Good Idiomatic Go Harder Than Writing Go That Compiles?' with a short code example

Introduction

Writing good idiomatic Go is harder than writing Go that compiles because the compiler checks types, syntax, and a small set of rules, while idiom is a judgement about readability, and that judgement is not in the compiler at all. A program can pass every compile check and still be hard to read, hard to test, and hard to change, because its errors are vague, its interfaces are wider than needed, its names repeat their context, and its normal path is buried inside nested checks. If the learning curve itself is the question, Why Does Learning Go Feel Hard Even Though The Syntax Looks Simple? covers it; this article is about the gap after code compiles.

Before You Start

  • Compilation: the check that source code follows the language grammar, that types line up, and that imports and declared variables are used. Compilation rejects code that breaks those rules. It does not reject code that is confusing, repetitive, or hard to test.
  • Idiom: a shared way of writing code in a language community, so a reader can guess the shape of a solution before reading every line. In Go, idiom includes short package names, small interfaces, early returns for error cases, and error values that carry context.
  • Interface: a type that lists methods, without saying how they work. A value satisfies an interface when it has those methods, with no declaration needed. The size of an interface is the number of methods it lists, and size decides how many types can satisfy it and how large a fake must be in a test.
  • Naming convention: the shared rule for how names are written, including MixedCaps for multiword names, lowercase single word package names, and one method interfaces named with an -er suffix, such as Reader and Writer.
  • Early return: a return at the start of a function for an error case or an empty case, so the rest of the function reads as the normal path, without an else block wrapping it.
  • Error wrapping: adding context to an error as it moves up the call stack, with fmt.Errorf and the %w verb, so the original error stays available for errors.Is and errors.As.
  • Table test: a test that lists cases in a table, usually a slice of structs with input and expected output, then runs each case in a loop, often with a subtest per case, instead of writing a separate test function per case.
  • Package layout: the choice of which files and types belong in which package, and which names are exported, meaning visible outside the package, and which stay unexported, meaning visible only inside it.

Prerequisites

  • Go 1.20 or later, because the article uses errors, fmt, and table tests, all stable in that version.
  • A terminal, and a text editor.
  • No third party packages are required for the complete example.
go version

Tested with: Go 1.22.2, module go 1.20. Verified 2026-10-25: go vet clean, go test passes (3 table cases), go run prints "1 done, 1 pending, 3.5 hours".

Project Layout

idiom-example/
├── go.mod        # module declaration, Go version
├── main.go       # task type, small interface, summary function, main function
└── main_test.go  # table test for the summary function
  • go.mod: declares the module and the minimum Go version.
  • main.go: holds the idiomatic version of the small program, so the naming, interface, early return, and error choices can be read in one place.
  • main_test.go: holds the table test, so the behaviour of the summary function is checked case by case.

What The Compiler Checks, And What It Does Not

The compiler is a narrow judge. It rejects a type mismatch, a missing import, an unused import, and an unused local variable. It accepts everything else that follows the grammar, including a function with deep nesting, a ten method interface for a one method need, a name that repeats its package, and an error message that says nothing about where it came from.

That narrowness is the whole difficulty. Idiom is not a second compiler pass. It is a set of expectations a reader brings to the code, built from the standard library, from Effective Go, and from the Go project's Code Review Comments. A reader who knows those expectations can read a new package fast, because names, error handling, and interface shapes follow familiar patterns. A reader who meets a different shape in every file must slow down and work out what each shape means, even when every shape compiles.

Two warnings belong with those sources, stated openly. Effective Go was written for the Go release in 2009, and its own note says it is not actively updated, and it does not cover generics, modules, or libraries added since. The Code Review Comments page is a wiki page for the Go project, edited in December 2023, not a language specification. Both are guidance for writing code other Go programmers can read, not a rulebook that decides whether code is correct. Teams differ on details, and that difference is part of the work: idiom is a conversation with readers, not a flag the compiler sets.

Naming: The First Readability Choice

Naming is the first idiom a reader meets, because names are read before logic. Effective Go gives three naming rules that show up in almost every package, and each rule has a reason tied to how a name is seen at the call site.

First, package names are lowercase, single word names, with no underscores or MixedCaps. The package name becomes the accessor for its contents, so bytes.Buffer reads as a buffer from the bytes package, and a long or stuttering package name is typed at every call site.

Second, exported names in a package should not repeat the package name, because the caller already sees it. Effective Go gives the buffered reader as the example: it is called Reader, not BufReader, because users see it as bufio.Reader. The same rule gives ring.New instead of NewRing, because ring is the only type the package exports, and clients see ring.New.

Third, multiword names use MixedCaps, not underscores, and initialisms keep a consistent case. That gives URLPony or urlPony, never UrlPony, and ServeHTTP, never ServeHttp. A getter for a field called owner is called Owner, not GetOwner, because the upper case first letter already marks it as exported, and the caller sees obj.Owner().

A before and after pair for naming, using the Effective Go examples, shows how little code changes and how much reading changes:

// Before: the package name is repeated, and the acronym case is inconsistent.
package tasklist
func NewTaskList() *TaskList { return &TaskList{} }
func (t *TaskList) GetUrl() string { return t.url }

// After: the package name carries the context, and the acronym keeps its case.
package tasklist
func New() *TaskList { return &TaskList{} }
func (t *TaskList) URL() string { return t.url }

The before version compiles. The after version reads better at the call site, as tasklist.New() and list.URL(), because the caller does not read the same word twice and does not meet two spellings of one acronym.

Early Returns And Error Handling

Error handling is where non-idiomatic Go most often hides the normal path. The compiler accepts a function that nests the normal path inside an else block, returns a plain string for an empty case, and discards an error, because none of those choices break a type rule. Effective Go gives the idiomatic shape directly: when an if block ends in a return, the unnecessary else is omitted, and the successful flow runs down the page, with error cases eliminated as they arise.

A before and after pair for the same small problem, summarising a list of tasks, shows the difference. The before version returns a sentence for the empty case, so a caller cannot tell an empty result from a real summary, and it wraps the whole normal path in an else block:

func summary(tasks []Task) string {
    if len(tasks) == 0 {
        return "No tasks."
    } else {
        total := 0.0
        done := 0
        for i := 0; i < len(tasks); i++ {
            if tasks[i].Done == true {
                done = done + 1
            }
            total = total + tasks[i].Hours
        }
        return fmt.Sprintf("%d done, %d pending, %.1f hours", done, len(tasks)-done, total)
    }
}

The after version returns an error for the empty case, returns early, ranges over the slice, and drops the redundant comparison and the else:

func summarize(src TaskSource) (string, error) {
    tasks := src.Tasks()
    if len(tasks) == 0 {
        return "", errors.New("no tasks to summarize")
    }
    var done int
    var hours float64
    for _, task := range tasks {
        hours += task.Hours
        if task.Done {
            done++
        }
    }
    pending := len(tasks) - done
    return fmt.Sprintf("%d done, %d pending, %.1f hours", done, pending, hours), nil
}

Three idiom choices are visible in the after version, and none of them is a compile rule. The empty case is an error value, not a sentence, so a caller can check it. The error string is lowercase, with no trailing punctuation, following the error string convention, and it names the problem, not the caller. The normal path is not nested, so a reader sees the counting loop first, not the empty case.

Error wrapping follows the same logic one level up. When a function calls another function that fails, returning the bare error loses the context of what was being done. Wrapping with fmt.Errorf and %w keeps the original error for inspection, while adding the operation that failed:

data, err := os.ReadFile(name)
if err != nil {
    return nil, fmt.Errorf("reading task file: %w", err)
}

Effective Go gives the same reason for detailed errors in the standard library: an error that includes the operation and the file name is useful even when printed far from the call that caused it, while a bare system error is not.

Interface Size And Placement

Interfaces are where a small decision has a large cost, because an interface decides what a caller must provide and what a test must fake. The compiler accepts a ten method interface for a function that calls one method, because satisfaction is a type rule, and a type with ten methods satisfies it. The cost shows up later: every fake in a test must implement ten methods, and every new caller must find a type with all ten, even when the function needs one.

The idiomatic shape is the opposite: a small interface, defined where it is used, listing only the methods the function calls. The site docs give this shape its own chapter, and the standard library gives the model: io.Reader and io.Writer each have one method, and a function that reads can accept any type that reads, from a file to a buffer in a test.

A before and after pair for the same summary function shows the cost:

// Before: the function needs one method, but the interface demands six.
type TaskStore interface {
    Tasks() ([]Task, error)
    SaveTask(Task) error
    DeleteTask(string) error
    UpdateTask(Task) error
    ListDone() ([]Task, error)
    ListPending() ([]Task, error)
}

func summarize(store TaskStore) (string, error) {
    tasks, err := store.Tasks()
    // ...
}

// After: the interface lists only what the function calls, where it is used.
type TaskSource interface {
    Tasks() []Task
}

func summarize(src TaskSource) (string, error) {
    tasks := src.Tasks()
    // ...
}

The after version also shows the accept interfaces, return structs shape. The function accepts an interface, so a caller can pass a slice type, a database type, or a test fake, and it returns a plain string and an error, not an interface, so the caller sees the full result type. Returning an interface would hide methods and force the caller to assert a type to reach them. The one exception in the standard library is error itself, a one method interface, returned because the concrete error type varies by call.

Placement matters as much as size. An interface defined in the provider package, listing what the provider happens to offer, forces every consumer to fit the provider's shape. An interface defined in the consumer package, listing what the consumer needs, lets the provider satisfy it without change. That is why the small interface in the complete example lives in the same package as the function that uses it, not in a separate store package.

Package Layout And Testing Habits

Package layout is idiom at file level. The compiler accepts a single package with every type exported, because export is a capital letter, not a design review. The reader pays for that shape: a public API with no boundary, where every helper is visible and every change is a breaking change.

The idiomatic shape is a small exported surface and a larger unexported interior. Exported names are the promise to callers, and unexported names are free to change. A package that exports only the types and functions callers need can refactor its helpers without breaking callers, and a reader can learn the package from its exported names first, then read the helpers as needed.

Testing habits follow the same logic. A test that calls a function with a concrete database type needs a database. A test that calls a function with a small interface needs a fake with one method, or a slice type with one method, as in the complete example. A table test then lists cases, input and expected output, and runs each case as a subtest, so adding a case is adding a row, not writing a new test function. The compiler does not require table tests, and a separate test function per case compiles and passes, but a table keeps the cases visible side by side, which is where a missing case is noticed.

Design Decisions And Tradeoffs

OptionUse whenCost
Small interface at the point of useA function needs one or two methods from a callerThe same behaviour may get a new small interface per consumer, instead of one shared interface
Wide shared interfaceMany consumers genuinely need the same set of methodsEvery fake and every implementation must carry every method, even for consumers that use one
Accept interfaces, return structsA function should accept many inputs and give a full resultThe caller must narrow a struct to an interface at the point of use, when only one method is needed
Return an interfaceThe concrete type is private or varies by construction, as with errorThe caller loses access to methods outside the interface, and widening the interface later breaks implementations
Early returns and guard clausesError cases and empty cases should be eliminated before the normal pathA function with many guards can read as a list of exits, and the order of guards becomes part of the logic
Nested checks with else blocksThe branches are genuinely parallel, with no normal pathThe normal path is indented and hard to find, and each new case adds a level of nesting
Short names at the call siteThe package name already gives context, as in bufio.ReaderA name that is too short in isolation can confuse a reader who meets it without its package
Long descriptive namesThe context is not clear from the package or the call siteThe same word is repeated at every call site, and the name hides the structure of the call
Table testsCases share the same setup and the same assertionA table with many fields can hide what differs between cases, and a shared setup can leak state between cases
Separate test function per caseCases need very different setup or assertionsCases are scattered, and a missing case is harder to notice than a missing row in a table

A design in this article has a deliberate compromise, stated openly. The complete example is a single main package, not a library package with a separate store package, so the interface, naming, and early return choices can be read without a module layout discussion. In a real service, the small interface would live in the consumer package, the store would live in its own package, and the summary function would accept the interface, not the store. The single package here is a teaching compromise, not a production layout. The idiom rules themselves are not compromised: the interface stays small, the error stays an error value, and the normal path stays unnested.

Complete Example

The complete example is the idiomatic version of the task summary program, as full files. A task has a title, a done flag, and hours. A small interface lists one method, returning tasks. A slice type implements that method, so both the main function and the tests can pass a plain list. The summary function returns an error for the empty case, counts done tasks and hours, and formats one line.

// go.mod
module idiomexample

go 1.20
// main.go
package main

import (
    "errors"
    "fmt"
)

// Task is one item of work, with a title, a done flag, and hours spent.
type Task struct {
    Title string
    Done  bool
    Hours float64
}

// TaskSource is the one method the summary function needs: a list of tasks.
type TaskSource interface {
    Tasks() []Task
}

// taskList is a slice of tasks that satisfies TaskSource.
type taskList []Task

// Tasks returns the tasks in the list.
func (t taskList) Tasks() []Task {
    return t
}

// summarize counts done tasks and hours, and formats one summary line.
// It returns an error when there are no tasks to summarize.
func summarize(src TaskSource) (string, error) {
    tasks := src.Tasks()
    if len(tasks) == 0 {
        return "", errors.New("no tasks to summarize")
    }
    var done int
    var hours float64
    for _, task := range tasks {
        hours += task.Hours
        if task.Done {
            done++
        }
    }
    pending := len(tasks) - done
    return fmt.Sprintf("%d done, %d pending, %.1f hours", done, pending, hours), nil
}

func main() {
    tasks := taskList{
        {Title: "Write draft", Done: true, Hours: 2.5},
        {Title: "Review draft", Done: false, Hours: 1.0},
    }
    summary, err := summarize(tasks)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(summary)
}
// main_test.go
package main

import "testing"

func TestSummarize(t *testing.T) {
    tests := []struct {
        name    string
        tasks   taskList
        want    string
        wantErr bool
    }{
        {
            name:    "empty list is an error",
            tasks:   taskList{},
            want:    "",
            wantErr: true,
        },
        {
            name: "mixed done and pending",
            tasks: taskList{
                {Title: "Write draft", Done: true, Hours: 2.5},
                {Title: "Review draft", Done: false, Hours: 1.0},
            },
            want:    "1 done, 1 pending, 3.5 hours",
            wantErr: false,
        },
        {
            name: "all done",
            tasks: taskList{
                {Title: "Write draft", Done: true, Hours: 1.0},
                {Title: "Review draft", Done: true, Hours: 2.0},
            },
            want:    "2 done, 0 pending, 3.0 hours",
            wantErr: false,
        },
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got, err := summarize(tc.tasks)
            if tc.wantErr && err == nil {
                t.Fatal("summarize() error = nil, want an error")
            }
            if !tc.wantErr && err != nil {
                t.Fatalf("summarize() error = %v, want nil", err)
            }
            if got != tc.want {
                t.Errorf("summarize() = %q, want %q", got, tc.want)
            }
        })
    }
}

Commands to run it, from the idiom-example folder:

go run .
go vet ./...
go test ./...

Expected output for the program:

1 done, 1 pending, 3.5 hours

Expected output for the tests is a pass for every case, including the empty list case, because the empty case returns an error value, not a sentence, and the test checks for that error.

Why this version is idiomatic, in order:

  • The package name is not repeated in the constructor or the method names, because the caller already sees the package.
  • The interface lists one method, defined where it is used, so a test can pass a slice type with one method, instead of a store with six.
  • The empty case returns early with an error value, so the counting loop is the normal path, not a nested branch.
  • The error string is lowercase, with no trailing punctuation, and it names the problem.
  • The test is a table, so the three cases sit side by side, and a fourth case is a new row.

Testing

Test idiom the same way as code idiom: by what a reader can see. A table test makes the cases visible, and a subtest per case makes a failure name the case that failed, instead of a line number in a long test function. The complete example uses both: a slice of structs with a name, input, expected output, and an error flag, and a t.Run call per case.

Two testing choices in the example deserve a note, because they follow from the interface choice. The summary function accepts a TaskSource interface, so the test passes a slice type, not a database, and not a hand written fake with six methods. The empty case is tested as an error, because the function returns an error for it, and a test that checked for a sentence would lock in the non-idiomatic shape.

Commands for the checks, repeated here for the testing section:

go vet ./...
go test ./...

go vet reports suspicious constructs. It does not check naming, interface size, or table layout, so a clean vet run is not proof that code is idiomatic. The table test, and a read of the exported names, are the checks for those choices.

Common Mistakes

  • Returning a sentence instead of an error: an empty case that returns a string like "No tasks." looks like a result, and a caller cannot check it as an error. The fix is to return an error value for the failure case, and a result only for success.
  • Keeping an else after a return: an else block after a return wraps the normal path in a branch for no reason, and each new case adds a level of nesting. The fix is an early return for the error case, then the normal path at the top level of the function.
  • Comparing a boolean with == true: a condition like if task.Done == true repeats what the boolean already says, and it hides the real condition. The fix is if task.Done, with the negation as if !task.Done when needed.
  • Repeating the package name in exported names: a type called TaskList in a package called tasklist, with a constructor called NewTaskList, makes the caller read the same word three times. The fix is to let the package name carry the context, as in tasklist.New().
  • Breaking acronym case: a name like UrlPony or ServeHttp gives one acronym two spellings across a codebase, and a reader must guess which spelling a new name uses. The fix is a consistent case for initialisms, as in URLPony and ServeHTTP.
  • Giving a getter a Get prefix for a field: a method called GetOwner for a field called owner repeats the access idea, when the upper case first letter already marks it as exported. The fix, from Effective Go, is Owner, with SetOwner for the setter when one is needed.
  • Defining a wide interface for a narrow need: an interface with six methods for a function that calls one forces every fake to implement six methods, and every caller to find a type with all six. The fix is a small interface at the point of use, listing only the methods the function calls.
  • Returning an interface when a struct would do: a constructor that returns an interface hides the concrete type's methods, and widening that interface later breaks every other implementation. The fix is to return the struct, and let the caller narrow it to an interface when only one method is needed, except for cases like error, where the concrete type varies.
  • Exporting every helper: a package that exports its helpers makes every helper part of the public promise, so refactoring a helper becomes a breaking change. The fix is a small exported surface, with helpers unexported, so only the promised names are stable.
  • Writing a separate test function per case: a set of near identical test functions hides the cases from each other, and a missing case is hard to notice. The fix is a table test with a subtest per case, so the cases sit side by side and a new case is a new row.

Production Notes

  • Review: idiom is checked in review, not by the compiler, so a team needs a shared note of its choices, not a hope that new code will match old code by accident. A short list, package names, interface size, error strings, and table tests, covers most of the ground in this article.
  • Refactoring: a small exported surface makes refactoring safer, because unexported helpers can change without breaking callers. A wide interface has the opposite effect: changing it breaks every implementation, so it resists change.
  • Onboarding: a new reader learns a codebase from its exported names and its test tables first. Short package names, small interfaces, and table tests shorten that first read, while stuttering names and wide interfaces lengthen it.
  • Tooling: gofmt settles formatting, and go vet catches suspicious constructs, but neither settles naming or interface size. Linters can flag some naming and complexity patterns, but a linter flag is a prompt for a reader's judgement, not a replacement for it.
  • Deployment: the example needs Go 1.20 or later. Pin that version in go.mod, run go vet ./... and go test ./... in continuous integration, and do not deploy a change that fails the table test. A vet pass alone is not enough for idiom, because vet does not check the choices in this article.

Decision Guide

  • When a function needs one or two methods: define a small interface at the point of use, and accept it as a parameter.
  • When many consumers need the same set of methods: define a shared interface, and check that every method is genuinely needed by every consumer, not by one.
  • When a constructor creates a type: return the struct, unless the concrete type is private or varies by construction, as with error.
  • When a function can fail: return an error value, with context added by wrapping, not a sentence, and not a bare error that hides what was being done.
  • When an error case or empty case comes first: return early, and let the normal path run at the top level of the function, without an else block.
  • When a name repeats its package: shorten the name, and let the package name carry the context, as in bufio.Reader and ring.New.
  • When a name contains an acronym: keep the acronym case consistent across the codebase, as in URL and ServeHTTP.
  • When cases share setup and assertions: write a table test, with a subtest per case, so the cases sit side by side.
  • When a helper is only used inside a package: keep it unexported, and export only the names callers need as a stable promise.

Conclusion

The compiler is a necessary check, and it is not a sufficient one. It accepts code that is hard to read, hard to test, and hard to change, because readability, testability, and changeability are not type rules. Idiom fills that gap, with choices a reader can learn once and recognise everywhere: short package names, small interfaces at the point of use, early returns, error values with context, a small exported surface, and table tests.

Summary Of Key Points

  • Compilation is narrow: the compiler checks types, syntax, and use of imports and variables, not readability, interface size, or naming.
  • Naming is read first: package names stay short and lowercase, exported names do not repeat the package, and acronyms keep a consistent case.
  • Early returns clear the normal path: error cases and empty cases return first, so the normal path runs at the top level, without an else block.
  • Errors are values, not sentences: a failure case returns an error value, with context added by wrapping, so a caller can check it and a reader can trace it.
  • Interfaces stay small and local: a function accepts the smallest interface it needs, defined where it is used, and returns a struct, not an interface, except when the concrete type varies.
  • Tests show the cases: a table test with a subtest per case makes cases visible side by side, and a small interface makes a fake cheap to write.

Follow those choices, and code that compiles becomes code that reads: a new reader can guess the shape of a package before reading every line, and a change to one package does not force a change in every caller.

Additional Resources

  • Go Documentation: Effective Go - naming, formatting, control structures, interfaces, and errors, with the note that it was written for Go in 2009 and is not actively updated
  • Go Project Wiki: Code Review Comments - review guidance for the Go project, including MixedCaps, naming, and error string conventions
  • Go Documentation: The Go Programming Language Specification - the grammar and type rules the compiler checks
  • Go Packages: errors package - New, Is, As, Join, and Unwrap for error values
  • Go Packages: fmt package - Errorf and the %w verb for wrapping errors with context

References

  1. Effective Go. Go team, Go Documentation. https://go.dev/doc/effective_go. Accessed 2026-10-25.
  2. Code Review Comments. Go project wiki, edited December 2023. https://github.com/golang/go/wiki/CodeReviewComments. Accessed 2026-10-25.
  3. The Go Programming Language Specification. Go team, Go Documentation. https://go.dev/ref/spec. Accessed 2026-10-25.
  4. errors package. Go team, Go Packages. https://pkg.go.dev/errors. Accessed 2026-10-25.
  5. fmt package. Go team, Go Packages. https://pkg.go.dev/fmt. Accessed 2026-10-25.