How to Structure Error Messages in Go (lowercase, no punctuation)

Use lowercase error messages without terminal punctuation to align with Go standard library conventions.

The fragment convention

You write a function that parses a config file. It fails. You return fmt.Errorf("Config file not found!"). You run the linter. It screams at you. You fix the punctuation. You capitalize the first letter. The linter screams again. You check the standard library. Every error message looks like a sentence fragment. Lowercase. No period. No exclamation mark. It feels wrong to write code that looks like broken English. It isn't broken. It's a convention designed for composition.

Go treats errors as composable units. The top-level error handler wraps the message in a complete sentence. The low-level functions provide the fragments. If every function returns a capitalized sentence with a period, the wrapper has to strip punctuation, lowercase the first letter, and reassemble the text. That's fragile. Fragile text manipulation in error paths is a recipe for bugs. Go avoids that by making the convention simple: return fragments.

Think of error messages like Lego bricks. A single brick has studs on top and grooves on bottom. You snap them together to build something bigger. A full sentence is a finished model. You can't snap another model onto a finished model without glue or awkward tape. Go treats errors as composable units. The top-level error handler wraps the message in a complete sentence. The low-level functions provide the fragments.

Error messages are fragments. Compose them, don't complete them.

Why lowercase and no punctuation?

The convention exists because errors bubble up through layers of code. A database driver returns an error. A repository wraps it. A service wraps that. An HTTP handler wraps that. Each layer adds context. If the database driver returns "Connection refused.", the repository might produce "Get user: Connection refused.". The service produces "Handle request: Get user: Connection refused.". The result is readable, but inconsistent capitalization and punctuation make logs noisy and hard to parse.

If the database driver returns "connection refused", the chain becomes "get user: connection refused". The casing is uniform. The punctuation is absent until the final handler adds a period. The final handler controls the presentation. The internal functions control the data. This separation keeps the error chain clean.

The compiler does not enforce lowercase or no punctuation. You can write errors.New("Error!") and the program builds. The convention is enforced by linters and community norms. Tools like gofmt handle code formatting, but they don't touch string contents. You have to follow the style manually or rely on a linter like revive or staticcheck to catch violations.

The community accepts verbose error handling patterns like if err != nil { return err } because they make the unhappy path visible. Error message structure is part of that contract. Clear, consistent messages make debugging faster. Inconsistent messages hide the signal in noise.

Composing errors with fmt.Errorf

Here's the simplest error definition. It uses errors.New for a static value and fmt.Errorf to wrap it with context.

package main

import (
	"errors"
	"fmt"
)

// ErrNotFound represents a missing resource.
var ErrNotFound = errors.New("not found")

func main() {
	// errors.New creates a static error value.
	// The message is a fragment. No capital, no period.
	err := ErrNotFound

	// fmt.Errorf wraps the error.
	// %w preserves the error chain for errors.Is checks.
	// The wrapper adds context about the operation.
	wrapped := fmt.Errorf("user lookup: %w", err)

	// The result is a readable sentence built from parts.
	fmt.Println(wrapped)
}

The %w verb is the mechanism that makes composition work. It tells fmt.Errorf to embed the error so errors.Unwrap can find it. Without %w, the error chain breaks. You lose the ability to check for specific errors programmatically. The compiler catches verb mismatches. If you pass a string to %w, the compiler rejects the code with fmt: %w has invalid verb. You must pass an error value.

The chain is the contract. Break it and you lose the signal.

Sentinel errors and errors.New

Sentinel errors are named error values that represent specific conditions. You define them with errors.New and export them so callers can check for them. The message should be a fragment. The name should be descriptive.

package archive

import "errors"

// ErrInvalidFormat indicates the file structure is wrong.
var ErrInvalidFormat = errors.New("invalid format")

// ErrCorrupted indicates the data is damaged.
var ErrCorrupted = errors.New("corrupted data")

Callers use errors.Is to check for sentinel errors. errors.Is walks the error chain created by %w. It returns true if the target error matches the sentinel. This works even if the sentinel is wrapped multiple times.

package main

import (
	"errors"
	"fmt"

	"example.com/archive"
)

func process() error {
	// Simulate a wrapped error.
	return fmt.Errorf("read file: %w", archive.ErrInvalidFormat)
}

func main() {
	err := process()

	// errors.Is checks the chain.
	// It finds ErrInvalidFormat inside the wrapper.
	if errors.Is(err, archive.ErrInvalidFormat) {
		fmt.Println("got invalid format error")
	}
}

If you use %s or %v instead of %w, the chain breaks. errors.Is won't find the underlying error. You lose the ability to handle errors programmatically. The message still prints correctly, but the logic fails. This is a common runtime bug. The compiler doesn't warn you. You have to choose the right verb intentionally.

Package names in error messages

Sometimes you see error messages like archive/tar: file not found. The package name appears at the start. This convention helps identify the source of the error in large systems. When errors bubble up through many layers, the stack trace might not be available, or the log might be truncated. Including the package name makes it easier to locate the origin.

The standard library uses this pattern for public packages. Your internal packages might not need it if the stack trace is always available. It's a safe habit for libraries that others will use. The message remains a fragment. The package name is just a prefix.

package archive

import "errors"

// ErrEOF is defined in the archive/tar package.
// Include package name for public libraries.
// Helps identify source in logs.
var ErrEOF = errors.New("archive/tar: EOF")

Custom error types follow the same convention. The Error() string method returns a fragment. The method name is Error by convention. The receiver is usually a pointer or value depending on the type. The message starts lowercase. No punctuation.

package main

import "fmt"

// ParseError holds details about a parsing failure.
type ParseError struct {
	Line int
	Msg  string
}

// Error returns the error message as a fragment.
// The method implements the error interface.
func (e *ParseError) Error() string {
	// Format the message.
	// No capital, no period.
	return fmt.Sprintf("parse error at line %d: %s", e.Line, e.Msg)
}

func main() {
	err := &ParseError{Line: 10, Msg: "unexpected token"}
	fmt.Println(err)
}

Pitfalls and compiler checks

The compiler doesn't police style. You can write errors.New("Error!") and the program builds. The risk is runtime behavior. If you use %s instead of %w in fmt.Errorf, the error chain breaks. errors.Is won't find the underlying error. You lose the ability to check for specific errors programmatically.

The compiler catches verb mismatches. If you pass a string to %w, the compiler rejects the code with fmt: %w has invalid verb. You must pass an error value. If you forget to import errors, you get undefined: errors. If you forget to use an import, you get imported and not used. These are standard compiler errors. They don't relate to error message structure.

Runtime panics happen if you unwrap a nil error incorrectly. errors.Unwrap returns nil if the error is nil or doesn't implement Unwrap. You should check for nil before unwrapping. The errors.Is and errors.As functions handle nil safely. They return false if the error is nil.

Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Error handling doesn't cause goroutine leaks directly, but ignoring errors can lead to resources staying open. Always handle errors. Return them. Wrap them. Don't swallow them.

The worst error bug is the one that never logs.

Decision: choosing the right error pattern

Use errors.New when you define a sentinel error that represents a specific condition and needs to be checked with errors.Is.

Use fmt.Errorf with %w when you wrap an error to add context while preserving the chain for programmatic checks.

Use fmt.Errorf with %v or %s when you are formatting a final message for the user and don't need to preserve the error chain.

Use a custom struct with an Error() string method when you need to return structured data alongside the message, like a status code or a retry flag.

Use plain strings only inside the Error() method or errors.New call. Never return a raw string as an error value.

Wrap errors with context. Return fragments. Let the caller build the sentence.

Where to go next