- Products & ServicesProducts & Services
- SolutionsSolutions
- PricingPricing
- CompanyCompany
- ResourcesResources
en
en
Detailed tutorials, research and analysis on the latest technology trends and techniques for programmers.
Tech enthusiast on a lifelong quest to break, build, and secure cool stuff. Known in the team as the go-to rubber duck 🦆.
Tech enthusiast on a lifelong quest to break, build, and secure cool stuff. Known in the team as the go-to rubber duck 🦆.
Comments (0)
Sign in to join the discussion

💡 Most tutorials teach you what functional options are. This one teaches you when they save you — and when they cause problems — backed by real code from grpc-go, zap, and aws-sdk-go-v2
Imagine you are writing a Go library. You start with a simple constructor:
func NewServer(host string, port int) *Server
Clean. Readable. Then a user asks for timeout support. You add it:
func NewServer(host string, port int, timeout time.Duration) *Server
Then TLS. Then max connections. Then a retry policy. Six months later:
func NewServer(
host string,
port int,
timeout time.Duration,
tlsConfig *tls.Config,
maxConns int,
retryPolicy RetryPolicy,
logger Logger,
enableMetrics bool,
) *Server
Every caller must now pass all 8 arguments — even if they only care about 2. Worse, adding a 9th parameter breaks every existing caller in every codebase that imports your library.
This is the API evolution problem. Functional options are Go’s most practical answer to it.
Before functional options became common in Go, developers tried a few other approaches. Each one has real drawbacks worth understanding.
func NewServer(host string, port int, enableTLS bool, enableMetrics bool, enableRetry bool) *Server
The call site becomes a guessing game:
NewServer("localhost", 8080, false, true, false)
What does true mean here? You have to look up the function signature to find out. This approach does not scale past two or three flags.
type ServerConfig struct {
Host string
Port int
Timeout time.Duration
TLSConfig *tls.Config
}
func NewServer(cfg ServerConfig) *Server
This is better — callers can name their fields. But there is a hidden problem: you cannot tell the difference between “the user set this to zero” and “the user forgot to set this.” Is a zero Timeout intentional or a mistake? You cannot know. You also have to export all config fields, which forces your internal structure into your public API.
func NewServer(host string, port int, tlsConfig *tls.Config) *Server // pass nil if you do not want TLS
This works for one optional parameter. It breaks down quickly with two. With three or more, it becomes hard to read.
The functional options pattern was introduced by Rob Pike and popularized by Dave Cheney’s 2014 blog post. It solves the problems above: options are explicit, easy to combine, backward-compatible by default, and do not require you to expose your internals.
Here is the full pattern in its simplest form:
type Server struct {
host string
port int
timeout time.Duration
}
// Option is a function that configures a Server.
type Option func(*Server)
func WithTimeout(t time.Duration) Option {
return func(s *Server) {
s.timeout = t
}
}
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
func NewServer(host string, opts ...Option) *Server {
// Start with sensible defaults.
s := &Server{
host: host,
port: 8080,
timeout: 30 * time.Second,
}
// Apply each option on top of the defaults.
for _, opt := range opts {
opt(s)
}
return s
}
The call site is easy to read:
srv := NewServer("localhost",
WithPort(9090),
WithTimeout(10*time.Second),
)
Need to add a new option later? Write a new With... function. No existing callers need to change anything.
The strongest argument for any pattern is seeing it work in production. Here is how three widely used Go projects apply functional options.
grpc-go — Google’s gRPC Library📄 Source: grpc/grpc-go — server.go, L198–L228
Instead of a plain function type, grpc-go defines ServerOption as an interface with a private apply method:
// <https://github.com/grpc/grpc-go/blob/master/server.go#L198>
type ServerOption interface {
apply(*serverOptions)
}
// <https://github.com/grpc/grpc-go/blob/master/server.go#L215>
// funcServerOption wraps a function that modifies serverOptions into an
// implementation of the ServerOption interface.
type funcServerOption struct {
f func(*serverOptions)
}
func (fdo *funcServerOption) apply(do *serverOptions) {
fdo.f(do)
}
// <https://github.com/grpc/grpc-go/blob/master/server.go#L223>
func newFuncServerOption(f func(*serverOptions)) *funcServerOption {
return &funcServerOption{f: f}
}
There are two reasons grpc-go uses an interface instead of a plain function type.
First: only grpc-go can create valid options. Because apply is unexported, no code outside the package can implement ServerOption. This is a Go rule: if an interface has a method with a lowercase name, only code in the same package can satisfy it. External packages can use the options that grpc-go provides — like grpc.MaxRecvMsgSize — but they cannot write their own. This keeps the set of valid options fully under grpc-go‘s control.
Second: the interface allows type assertions later. With a plain function type, an option is just a function — nothing more. With an interface, grpc-go can later check at runtime whether an option also implements additional behavior. For example:
// In the future, grpc-go could do this:
if s, ok := opt.(fmt.Stringer); ok {
log.Println("applying option:", s.String())
}
This kind of optional extension is only possible when options are an interface type. A plain function cannot be type-asserted for extra capabilities.
The result at the call site is clean and descriptive:
grpc.NewServer(
grpc.MaxRecvMsgSize(1024),
grpc.Creds(credentials.NewTLS(tlsCfg)),
)
📄 To see how individual options like MaxRecvMsgSize are built using newFuncServerOption, search for newFuncServerOption throughout server.go — every call is a concrete option.
uber-go/zap — Uber’s Logger📄 Source: uber-go/zap — options.go, L30–L40
zap takes the same interface-based approach:
// <https://github.com/uber-go/zap/blob/master/options.go#L30>
// An Option configures a Logger.
type Option interface {
apply(*Logger)
}
// <https://github.com/uber-go/zap/blob/master/options.go#L35>
// optionFunc wraps a func so it satisfies the Option interface.
type optionFunc func(*Logger)
func (f optionFunc) apply(log *Logger) {
f(log)
}
Options are passed at construction time via zap.New:
logger := zap.New(core,
zap.WithCaller(true),
zap.AddCallerSkip(1),
)
📄 To see concrete options in action, look at WithCaller at L98 and WrapCore at L42 — each one is a short, self-contained function that returns an optionFunc.
📄 Bonus: Uber’s internal Go style guide explains exactly why they prefer the interface approach over bare closures — see the Functional Options section of the Uber Go Style Guide.
aws-sdk-go-v2 — AWS SDK at Scale📄 Source: aws/aws-sdk-go-v2 — service/s3/api_op_PutObject.go, L160
The AWS SDK v2 takes a different approach. Because it covers hundreds of services and is largely code-generated, each operation accepts a plain ...func(*Options) directly — no named interface type:
// <https://github.com/aws/aws-sdk-go-v2/blob/main/service/s3/api_op_PutObject.go#L160>
func (c *Client) PutObject(
ctx context.Context,
params *PutObjectInput,
optFns ...func(*Options), // ← plain function, no named type
) (*PutObjectOutput, error)
This is the same pattern at its most minimal. Skipping the named type works well when you are generating code at large scale and need simplicity over discoverability. Every single service in the SDK — S3, EC2, DynamoDB, and the rest — follows this exact same shape consistently.
Here is a realistic HTTP client built with the pattern — close to what you would write on the job.
package httpclientimport ( "net/http" "time" )
type client struct { baseURL string timeout time.Duration retries int headers map[string]string httpClient *http.Client }
Keep all fields unexported. Callers configure the client through options, never by touching the struct directly.
type Option func(*client)
func WithTimeout(d time.Duration) Option {
return func(c *client) {
c.timeout = d
}
}
func WithRetries(n int) Option {
return func(c *client) {
c.retries = n
}
}
func WithHeader(key, value string) Option {
return func(c *client) {
c.headers[key] = value
}
}
func WithHTTPClient(hc *http.Client) Option {
return func(c *client) {
c.httpClient = hc
}
}
func New(baseURL string, opts ...Option) *client {
c := &client{
baseURL: baseURL,
timeout: 30 * time.Second,
retries: 3,
headers: make(map[string]string),
httpClient: &http.Client{},
}
for _, opt := range opts {
opt(c)
}
// Apply the configured timeout to the underlying http.Client.
c.httpClient.Timeout = c.timeout
return c
}
client := httpclient.New("<https://api.example.com>",
httpclient.WithTimeout(5*time.Second),
httpclient.WithRetries(5),
httpclient.WithHeader("Authorization", "Bearer "+token),
)
No nil values. No positional guessing. Adding a new option later requires no changes to existing callers.
Once you understand the basics, there are a few more powerful ways to use this pattern.
You can bundle several options together under a single name:
func ProductionDefaults() Option {
return func(c *client) {
c.timeout = 10 * time.Second
c.retries = 5
c.headers["User-Agent"] = "myapp/1.0"
}
}
// Apply the preset, then override one specific value.
client := New(url, ProductionDefaults(), WithHeader("X-Debug", "true"))
This is useful for environment-specific configurations.
If a wrong value is dangerous, your option can return an error:
type Option func(*client) errorfunc WithRetries(n int) Option { return func(c *client) error { if n < 0 { return fmt.Errorf("retries must be non-negative, got %d", n) } c.retries = n return nil } }
func New(baseURL string, opts ...Option) (*client, error) { c := &client{ baseURL: baseURL, timeout: 30 * time.Second, retries: 3, headers: make(map[string]string), httpClient: &http.Client{}, } for _, opt := range opts { if err := opt(c); err != nil { return nil, err } } return c, nil }
Note that this changes the Option type signature. If you mix this with the non-error version, they are incompatible — pick one style and use it consistently across your package. Use this only when a wrong value could cause real harm. Callers must now handle the error.
Replace the plain function type with an interface and add a String() method. This lets each option describe itself, which helps with debugging:
type Option interface {
apply(*client)
String() string // human-readable description of what this option does
}
You can then log which options were applied when constructing a value:
for _, opt := range opts {
log.Println("applying option:", opt.String())
opt.apply(c)
}
The trade-off is that every option now needs to be a struct rather than a plain closure, which adds boilerplate. Use this when observability matters more than brevity — for example, in a library where users frequently ask “why is my client configured this way?”
You can add a With method that produces a modified copy without changing the original:
func (c *client) With(opts ...Option) *client {
copy := *c // shallow copy
for _, opt := range opts {
opt(©)
}
return ©
}
Note: this is a shallow copy. If your struct has map or pointer fields, the original and the copy will share the same underlying data. You will need to deep copy those fields manually before applying options.
WithTimeout(5*time.Second) is self-explanatory; positional args are notWith... functions
// These two lines produce different results, with no compiler warning. srv := New(url, WithTimeout(5*time.Second), WithTimeout(30*time.Second)) // timeout = 30s srv := New(url, WithTimeout(30*time.Second), WithTimeout(5*time.Second)) // timeout = 5s
The last option applied wins. If options from different parts of your code both set the same field, the result depends on argument order in a way that is easy to miss.
One fix: add a timeoutSet bool field to your struct, then use it to ignore duplicate options:
type client struct {
baseURL string
timeout time.Duration
timeoutSet bool // add this field
retries int
headers map[string]string
httpClient *http.Client
}
func WithTimeout(d time.Duration) Option {
return func(c *client) {
if c.timeoutSet {
return // ignore later calls
}
c.timeout = d
c.timeoutSet = true
}
}
var opt Option // nil function value srv := New(url, opt) // panics when opt(c) is called
If you receive options from outside your package or store them in slices, always guard against nil:
for _, opt := range opts {
if opt != nil {
opt(c)
}
}
You cannot inspect a closure to see what values it will set. This means you cannot write a test like:
// This does NOT work — you cannot peek inside a closure. opt := WithTimeout(5 * time.Second) assert.Equal(t, 5*time.Second, opt.timeout) // ❌ no such field
You have two options, each with a trade-off.
Option A — Expose a getter.
Add an exported method that reveals the internal value:
func (c *client) Timeout() time.Duration {
return c.timeout
}
Now you can write a direct test:
c := New("<https://api.example.com>", WithTimeout(5*time.Second))
assert.Equal(t, 5*time.Second, c.Timeout()) // ✅ works
The downside: every getter you add widens your public API. Over time this leaks your internal structure to callers.
Option B — Test the behavior, not the value.
Instead of checking what was configured, check what the client does as a result. For timeout, you can spin up a slow test server and assert the client returns an error within the expected window:
func TestWithTimeout(t *testing.T) {
// A server that never responds.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Second)
}))
defer srv.Close()
c := New(srv.URL, WithTimeout(100*time.Millisecond))
_, err := c.Get("/")
// We do not care about the exact timeout value — we care that it timed out.
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
This test is more realistic — it verifies the option actually works, not just that a field was assigned. The trade-off is that it is slower and more complex to write.
Which to choose? For most cases, behavioral tests are the better investment. They catch real bugs that a field-value check would miss — for example, a bug where WithTimeout sets the field but the field is never applied to the underlying http.Client. A getter test would pass; a behavioral test would catch it.
This applies when your config type is exported — for example, like AWS SDK’s Options. In that case, if Option is a plain function type, any external package can define its own option functions and pass them in — completely outside the API you designed. You lose control over what counts as a valid option.
If your config type is unexported (like the client struct in this post’s example), external callers cannot name the type at all, so they cannot create their own option functions regardless. The interface approach is still useful in that case for the type assertion benefit described in the grpc-go section.
Using an interface with an unexported method gives the strongest guarantee:
type Option interface {
apply(*Server) // unexported — only your package can implement this
}
External code cannot implement this interface, so the only valid options are the ones you explicitly provide. This is the grpc-go approach and is worth using for any public API where you want full control over the option surface.
This is a common Go mistake that appears inside option constructors. The example below uses a simplified struct to keep things clear:
opts := make([]Option, len(timeouts))
for i, t := range timeouts {
// Bug: all closures share the same t variable.
// By the time they run, t holds the last value from the loop.
opts[i] = func(c *client) { c.timeout = t }
}
The fix is to create a new variable inside each loop iteration:
for i, t := range timeouts {
t := t // new variable, scoped to this iteration
opts[i] = func(c *client) { c.timeout = t }
}
Note: In Go 1.22 and later, loop variables are scoped per iteration by default, so this is less of a concern in newer codebases. If your project supports older Go versions, take a look on this post.
| Functional Options | Builder Pattern | Config Struct | |
|---|---|---|---|
| Backward compatible | ✅ Excellent | ✅ Good | ✅ Good — but zero values can be ambiguous |
| Readable call sites | ✅ Good | ✅ Great (method chaining) | ✅ Great (named fields) |
| Private internals | ✅ Yes | ✅ Yes | ❌ Usually no |
| Easy to test | ⚠️ Behavioral only | ✅ Yes | ✅ Yes |
| Idiomatic Go | ✅ Yes | ⚠️ Less common | ✅ Yes |
| Best for | Libraries & SDKs | Fluent / complex builders | Internal app code |
The short answer: if it goes in a go.mod someone else imports, use functional options. For internal application code, a config struct is simpler and equally good.
grpc-go server options source: https://github.com/grpc/grpc-go/blob/master/server.gouber-go/zap options source: https://github.com/uber-go/zap/blob/master/options.goaws-sdk-go-v2: https://github.com/aws/aws-sdk-go-v2