Effective Go explained: how to write idiomatic Go without overthinking it

The principles of Effective Go translated into modern, practical examples. How to write idiomatic Go for real backend work.

Cover for Effective Go explained: how to write idiomatic Go without overthinking it

Effective Go was published in 2009. Parts of the document have aged, there are sections that assume a world without modules, without generics, without context.Context. But I think the philosophy it conveys is still the best guide for writing Go that doesn’t fight against the language. The problem, at least as I see it, is that many people read it as a technical reference when it’s actually a design manifesto: it tells you what style of code Go tries to avoid and why.

I’ve reread it several times over the years. And it’s interesting, because every time I do, I understand something that previously seemed arbitrary to me. This article is my translation of those principles into modern examples, applied to the kind of backend I write today. It’s not a literal summary of the document. It’s what sticks with me after applying it in real projects, with the successes and mistakes that implies.


Formatting: go fmt is not optional

The first section of Effective Go talks about formatting. And the conclusion is radical: there’s no style debate in Go. go fmt decides, you accept. Tabs, not spaces. Braces on the same line. End of discussion.

This seems trivial, and at first I thought the same. But I think it’s one of the smartest design decisions in the language. In other ecosystems you spend hours configuring linters, debating whether imports are sorted by type or by length, whether functions have a blank line after { or not. In Go, that doesn’t exist. All the code in the world has the same format.

// Before go fmt (this doesn't compile, but as a visual example)
func   handler(w http.ResponseWriter,r *http.Request){
    if r.Method!="POST" {
    http.Error(w,"method not allowed",405)
    return
    }
    // ...
}

// After go fmt
func handler(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		http.Error(w, "method not allowed", 405)
		return
	}
	// ...
}

The real benefit isn’t aesthetic. It’s that any diff in a PR shows only logic changes, never formatting changes. Code reviews go faster. Merges have fewer conflicts. And new developers don’t need to read a 40-page style document.

My rule: run gofmt or goimports on save. Configure it in your editor and forget about it. If someone on the team proposes “their own style”, the answer is always the same: no.


Naming: short names, meaningful packages, zero getters

Naming in Go is radically different from Java or C#. If you come from those worlds, short names are going to seem cryptic at first. It happened to me too. But they have a reason that you understand over time.

Short variables in short scopes

In Go, a variable that lives three lines doesn’t need to be called userAccountBalance. You name it b and everyone understands it in context.

// This is idiomatic Go
for i, v := range items {
	if v.IsActive() {
		process(v)
	}
}

// This is NOT idiomatic Go
for index, currentItem := range itemsList {
	if currentItem.IsActive() {
		processItem(currentItem)
	}
}

The rule is simple: the shorter the scope, the shorter the name can be. A function parameter used once can be r for a *http.Request. A struct field that’s exported and lives forever needs a complete, descriptive name.

Package names

The package name is part of the identifier when you use it from outside. That’s why http.Server is better than httpserver.HTTPServer. And json.Marshal is better than jsonutil.MarshalJSON.

// Good: the package gives context
user.New("roger", "roger@example.com")
order.Create(cart)

// Bad: redundancy between package and function
user.NewUser("roger", "roger@example.com")
order.CreateOrder(cart)

No getters

Go doesn’t use getters with a Get prefix. If you have a field name, the getter is called Name(), not GetName(). The setter does use the prefix: SetName().

type Config struct {
	timeout time.Duration
}

// Getter: no "Get"
func (c *Config) Timeout() time.Duration {
	return c.timeout
}

// Setter: with "Set"
func (c *Config) SetTimeout(d time.Duration) {
	c.timeout = d
}

This isn’t a quirk. It’s that config.Timeout() reads like natural English. config.GetTimeout() adds noise without adding information.


Control flow: early returns, avoid nesting

Effective Go insists on a pattern that, at least in my experience, makes the difference between readable Go code and Go code that looks like it was written in Java: the early return.

The idea, which sounds obvious but is hard to internalize, is that the main path (happy path) of the code should be at the lowest possible indentation level. Errors, validations, and special cases are handled first and exit the function as soon as possible.

// BAD: unnecessary nesting
func processOrder(o *Order) error {
	if o != nil {
		if o.IsValid() {
			if o.HasStock() {
				// main logic here, 3 levels of indentation
				return nil
			} else {
				return fmt.Errorf("no stock for order %s", o.ID)
			}
		} else {
			return fmt.Errorf("invalid order %s", o.ID)
		}
	} else {
		return errors.New("order is nil")
	}
}

// GOOD: early return, happy path on the left
func processOrder(o *Order) error {
	if o == nil {
		return errors.New("order is nil")
	}
	if !o.IsValid() {
		return fmt.Errorf("invalid order %s", o.ID)
	}
	if !o.HasStock() {
		return fmt.Errorf("no stock for order %s", o.ID)
	}

	// main logic here, no nesting
	return nil
}

The early return version is longer in lines but much easier to read. You don’t have to do a mental exercise of “what level of if am I on?”. Each error condition is isolated, easy to find and easy to modify.

I apply this almost religiously, I’ll admit. If I see an else after a return, I remove it. If I see more than two levels of indentation, I look for a way to flatten it. Sometimes I go too far with the purism, but in general I think it’s worth it.


Error handling: check immediately, wrap with context

And here’s the elephant in the room. Error handling in Go is the part that generates the most complaints, and honestly, there’s some truth to that. The famous if err != nil that repeats endlessly.

But Effective Go frames it in a way that, over time, has convinced me: errors are values. They’re returned, checked, propagated. Not ignored.

// The basic pattern
result, err := doSomething()
if err != nil {
	return fmt.Errorf("doing something: %w", err)
}

What Effective Go doesn’t cover (because it was written before Go 1.13) is error wrapping with %w. Today it’s essential. Every time you propagate an error, you add context about where it occurred.

func GetUser(ctx context.Context, id string) (*User, error) {
	row := db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)

	var u User
	if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, fmt.Errorf("user %s not found: %w", id, ErrNotFound)
		}
		return nil, fmt.Errorf("scanning user %s: %w", id, err)
	}

	return &u, nil
}

Three rules I always try to follow (though I don’t always get it right on the first try):

  1. Check the error immediately after the call. Don’t do three operations and then check. Every call that can fail is checked on the next line.
  2. Wrap with context. "scanning user" is infinitely more useful in a log than a bare sql: no rows. It saves hours of debugging.
  3. Don’t use _ to ignore errors unless you have a documented reason. If you think an error can’t happen, think again.

If you want to go deeper into error patterns, I have a complete article on Go error handling with sentinel errors, custom types, and errors.Is/errors.As.


Interfaces: small, defined in the consumer

Interfaces in Go are implicit. You don’t need to declare implements. If your type has the methods the interface requires, it implements it. Period.

Effective Go recommends small interfaces. And over time, I think this is one of the most powerful ideas in the language. An interface with a single method is perfectly normal in Go. And an interface with ten methods is almost always a sign that something’s wrong.

// Standard library interface: a single method
type Reader interface {
	Read(p []byte) (n int, err error)
}

// Your domain interface: also a single method
type UserRepository interface {
	FindByID(ctx context.Context, id string) (*User, error)
}

Define them where they’re consumed, not where they’re implemented

This is the principle that’s hardest to internalize if you come from Java. In Java, you define the interface alongside the implementation. In Go, you define the interface where you need it.

// In the "order" package (consumer)
package order

type PaymentProcessor interface {
	Charge(ctx context.Context, amount int64, currency string) error
}

type Service struct {
	payments PaymentProcessor
}

func NewService(p PaymentProcessor) *Service {
	return &Service{payments: p}
}
// In the "stripe" package (implementation)
package stripe

type Client struct {
	apiKey string
}

func (c *Client) Charge(ctx context.Context, amount int64, currency string) error {
	// call to the Stripe API
	return nil
}

The stripe package doesn’t import the order package. It doesn’t know a PaymentProcessor interface exists. It simply has a Charge method that matches. The coupling goes in one direction only.

This gives you enormous flexibility for testing (trivial mocks), for swapping implementations, and for keeping packages decoupled. I explain it in detail in the article on Go interfaces.


Concurrency: don’t share memory, communicate

The most quoted phrase in the Go ecosystem: “Don’t communicate by sharing memory; share memory by communicating.”

Effective Go dedicates quite a bit of space to goroutines and channels. The core idea is that instead of protecting shared data with mutexes (which is error-prone and hard to reason about), you prefer to send data between goroutines through channels. Said like that it sounds simple, but reality has nuances.

// Pattern: fan-out tasks with a results channel
func processItems(ctx context.Context, items []Item) ([]Result, error) {
	results := make(chan Result, len(items))
	errs := make(chan error, len(items))

	for _, item := range items {
		go func(it Item) {
			r, err := process(ctx, it)
			if err != nil {
				errs <- err
				return
			}
			results <- r
		}(item)
	}

	var out []Result
	for range items {
		select {
		case r := <-results:
			out = append(out, r)
		case err := <-errs:
			return nil, fmt.Errorf("processing items: %w", err)
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}

	return out, nil
}

In fact, there are important nuances that Effective Go doesn’t cover with the depth you need today:

  • Don’t launch goroutines without control. You should always know when they finish. sync.WaitGroup or a signaling channel.
  • Use context.Context for cancellation. Every goroutine that does I/O should receive a context and respect it.
  • Channels aren’t the solution for everything. If you need an atomic counter, use sync/atomic. If you need to protect a map from concurrent access, sync.RWMutex is clearer than a channel.

The practical rule: use channels when you need to communicate data between goroutines. Use mutexes when you need to protect simple shared state. Don’t force a channel where a mutex is clearer.


Composition over inheritance: embedding, not extension

Go has no inheritance. No classes, no extends, no type hierarchies. This is intentional, and at first it can feel jarring if you come from OOP languages. Effective Go defends this with the concept of embedding.

Embedding lets you include one type inside another, automatically promoting its methods. But it’s not inheritance, even though it might look like it at first glance. There’s no subtype polymorphism. It’s pure composition.

type Logger struct {
	prefix string
}

func (l *Logger) Log(msg string) {
	fmt.Printf("[%s] %s\n", l.prefix, msg)
}

// Server embeds Logger
type Server struct {
	Logger
	addr string
}

func main() {
	s := Server{
		Logger: Logger{prefix: "HTTP"},
		addr:   ":8080",
	}

	// Log() is promoted from Logger
	s.Log("starting server")
}

The most common use case in backend is embedding sync.Mutex to protect a struct:

type SafeCounter struct {
	sync.Mutex
	counts map[string]int
}

func (c *SafeCounter) Increment(key string) {
	c.Lock()
	defer c.Unlock()
	c.counts[key]++
}

What to avoid: using embedding as if it were inheritance. If I embed Database in UserService to “inherit” data access methods, I’m creating unnecessary coupling. Embedding should promote behavior that makes sense in the public interface of the containing type.


Package design: cohesive, focused packages

Effective Go doesn’t dedicate a specific section to package design, but I think the philosophy comes through from the entire document if you read between the lines. A package in Go should do one thing well.

// BAD: "util" package that does everything
util/
  strings.go
  http.go
  time.go
  crypto.go
  math.go

// GOOD: focused packages
httputil/
  middleware.go
  response.go
order/
  service.go
  repository.go
  model.go
user/
  service.go
  repository.go
  handler.go

Rules I apply:

  1. If the package name is util, common, helpers, or shared, you probably need to split it. Those names are symptoms that you haven’t thought through the responsibilities.
  2. Avoid circular dependencies. Go doesn’t allow them at the compilation level, which forces you to think about the direction of dependencies from the start.
  3. A package shouldn’t have more than one level of subdirectories unless it’s a large library. For most backend applications, a flat structure works better.
  4. internal/ packages are your friends. Everything you don’t want to expose outside your module goes there.

If you want to see how I organize real Go projects, I explain it in the article on Go project structure.


Comments: explain the why, not the what

Effective Go has a clear position on comments: good comments explain why, not what. The code already says what it does. If you need a comment to explain what the code does, the code is probably too complicated.

// BAD: the comment repeats what the code says
// Increment the counter
counter++

// BAD: the comment describes the obvious implementation
// Iterate over users and filter active ones
for _, u := range users {
	if u.Active {
		active = append(active, u)
	}
}

// GOOD: the comment explains a non-obvious decision
// We use a buffer of 100 because the producer can generate bursts
// of up to 80 events per second and the consumer processes ~50/s.
ch := make(chan Event, 100)

// GOOD: the comment explains a workaround
// The PostgreSQL driver returns a generic error when the connection
// is closed due to a server timeout. We retry once before
// propagating the error to the caller.
result, err := retryOnce(func() (*Result, error) {
	return db.QueryContext(ctx, query, args...)
})

Documentation comments

In Go, comments that precede an exported declaration are documentation. godoc extracts them automatically. They should start with the name of the element they document.

// OrderService manages the business logic for orders.
// It coordinates between the order repository, payment service,
// and user notifications.
type OrderService struct {
	// ...
}

// Create validates and persists a new order.
// Returns ErrInvalidOrder if the order fails validation
// and ErrPaymentFailed if the charge cannot be completed.
func (s *OrderService) Create(ctx context.Context, o *Order) error {
	// ...
}

What Effective Go doesn’t cover

The document was written in 2009 and hasn’t been updated to reflect important language changes. And here’s the problem: there are three areas you need to learn separately, because Effective Go simply doesn’t cover them:

Generics (Go 1.18+)

Generics change how you write reusable functions and types. Effective Go says nothing about them because they didn’t exist.

// Before generics: one function per type
func ContainsString(slice []string, target string) bool { ... }
func ContainsInt(slice []int, target int) bool { ... }

// With generics: one function for all
func Contains[T comparable](slice []T, target T) bool {
	for _, v := range slice {
		if v == target {
			return true
		}
	}
	return false
}

My position, which I admit might be conservative: use generics when the alternative is duplicating code or using interface{}. Don’t use them for premature abstractions. Most backend code doesn’t need generics.

Modules (Go 1.11+)

Effective Go assumes GOPATH. Today we use modules. go.mod and go.sum manage dependencies deterministically. If you’re starting with Go today, you don’t even need to know what GOPATH was.

// typical go.mod
module github.com/rogerbcn/myservice

go 1.22

require (
	github.com/gin-gonic/gin v1.10.0
	github.com/jackc/pgx/v5 v5.6.0
)

Context (Go 1.7+)

context.Context is ubiquitous in Go backend code today. It’s used for cancellation, timeouts, and propagating values between layers. Effective Go doesn’t mention it.

func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
	// The context is propagated to the database query
	row := s.db.QueryRowContext(ctx, "SELECT * FROM orders WHERE id = $1", id)

	// If the context is cancelled (HTTP handler timeout, for example),
	// the query is automatically cancelled
	var o Order
	if err := row.Scan(&o.ID, &o.Total, &o.Status); err != nil {
		return nil, fmt.Errorf("getting order %s: %w", id, err)
	}
	return &o, nil
}

The rule: context.Context is always the first parameter of a function. Never store it in a struct. Always propagate it downward.


The 10 rules I apply daily

After several years writing Go for backend, these are the Effective Go principles distilled into the rules I try to apply every day. They’re not original or revolutionary, but they’ve served me well:

  1. Run go fmt on save. No exceptions. The format is non-negotiable.

  2. Short names for short scopes. r for a request, ctx for a context, err for an error. Long names only for things that live long or are exported.

  3. Always early return. If you can exit the function sooner, do it. The happy path goes at the lowest level of indentation.

  4. Check errors immediately. Never accumulate operations without checking. Wrap with fmt.Errorf("context: %w", err).

  5. Single-method interfaces. If your interface has more than three methods, you probably need to split it. Define interfaces where they’re consumed.

  6. Don’t launch goroutines without knowing when they’ll finish. Use sync.WaitGroup, signaling channels, or errgroup.Group.

  7. Composition, not inheritance. Embed types when it makes sense for the public interface. Don’t use it as a substitute for inheritance.

  8. Packages with a single purpose. If the name is util or common, rethink the structure.

  9. Comment the why, not the what. Documentation comments start with the element’s name. Inline comments explain decisions, not syntax.

  10. context.Context as the first parameter. Always propagate it. Never store it in a struct.

None of these rules are original, as I said. They all come, directly or indirectly, from Effective Go and the culture it generated. The original document deserves at least one complete read. But if you stick with just these ten rules and apply them consistently, I think your Go code will be more readable, more maintainable, and more idiomatic than most of what’s out there.

And in the end, what separates idiomatic Go from “functional but weird” Go isn’t knowing advanced tricks. It’s respecting the language’s conventions systematically. Effective Go tells you exactly what those conventions are. The rest is practice. And time. And making enough mistakes to understand why those conventions exist.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved