Function signatures are contracts
You write func add(a, b) in Go and the compiler yells at you. You're used to dynamic typing where arguments are optional, or languages that let you omit return types. Go demands you spell out the contract before you write the logic. func Add(a, b int) int. It looks verbose compared to Python or JavaScript. That verbosity is a feature. Go function signatures force you to declare exactly what a function needs, what it produces, and how it signals failure. The signature is the documentation. If you can't read the contract in the signature, the design needs work.
The anatomy of a signature
Every Go function follows the same structure: func Name(params) (returns). Parameters and return values are optional, but types are mandatory. You can return multiple values. You can name return values. You can define functions that take other functions. The syntax is rigid, which makes the codebase predictable. Anyone reading your code knows where to look for inputs and outputs. There are no hidden side effects in the signature.
Go doesn't have default parameters. If you need optional arguments, you use a struct configuration or a builder pattern. This keeps signatures honest. You don't hide complexity behind func DoThing(opts ...interface{}). You expose the shape of the data.
Minimal examples
Here's the anatomy of a Go function signature, from the simplest case to the standard error-handling pattern.
// Greet takes no arguments and returns nothing.
// This is valid but rare in library code.
func Greet() {
// Body goes here.
}
// Add takes two ints and returns one int.
// Types are explicit. No implicit conversion.
func Add(a, b int) int {
return a + b
}
// Divide returns a result and an error.
// This is the idiomatic way to handle failure in Go.
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
How the compiler enforces the contract
The compiler treats the signature as a strict contract. When you write func Add(a, b int) int, the compiler checks that a and b are both int. It checks that the return value is an int. If you try to return a float64, the compiler rejects the program with cannot use a / b (untyped float constant) as int value in return argument. Go doesn't do implicit coercion. You have to be explicit about type conversions.
Multiple returns change how callers interact with your code. The Divide function returns two values. The caller must handle both. result, err := Divide(10, 2). If you ignore the error, the compiler might not error if you assign to a blank identifier, but linters will flag it. The community convention is to handle errors immediately. if err != nil { return err } is verbose by design. The boilerplate makes the unhappy path visible. You can't accidentally swallow an error.
Named returns add another layer. func Echo(msg string) (result string) names the return value. You can assign to result and use a bare return. This is useful for short functions where the return value is obvious. It's dangerous in long functions because bare returns hide what's being returned. Use named returns sparingly.
// Echo demonstrates named returns.
// The return value is named 'result'.
func Echo(msg string) (result string) {
// Assign to the named return variable.
result = msg
// Bare return uses the named variable.
return
}
Realistic patterns in production code
Real code involves I/O, cancellation, and error wrapping. Signatures reflect these concerns. Here's how signatures look in a service layer, combining context, multiple returns, and named returns for clarity.
// GetUser fetches a user by ID.
// Context is the first parameter.
// Returns the user struct and an error.
func GetUser(ctx context.Context, id string) (User, error) {
// Check context cancellation early.
if err := ctx.Err(); err != nil {
return User{}, fmt.Errorf("context done: %w", err)
}
// Simulate database lookup.
user, err := db.FindByID(ctx, id)
if err != nil {
// Wrap the error to add context.
return User{}, fmt.Errorf("find user %s: %w", id, err)
}
return user, nil
}
The context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This is plumbing. Run it through every long-lived call site. The signature signals that this function can be cancelled.
Error wrapping uses %w in fmt.Errorf. This preserves the error chain. Callers can use errors.Is or errors.As to check for specific error types. The signature returns error, but the implementation carries rich context.
Receivers and methods
Methods are functions with a receiver. The receiver appears before the function name. func (r Receiver) Method(). The receiver can be a value or a pointer. This changes the semantics of the signature.
// Counter holds a count.
type Counter struct {
count int
}
// Increment modifies the counter.
// Pointer receiver allows mutation of the original value.
func (c *Counter) Increment() {
c.count++
}
// Value returns the count.
// Value receiver is safe for read-only access.
// It copies the struct, so the caller can't modify the original.
func (c Counter) Value() int {
return c.count
}
The receiver name is usually one or two letters matching the type: (c *Counter), not (this *Counter) or (self *Counter). This is a community convention. It keeps the signature clean.
Use a pointer receiver when the method modifies the receiver or when the receiver is large and you want to avoid copying. Use a value receiver when the method is read-only and the struct is small. The signature tells you whether the method mutates state.
Advanced patterns
Go supports variadic functions and function types. These are common in libraries and frameworks.
// Sum takes a variable number of ints.
// The ...int syntax collects arguments into a slice.
func Sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// Handler is a function type.
// It defines the signature for HTTP handlers.
// Functions matching this signature can be passed around.
type Handler func(http.ResponseWriter, *http.Request)
Variadic functions allow you to pass zero or more arguments. The ...int becomes a []int inside the function. This is useful for logging, formatting, and aggregation.
Function types let you define callbacks. Handler is a type that matches a specific signature. You can pass functions as arguments, store them in variables, and return them from other functions. This enables middleware patterns and dependency injection.
Pitfalls and compiler errors
Common mistakes in function signatures lead to compiler errors or runtime bugs.
If you forget types, the compiler rejects the program with syntax error: unexpected ). Go requires explicit types. You can't write func Add(a, b).
If you return the wrong number of values, the compiler complains with Add returns 1 value(s), expected 2. Multiple returns must match the signature exactly.
If you ignore an error, you might assign it to _. result, _ := Divide(10, 0) discards the error. The underscore discards a value intentionally. Use it sparingly with errors. Discarding an error should be a conscious decision, not an accident.
Don't pass a *string. Strings are already cheap to pass by value. They are immutable and small. Passing a pointer to a string adds indirection without benefit. The compiler won't stop you, but it's a code smell.
Public names start with a capital letter. Private names start lowercase. No keywords like public or private. func Public() is exported. func private() is not. This controls visibility at the package level.
Interfaces are accepted, structs are returned. "Accept interfaces, return structs" is the most common Go style mantra. If a function returns an interface, the caller is coupled to the implementation details. If it returns a struct, the caller can see the fields and use them directly.
Decision matrix
Use a single return value when the function always succeeds or when the result is the only output. Use multiple returns with an error when the function can fail and you need to distinguish between a valid zero-value result and an error. Use named returns when the function is short and the return values need documentation in the signature itself. Use a bare return only when the function is trivial and the named returns are immediately obvious. Use context.Context as the first parameter when the function performs I/O or can be cancelled. Use a method receiver when the function logically belongs to a type and modifies or reads its state. Use a plain function when the operation is stateless or acts on external inputs. Use a variadic parameter when the function accepts a variable number of arguments of the same type. Use a function type when you need to pass behavior as a value, such as callbacks or middleware.
Go signatures are contracts. Read them like a lawyer. Errors are values. Handle them or discard them intentionally. Context is plumbing. Run it through every long-lived call site. Named returns are a shortcut. Take them only when the destination is clear. Trust gofmt. Argue logic, not formatting.