How to Write Clean Functions in Go (Small, Focused, Named Returns)

Write Go functions that do exactly one thing, keep them under 20 lines, and use named return values only when they improve readability for complex error handling or logging.

The God Function trap

You are reviewing a pull request. The function is called HandleUser. You scroll down. And down. And down. It is 180 lines long. It validates the input, hashes the password, saves to the database, sends a welcome email, updates analytics, and logs metrics. You try to write a test for the password hashing logic, but the test fails because the analytics service is down. The function is a tangled knot. You cannot touch one thread without pulling the whole sweater apart.

This is the "God Function." It does everything, so it breaks everywhere. It violates the single responsibility principle by mixing concerns. In Go, the antidote is small, focused functions that compose together. A function should be like a well-labeled jar in a pantry. You open it, you get what the label says, you close it. No surprises.

Functions as contracts

A function is a contract. It promises to take specific inputs, perform a defined transformation, and return a result. When a function does too much, the contract becomes vague. ProcessOrder could mean validation, storage, or notification. A focused function like ValidateOrder has a clear contract. You pass an order, you get an error or success.

Small functions reduce cognitive load. When you read code, you hold its state in your working memory. If a function is 100 lines long, you need to remember variables from the top by the time you reach the bottom. Small functions reset this memory. You finish one function, you clear the slate, you start the next. This makes code easier to review and debug.

Small functions are also easier to test. You can test ValidateOrder with a simple struct. You do not need a database. You do not need a network. You can run thousands of tests in milliseconds. Large functions require complex setup. Tests become slow and brittle. When a test fails, it is hard to know which part broke.

Minimal example

Here is a focused function that calculates a discount. It takes inputs, applies a rule, and returns a value.

// CalculateDiscount applies a percentage discount to a price.
// It clamps the percent to a valid range to prevent invalid results.
func CalculateDiscount(price float64, percent float64) float64 {
    // Clamp percent to 0 to prevent negative discounts.
    if percent < 0 {
        percent = 0
    }
    // Clamp percent to 100 to prevent free items.
    if percent > 100 {
        percent = 100
    }
    // Return the reduced price.
    return price * (1 - percent/100)
}

The function signature declares the inputs and output. price and percent are passed by value. The body clamps the percent to a valid range. This prevents invalid input from causing negative prices. The return statement computes the discounted price. The function has no side effects. It does not modify global state. It does not write to a database. It just transforms data. This makes it easy to test. You can call it with any numbers and verify the result without setting up external dependencies.

Realistic workflow

Real code involves I/O, errors, and multiple steps. Here is how to structure a realistic workflow using small functions.

// ProcessOrder orchestrates the order workflow by delegating to focused helpers.
// It validates, saves, and sends confirmation in a clear sequence.
func ProcessOrder(order *Order) error {
    // Validate first to fail fast without touching external resources.
    if err := ValidateOrder(order); err != nil {
        return fmt.Errorf("validation: %w", err)
    }
    // Save to database.
    if err := SaveOrder(order); err != nil {
        return fmt.Errorf("storage: %w", err)
    }
    // Send notification as a final step.
    return SendConfirmation(order)
}

// ValidateOrder checks business rules for the order.
// It returns an error if the order is invalid.
func ValidateOrder(o *Order) error {
    if o == nil {
        return errors.New("order is nil")
    }
    if o.Total <= 0 {
        return errors.New("total must be positive")
    }
    if o.Email == "" {
        return errors.New("email is required")
    }
    return nil
}

The refactored version delegates to helpers. ProcessOrder orchestrates the workflow. It calls ValidateOrder, then SaveOrder, then SendConfirmation. Each helper has a single responsibility. ValidateOrder checks business rules. SaveOrder handles persistence. SendConfirmation manages notifications. The main function reads like a table of contents. You can see the high-level flow without getting lost in details. Each helper can be tested independently. You can test validation without a database. You can test storage without sending emails.

The if err != nil pattern is verbose. The Go community accepts this boilerplate because it makes error handling explicit. Every error is checked immediately. There are no silent failures. The unhappy path is visible. Do not try to hide errors in complex control structures. Return them early.

Named returns

Named return values are a Go feature that lets you label return variables in the signature. They are useful in specific scenarios but harmful if overused.

// FetchAndParse reads data from a source and parses it.
// Named returns allow wrapping the error in a defer block.
func FetchAndParse(ctx context.Context, url string) (data []byte, err error) {
    // Defer a function to wrap errors on any return path.
    defer func() {
        if err != nil {
            err = fmt.Errorf("fetch %s: %w", url, err)
        }
    }()
    // Fetch the resource.
    resp, err := http.Get(ctx, url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    // Read the body.
    data, err = io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    // Return uses the named variables.
    return
}

Named returns shine when you need to modify the return value before exiting. The defer block wraps the error with context. This works on every return path, including early returns. The return statement uses the named variables automatically. This reduces repetition. You do not need to write return nil, err everywhere. The defer block handles the wrapping.

Named returns obscure simple logic. The signature (result int) hides the return value. A reader has to scan the signature to see what the function returns. The return statement is empty. It relies on the named variable. This adds mental overhead for no benefit. Prefer explicit returns for simple functions. The return value should be obvious from the signature.

Named returns are a tool for complexity, not a style choice for simplicity. Use them when they save you from repeating error wrapping, not to save keystrokes.

Pitfalls and conventions

Small functions introduce new challenges. You need to manage boundaries, arguments, and return values carefully.

The compiler rejects programs with unused local variables with declared and not used. This catches typos and dead code. Named return variables are an exception. They are initialized to zero values. If you forget to assign a named return, the function returns the zero value. The compiler will not stop you. This can lead to silent bugs where a function returns nil or 0 unexpectedly. Always assign named returns explicitly.

If you assign the wrong type to a return variable, the compiler rejects the program with cannot use x as type y in return. This ensures type safety. You cannot accidentally return a string when an error is expected.

Methods are functions attached to types. The receiver name should be short and match the type. Use (b *Buffer), not (this *Buffer). This keeps method signatures clean.

// (b *Buffer) Write implements io.Writer.
// The receiver name is short and matches the type.
func (b *Buffer) Write(p []byte) (int, error) {
    // Append data to the internal slice.
    b.buf = append(b.buf, p...)
    return len(p), nil
}

Functions that perform I/O or spawn goroutines should take a context.Context as the first parameter. Name it ctx. Respect cancellation. If the context is done, stop work and return an error. This prevents goroutine leaks. The worst goroutine bug is the one that never logs.

Formatting is automated. Run gofmt on save. Do not argue about indentation. Trust the tool.

Do not pass pointers to strings. Strings are cheap to pass by value. They are immutable and small. Passing *string adds indirection without benefit.

Use the underscore to discard values intentionally. result, _ := func() tells the reader you considered the second return value and chose to drop it. Use it sparingly with errors. Ignoring errors is usually a mistake.

Accept interfaces, return structs. This mantra keeps your code flexible and maintainable.

When to use what

Choosing the right structure depends on the complexity and requirements of your code.

Use a single function when the logic is a cohesive unit under 20 lines and testing the whole block is easier than testing parts.

Use named returns when you need to wrap errors in a defer block or when multiple return values benefit from explicit labels in the signature.

Use a helper function when a block of code has a distinct purpose that could be reused or tested in isolation.

Use a struct for arguments when a function requires more than three parameters or when the parameters form a logical group.

Use context.Context as the first parameter when the function performs I/O, spawns goroutines, or calls other functions that might need cancellation.

Use interfaces for parameters when you want to accept multiple types that share behavior. Use structs for return values to keep the implementation concrete.

Trust gofmt. Argue logic, not formatting.

Where to go next