Go Functions: Parameters, Returns, and Variadic

Go functions follow a straightforward syntax that prioritizes clarity. Every function declares its parameters with explicit types, and Go requires you to use every parameter you declare—no unused...

Key Insights

  • Go functions pass arguments by value, but slices, maps, and channels have reference semantics—understanding this distinction prevents bugs and unnecessary pointer usage
  • Variadic functions using ... syntax provide flexibility for APIs, but should be combined with regular parameters carefully to maintain clear function signatures
  • Named return values improve documentation but naked returns harm readability—use named returns for complex functions while explicitly returning values

Function Basics and Parameter Passing

Go functions follow a straightforward syntax that prioritizes clarity. Every function declares its parameters with explicit types, and Go requires you to use every parameter you declare—no unused variables allowed.

func greet(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

func calculateArea(width int, height int) int {
    return width * height
}

When multiple consecutive parameters share the same type, Go provides a shorthand syntax that reduces repetition:

func add(x, y int) int {
    return x + y
}

func createUser(firstName, lastName string, age int) {
    // firstName and lastName are both strings
    fmt.Printf("%s %s is %d years old\n", firstName, lastName, age)
}

This shorthand works for any number of consecutive same-type parameters. It’s purely syntactic sugar—the compiler treats both forms identically.

Return Values

Go’s approach to return values sets it apart from many languages. Functions can return multiple values, which enables Go’s idiomatic error handling pattern without exceptions.

func divide(a, b float64) float64 {
    return a / b
}

func safeDivide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("cannot divide by zero")
    }
    return a / b, nil
}

The multiple return value pattern appears everywhere in Go code. The convention places the error as the last return value, allowing you to check for errors immediately:

result, err := safeDivide(10, 0)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result)

Go supports named return values, which act as variables declared at the top of the function:

func split(sum int) (x, y int) {
    x = sum * 4 / 9
    y = sum - x
    return x, y  // explicit return
}

func splitNaked(sum int) (x, y int) {
    x = sum * 4 / 9
    y = sum - x
    return  // naked return
}

Named returns document what the function returns, but naked returns (returning without specifying values) hurt readability in longer functions. Use naked returns only in short, simple functions.

When you don’t need all return values, use the blank identifier:

result, _ := safeDivide(10, 2)  // ignore error
_, err := safeDivide(10, 0)     // ignore result, check error only

Pass by Value vs Pass by Reference

Go passes all arguments by value. The function receives a copy of whatever you pass. For basic types, this behavior is straightforward:

func increment(n int) {
    n++
    fmt.Println("Inside function:", n)
}

func main() {
    count := 5
    increment(count)
    fmt.Println("Outside function:", count)
    // Output:
    // Inside function: 6
    // Outside function: 5
}

The same copy behavior applies to structs:

type User struct {
    Name string
    Age  int
}

func updateAge(u User, newAge int) {
    u.Age = newAge  // modifies the copy
}

func main() {
    user := User{Name: "Alice", Age: 30}
    updateAge(user, 31)
    fmt.Println(user.Age)  // still 30
}

To modify the original struct, pass a pointer:

func updateAgePointer(u *User, newAge int) {
    u.Age = newAge  // modifies original
}

func main() {
    user := User{Name: "Alice", Age: 30}
    updateAgePointer(&user, 31)
    fmt.Println(user.Age)  // now 31
}

Here’s where it gets interesting: slices, maps, and channels have reference semantics despite being passed by value. When you pass a slice, you pass a copy of the slice header (pointer, length, capacity), but both the original and copy point to the same underlying array:

func modifySlice(nums []int) {
    nums[0] = 999  // modifies underlying array
}

func replaceSlice(nums []int) {
    nums = []int{1, 2, 3}  // only replaces local copy
}

func main() {
    numbers := []int{10, 20, 30}
    modifySlice(numbers)
    fmt.Println(numbers)  // [999 20 30]
    
    replaceSlice(numbers)
    fmt.Println(numbers)  // still [999 20 30]
}

For maps, the same principle applies—you can modify map contents, but reassigning the map variable only affects the local copy:

func addToMap(m map[string]int, key string, value int) {
    m[key] = value  // modifies original map
}

Variadic Functions

Variadic functions accept a variable number of arguments using the ... syntax. The variadic parameter becomes a slice inside the function:

func sum(numbers ...int) int {
    total := 0
    for _, n := range numbers {
        total += n
    }
    return total
}

func main() {
    fmt.Println(sum(1, 2, 3))        // 6
    fmt.Println(sum(1, 2, 3, 4, 5))  // 15
    fmt.Println(sum())               // 0
}

You can mix regular parameters with variadic parameters, but the variadic parameter must come last:

func greetAll(greeting string, names ...string) {
    for _, name := range names {
        fmt.Printf("%s, %s!\n", greeting, name)
    }
}

func main() {
    greetAll("Hello", "Alice", "Bob", "Charlie")
}

To pass a slice to a variadic function, use the ... operator:

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    total := sum(numbers...)  // expands slice
    fmt.Println(total)
}

A practical example shows variadic functions’ real value—building flexible APIs:

type LogLevel int

const (
    INFO LogLevel = iota
    WARNING
    ERROR
)

func log(level LogLevel, format string, args ...interface{}) {
    prefix := map[LogLevel]string{
        INFO:    "[INFO]",
        WARNING: "[WARN]",
        ERROR:   "[ERROR]",
    }
    fmt.Printf("%s "+format+"\n", append([]interface{}{prefix[level]}, args...)...)
}

func main() {
    log(INFO, "Server started on port %d", 8080)
    log(ERROR, "Failed to connect to %s: %v", "database", "timeout")
}

Function Types and Higher-Order Functions

Go treats functions as first-class citizens. You can assign functions to variables, pass them as arguments, and return them from other functions.

type Operation func(int, int) int

func add(a, b int) int {
    return a + b
}

func multiply(a, b int) int {
    return a * b
}

func apply(a, b int, op Operation) int {
    return op(a, b)
}

func main() {
    result := apply(5, 3, add)
    fmt.Println(result)  // 8
    
    result = apply(5, 3, multiply)
    fmt.Println(result)  // 15
}

Functions can return other functions, creating closures that capture variables from their surrounding scope:

func multiplier(factor int) func(int) int {
    return func(n int) int {
        return n * factor
    }
}

func main() {
    double := multiplier(2)
    triple := multiplier(3)
    
    fmt.Println(double(5))  // 10
    fmt.Println(triple(5))  // 15
}

This pattern enables powerful abstractions like middleware in web applications:

type Handler func(string) string

func loggingMiddleware(next Handler) Handler {
    return func(input string) string {
        fmt.Printf("Input: %s\n", input)
        result := next(input)
        fmt.Printf("Output: %s\n", result)
        return result
    }
}

func uppercase(s string) string {
    return strings.ToUpper(s)
}

func main() {
    handler := loggingMiddleware(uppercase)
    handler("hello")
}

Best Practices and Common Patterns

Always handle errors explicitly. Don’t ignore them unless you have a specific reason:

// Good
file, err := os.Open("data.txt")
if err != nil {
    return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

// Bad
file, _ := os.Open("data.txt")  // ignoring potential error

The functional options pattern uses variadic functions to create flexible, backward-compatible APIs:

type Server struct {
    host    string
    port    int
    timeout time.Duration
}

type Option func(*Server)

func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}

func WithTimeout(timeout time.Duration) Option {
    return func(s *Server) {
        s.timeout = timeout
    }
}

func NewServer(host string, options ...Option) *Server {
    server := &Server{
        host:    host,
        port:    8080,  // default
        timeout: 30 * time.Second,  // default
    }
    
    for _, option := range options {
        option(server)
    }
    
    return server
}

func main() {
    server := NewServer("localhost", 
        WithPort(9000),
        WithTimeout(60*time.Second))
}

Use named returns to document complex return values, but always return explicitly in functions longer than a few lines:

// Good: named returns for documentation, explicit return
func parseUser(data string) (name string, age int, err error) {
    parts := strings.Split(data, ",")
    if len(parts) != 2 {
        return "", 0, fmt.Errorf("invalid format")
    }
    
    name = parts[0]
    age, err = strconv.Atoi(parts[1])
    if err != nil {
        return "", 0, fmt.Errorf("invalid age: %w", err)
    }
    
    return name, age, nil  // explicit
}

Keep functions focused and small. If a function does multiple things, split it. Use descriptive names that reveal intent. Go’s explicit error handling, multiple return values, and first-class functions give you the tools to write clear, maintainable code—use them wisely.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.