Go's two most distinctive language design choices are error handling (no exceptions, errors are values) and the interface model (implicit, structural). Master these and the language clicks.
Errors Are Values
error is a built-in interface:
type error interface {
Error() string
}
Any type with an Error() string method is an error. Functions return errors as their last return value:
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
cfg := &Config{}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return cfg, nil
}
Three idioms in those eight lines:
- Check immediately.
if err != nil { ... }right after each call. - Wrap with context.
fmt.Errorf("...: %w", err)preserves the original (the%wverb) while adding context. The error message reads as a chain. - Return early. No
elsebranch; the happy path stays unindented.
Custom Error Types
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %q not found", e.Resource, e.ID)
}
// Sentinel error — package-level value
var ErrUnauthorised = errors.New("unauthorised")
Checking Error Identity
Two functions in the errors package do almost all the work:
// errors.Is — does the error chain contain a specific sentinel?
if errors.Is(err, ErrUnauthorised) {
return http.StatusUnauthorized
}
// errors.As — extract a specific error type from the chain
var nfe *NotFoundError
if errors.As(err, &nfe) {
log.Printf("not found: %s/%s", nfe.Resource, nfe.ID)
return http.StatusNotFound
}
Never compare error strings with == or substring matches — fragile. Use errors.Is or errors.As.
When to Panic
Almost never. panic aborts the goroutine unwinding the stack. Use it for:
- Programmer mistakes that cannot continue (impossible-to-reach code paths)
init()failures where the program cannot proceed- Recovering from a third-party library that panics — wrap with
recoverat the HTTP middleware level
Never panic on user input, network failures, or expected error conditions. Return errors.
Interfaces
An interface is a set of method signatures. Any type that has those methods satisfies the interface — implicitly. There is no implements keyword.
type Storer interface {
Get(key string) ([]byte, error)
Put(key string, value []byte) error
Delete(key string) error
}
// MemoryStore satisfies Storer simply by having those methods
type MemoryStore struct {
data map[string][]byte
}
func (m *MemoryStore) Get(key string) ([]byte, error) { ... }
func (m *MemoryStore) Put(key string, value []byte) error { ... }
func (m *MemoryStore) Delete(key string) error { ... }
// Use anywhere a Storer is expected
func writeReport(s Storer, name string, content []byte) error {
return s.Put("reports/"+name, content)
}
The implicit satisfaction is a big deal. You can write code against an interface defined in your package, then pass it a type defined in someone else's package that you have never met — as long as the method signatures match.
Small Interfaces
Idiom: the smaller the interface, the more useful. The standard library's most-used interfaces are single-method:
| Interface | Method | Used by |
|---|---|---|
io.Reader | Read(p []byte) (int, error) | Files, HTTP bodies, network connections, buffers, decompressors |
io.Writer | Write(p []byte) (int, error) | Files, HTTP responses, log targets, compressors |
fmt.Stringer | String() string | Custom %v formatting |
error | Error() string | Every error |
http.Handler | ServeHTTP(w, r) | Every HTTP route |
"Accept io.Reader" lets your function read from a file, a network connection, or a test fixture without caring which.
The Empty Interface and any
The empty interface (interface{}, or any from 1.18) is satisfied by every type. Use it for genuinely untyped data — like JSON decoding into arbitrary structures. Outside those cases, prefer specific interfaces or generics.
Composition via Embedding
Go has no inheritance. The way to share behaviour is to embed one type inside another:
type Logger struct{}
func (l Logger) Log(msg string) { fmt.Println(msg) }
type Service struct {
Logger // embedded; Service now has a Log method
Name string
}
s := Service{Name: "checkout"}
s.Log("started") // calls Logger.Log
fmt.Println(s.Name)
The embedded type's methods are "promoted" to the embedding struct. There is no super, no overriding — if you define a Log method on Service it simply shadows.
This is the standard pattern for what other languages call mixins. Used heavily in the standard library — bufio.Reader embeds io.Reader and adds buffering, etc.
The Nil-Interface Gotcha
An interface value is two pointers: type and value. An interface is nil only when both are nil. This trips up everyone once:
func mightFail() error {
var e *MyError = nil // typed nil pointer
return e // returns interface{type=*MyError, value=nil} — NOT nil!
}
if err := mightFail(); err != nil {
// This branch fires! err is not the nil interface.
fmt.Println("oops")
}
Rule: never declare var e *MyError and return it. Return nil directly, or use a different approach.
Practical Patterns You Will Use Daily
- Functional options.
NewServer(WithTimeout(5*time.Second), WithLogger(log))instead of long parameter lists. - Defer cleanup.
f, err := os.Open(p); defer f.Close().deferruns when the surrounding function returns, in LIFO order. - Return early. Validate inputs and error on top; happy path at the bottom.
- Constructor convention.
NewXreturns a pointer to a configured X plus an error.
These three ideas — errors as values, implicit interfaces, composition via embedding — are most of what makes Go feel different. The rest is syntactic sugar.