Constructors in Go are simple when a type has one or two fields. But the moment you start building something with five, ten, or fifteen configuration knobs, the cracks show. You either end up with a dozen overloaded constructors (which Go doesn’t even support), a massive config struct that every caller has to populate, or a builder that compiles fine even when half its fields are missing. None of these are great.
The Functional Options Pattern is the Go community’s most effective answer to this problem. It gives you sensible defaults, named optional parameters, compile-time safety, and an API that reads naturally at the call site. Libraries like gRPC-Go, cilium/ebpf, and the Go standard library’s own database/sql drivers lean on variants of this pattern. Let’s walk through why it works and how to implement it correctly.
The Problem: Telescoping Constructors
Imagine a database client with a host, port, timeout, max connections, TLS setting, and a retry policy. The naive approach is a constructor per combination:
func NewClient(host string, port int) *Client
func NewClientWithTimeout(host string, port int, timeout time.Duration) *Client
func NewClientWithTLS(host string, port int, timeout time.Duration, tlsCfg *tls.Config) *Client
func NewClientWithEverything(host string, port int, timeout time.Duration, tlsCfg *tls.Config, maxConn int, retry RetryPolicy) *Client
This explodes combinatorially. Worse, callers end up passing zero-values for parameters they don’t care about just to reach the one they need — NewClientWithEverything("localhost", 5432, 0, nil, 0, myRetry) is unreadable and fragile.
The Alternatives (And Why They Fall Short)
Config Struct
You can bundle everything into a struct and pass it to the constructor:
type Config struct {
Host string
Port int
Timeout time.Duration
TLSConfig *tls.Config
MaxConnections int
RetryPolicy RetryPolicy
}
func NewClient(cfg Config) *Client
This works for small types but has two problems. First, every caller must specify every field — there are no defaults unless you document them and trust the caller to read the docs. Second, required fields (like Host) become indistinguishable from optional ones at the type level. Nothing stops NewClient(Config{}) from compiling.
Builder Pattern
The classic Builder with chained method calls is popular in Java but awkward in Go:
client, err := NewClientBuilder().
Host("localhost").
Port(5432).
Timeout(30 * time.Second).
Build()
It requires returning the builder from each method, which means either a mutable builder (error-prone if reused) or a pointer that callers might hold onto. It also can’t enforce required fields at compile time — Build() without Host() compiles fine and fails at runtime. Go doesn’t have method chaining ergonomics the way Java or Kotlin do, and the pattern feels foreign.
The Functional Options Pattern
The core idea: define a function type that mutates a configuration, provide a constructor that applies sensible defaults first, then layers user-supplied options on top.
package dbclient
import (
"crypto/tls"
"fmt"
"time"
)
// Client holds the runtime configuration after all options are applied.
type Client struct {
host string
port int
timeout time.Duration
tlsConfig *tls.Config
maxConnections int
retryPolicy RetryPolicy
}
// Option is a function that configures a Client.
type Option func(*Client) error
// RetryPolicy defines how the client retries failed requests.
type RetryPolicy struct {
MaxAttempts int
Backoff time.Duration
}
// NewClient creates a Client with the given required parameters and applies
// any number of optional configuration functions on top of the defaults.
func NewClient(host string, port int, opts ...Option) (*Client, error) {
c := &Client{
host: host,
port: port,
timeout: 30 * time.Second,
maxConnections: 10,
retryPolicy: RetryPolicy{MaxAttempts: 3, Backoff: 100 * time.Millisecond},
}
for _, opt := range opts {
if err := opt(c); err != nil {
return nil, fmt.Errorf("applying option: %w", err)
}
}
if c.host == "" {
return nil, fmt.Errorf("host is required")
}
if c.port <= 0 || c.port > 65535 {
return nil, fmt.Errorf("invalid port: %d", c.port)
}
return c, nil
}
Each optional parameter gets its own exported function that returns an Option:
// WithTimeout sets the connection timeout.
func WithTimeout(d time.Duration) Option {
return func(c *Client) error {
if d < 0 {
return fmt.Errorf("timeout must be non-negative, got %v", d)
}
c.timeout = d
return nil
}
}
// WithTLS enables TLS using the provided configuration.
func WithTLS(cfg *tls.Config) Option {
return func(c *Client) error {
if cfg == nil {
return fmt.Errorf("tls config must not be nil")
}
c.tlsConfig = cfg
return nil
}
}
// WithMaxConnections sets the maximum number of concurrent connections.
func WithMaxConnections(n int) Option {
return func(c *Client) error {
if n <= 0 {
return fmt.Errorf("max connections must be positive, got %d", n)
}
c.maxConnections = n
return nil
}
}
// WithRetryPolicy overrides the default retry behavior.
func WithRetryPolicy(p RetryPolicy) Option {
return func(c *Client) error {
if p.MaxAttempts <= 0 {
return fmt.Errorf("max attempts must be positive, got %d", p.MaxAttempts)
}
c.retryPolicy = p
return nil
}
}
What the Call Site Looks Like
Now compare the caller experience to the telescoping constructor:
client, err := dbclient.NewClient(
"localhost",
5432,
dbclient.WithTimeout(10*time.Second),
dbclient.WithMaxConnections(50),
dbclient.WithRetryPolicy(dbclient.RetryPolicy{
MaxAttempts: 5,
Backoff: 200 * time.Millisecond,
}),
)
Every option is named and self-documenting. You can skip any of them and get the documented defaults. You can't accidentally pass a tls.Config where a RetryPolicy belongs because the types are explicit. And the call reads like a declarative configuration block — you see exactly what's being customized.
Validation: Where the Pattern Earns Its Keep
Notice that the Option type returns error. This is the key detail that many implementations miss. Without error returns, you have two bad choices: panic on bad input (hostile to callers) or silently ignore it (bugs in production). With error-returning options, validation happens inline, before the struct is ever used:
// This fails fast at construction, not on the first query:
client, err := dbclient.NewClient("localhost", 5432,
dbclient.WithTimeout(-5*time.Second), // returns error
)
if err != nil {
log.Fatal(err) // "timeout must be non-negative, got -5s"
}
For cross-field validation — say, enforcing that TLS is enabled when the port is 443 — handle it in the constructor body after all options have been applied:
func NewClient(host string, port int, opts ...Option) (*Client, error) {
// ... apply defaults and options as above ...
if port == 443 && c.tlsConfig == nil {
return nil, fmt.Errorf("port 443 requires TLS, use WithTLS()")
}
return c, nil
}
Defaults: Make Them Visible
One of the pattern's strengths is that defaults live in exactly one place — the Client literal at the top of NewClient. This is more maintainable than scattering if cfg.Timeout == 0 { cfg.Timeout = 30 * time.Second } checks throughout a config struct constructor. Keep the defaults block clean and well-commented, and document each default in the option function's doc comment so callers know what they're overriding:
// WithTimeout sets the connection timeout. The default is 30s.
func WithTimeout(d time.Duration) Option { ... }
When to Reach for This Pattern
Functional options shine when you have a type with a few required parameters and many optional ones. If your type has two fields, a plain constructor is simpler and more readable — don't over-engineer. If every field is required, a config struct (or just positional arguments) is the right call.
The pattern is especially valuable for library APIs. Application code might tolerate a config struct because you control every call site. But a public library can't anticipate what every caller needs, and functional options let users customize behavior without forking your constructor or passing a sprawling struct full of zeros.
A Subtle Gotcha: Option Order
Options are applied left-to-right. If two options set the same field, the last one wins. This is usually desirable (it lets a caller override a preset), but it means you should avoid designing options that interact destructively. For example, WithInsecure() and WithTLS(cfg) should be mutually exclusive by validation, not by silent override — check the conflicting state inside the option or in the constructor's post-application validation block.
Conclusion
The Functional Options Pattern hits a sweet spot in Go's design philosophy: explicit, readable, and type-safe without boilerplate. Required parameters stay positional, optional ones become named and self-documenting, and validation happens at construction time rather than at first use. The pattern takes a few extra lines of setup — the Option type and a setter function per knob — but pays for itself the first time a caller configures your type without reading the docs.
The next time you find yourself adding a sixth parameter to a constructor or debating whether to add another NewXWithY variant, reach for functional options instead. Your callers — and your future self — will thank you. The Go standard library and major ecosystem libraries have already made this choice, and it's one of the most reliable patterns in modern Go API design.