How to Use the Functional Options Pattern in Go

Use variadic functions that accept option functions to flexibly configure Go structs without bloating your constructor API.

The Constructor Explosion

You are building a library. The constructor starts simple. NewClient(baseURL string) works fine for the first version. Then users ask for a timeout. You add it. NewClient(baseURL string, timeout time.Duration). Then retries. Then a custom dialer. Then TLS configuration. Then a logger. The signature explodes.

NewClient(baseURL string, timeout time.Duration, retries int, dialer Dialer, tlsConfig *tls.Config, logger Logger)

Callers struggle. They pass 0 for timeout and wonder why requests hang. They pass nil for TLS and get a panic. They have to remember the exact order of arguments. You release a new version and add maxConnections. Every call site in every project that depends on your library breaks. The compiler rejects the build with not enough arguments in call to NewClient. You force every user to update their code just to add one optional field.

This is the pain the Functional Options Pattern eliminates. It lets you add options forever without changing the constructor signature or breaking existing callers.

The Pattern in Plain Words

The Functional Options Pattern changes how you pass configuration. Instead of a long list of arguments, the constructor accepts a variadic slice of functions. Each function knows how to modify the struct. You define helper functions that return these modifiers. Callers pass only the options they care about.

The constructor starts with safe defaults and applies the modifiers one by one. If a caller doesn't pass an option, the default stays. If they pass an option, the modifier overwrites the default. You can add new options in future versions without touching the constructor signature. Existing code keeps compiling. New code gets the new features.

Think of it like a restaurant order ticket. The base dish is the struct with defaults. The options are notes you add to the ticket. "Extra cheese," "No onions," "Gluten-free crust." You hand the ticket to the kitchen. The kitchen starts with the base dish and applies the notes. The order of notes doesn't matter much, and you can add new notes without rewriting the whole ticket format.

Minimal Example

Here's the skeleton. Define the option type, a helper to create options, and the constructor that applies them.

// Option modifies a Config struct.
type Option func(*Config)

// Config holds the settings.
type Config struct {
    Timeout time.Duration
    Retries int
}

// WithTimeout returns an Option that sets the timeout.
func WithTimeout(d time.Duration) Option {
    return func(c *Config) {
        c.Timeout = d
    }
}

// NewConfig creates a Config with defaults, then applies options.
func NewConfig(opts ...Option) *Config {
    c := &Config{
        Timeout: 5 * time.Second,
        Retries: 3,
    }
    for _, opt := range opts {
        opt(c)
    }
    return c
}

The Option type is a function that takes a pointer to Config and returns nothing. The pointer lets the function mutate the struct. WithTimeout captures the duration d and returns a closure that sets c.Timeout. NewConfig creates the struct with defaults, loops over the options, and calls each one.

Usage is clean. Callers pass only what they need.

cfg := NewConfig(WithTimeout(10 * time.Second))
// cfg.Timeout is 10s, cfg.Retries is 3 (default)

The compiler enforces the types. If you pass a function with the wrong signature, the compiler rejects it with cannot use func() (type Option) as type Option in argument. You can't accidentally pass a string where an option belongs.

How the Closure Works

The magic happens in the closure. WithTimeout returns a function. That function is a closure because it captures the variable d from the outer scope. When NewConfig calls the closure, the closure uses the captured d to set the field.

This capture happens at the time WithTimeout is called. The value is frozen. If you pass a variable, the closure captures the value, not the variable. This prevents bugs where the value changes later.

d := 10 * time.Second
opt := WithTimeout(d)
d = 0 // This does not affect opt

The closure still uses 10s. The compiler optimizes these small closures. They usually don't allocate on the heap unless they escape the function scope. The overhead is negligible. Options are cheap.

Convention aside: gofmt handles the indentation of the option calls. Trust gofmt. Argue logic, not formatting. When you write NewClient(url, WithA(), WithB(), WithC()), the formatter aligns the arguments so the code stays readable.

Realistic Example: HTTP Client

Here's a closer look at real code. An HTTP client with multiple options and a logger.

// Client represents an HTTP client.
type Client struct {
    BaseURL string
    Timeout time.Duration
    Logger  Logger
}

// Option configures a Client.
type Option func(*Client)

// WithTimeout sets the request timeout.
func WithTimeout(d time.Duration) Option {
    return func(c *Client) {
        c.Timeout = d
    }
}

// WithLogger attaches a logger for debugging.
func WithLogger(l Logger) Option {
    return func(c *Client) {
        c.Logger = l
    }
}

// NewClient initializes a Client with defaults and applies options.
func NewClient(baseURL string, opts ...Option) *Client {
    c := &Client{
        BaseURL: baseURL,
        Timeout: 30 * time.Second,
    }
    for _, opt := range opts {
        opt(c)
    }
    return c
}

The Client struct holds the state. Option is exported so users can write their own options if needed. NewClient takes the required baseURL and optional opts. The loop applies each option. The underscore _ discards the loop index, signaling that the index is irrelevant. Use _ sparingly with errors, but freely with loop indices.

Callers can compose options.

client := NewClient("https://api.example.com",
    WithTimeout(5*time.Second),
    WithLogger(myLogger),
)

You can also group options into presets. This reduces boilerplate for common configurations.

// WithProductionDefaults applies settings for production use.
func WithProductionDefaults() Option {
    return func(c *Client) {
        c.Timeout = 10 * time.Second
        c.Logger = prodLogger
    }
}

client := NewClient("https://api.example.com", WithProductionDefaults())

Options are a contract. If the order changes behavior, document the order.

Validation and Errors

Sometimes options need to validate input. A timeout of -1 is invalid. A port of 99999 is invalid. The standard pattern doesn't return errors from options, but you can extend it.

Define an option type that returns an error. The constructor checks the error and stops on failure.

// OptionWithErr allows options to fail validation.
type OptionWithErr func(*Config) error

// WithPort validates the port number.
func WithPort(p int) OptionWithErr {
    return func(c *Config) error {
        if p < 1 || p > 65535 {
            return fmt.Errorf("invalid port %d", p)
        }
        c.Port = p
        return nil
    }
}

// NewConfigWithErr applies options and returns the first error.
func NewConfigWithErr(opts ...OptionWithErr) (*Config, error) {
    c := &Config{Port: 8080}
    for _, opt := range opts {
        if err := opt(c); err != nil {
            return nil, err
        }
    }
    return c, nil
}

The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't miss the error handling. The constructor returns nil on error, so callers must check the error before using the config.

Validation belongs in the option or the constructor, not scattered in the caller.

Pitfalls and Gotchas

The pattern is robust, but a few traps exist.

Loop variable capture is the classic bug. If you create options inside a loop and capture the loop variable, all options might use the final value.

for _, d := range durations {
    opts = append(opts, WithTimeout(d))
}

In Go 1.22+, the compiler fixes this by creating a new variable for each iteration. In older versions, the compiler warns with loop variable captured by func literal and rejects the code if you use the variable in a goroutine, but options might slip through. Always capture the variable explicitly to be safe.

for _, d := range durations {
    d := d // capture
    opts = append(opts, WithTimeout(d))
}

Order dependence is another risk. If options modify the same field, the last one wins. If options depend on each other, the result depends on the order. WithPort sets the port. WithSecure might change the port based on the scheme. If the caller passes WithSecure before WithPort, the port gets overwritten. Document the order or make options idempotent.

Overuse is a design smell. If you have 20 options, the pattern hides complexity. The caller still has to know all of them. Refactor the design. Split the struct into smaller pieces. Use composition instead of configuration.

Don't pass a *string for options. Strings are cheap to pass by value. Pass the string directly. The option function captures the value.

Options are cheap. Closures are cheap. Don't fear the function.

When to Use Functional Options

Use the Functional Options Pattern when you have a struct with many optional fields and want to preserve backward compatibility across versions.

Use a plain struct literal when the configuration is simple and all fields are required or rarely change.

Use a builder pattern with a fluent interface when you need to enforce complex validation rules or step-by-step construction logic.

Use a configuration file or environment variables when the settings are external and change without recompilation.

Backward compatibility is a feature. Options give you that for free.

Where to go next