Functions #

Functions are the primary composition unit in Go. There are no classes, no inheritance — the way you organize and reuse logic is through functions. But functions in Go are far more expressive than they appear on the surface: they can return multiple values at once, can be stored in variables and passed as arguments, can form closures that “remember” state from the scope where they were created, and have defer, which changes how you think about resource cleanup. Understanding all these aspects is the key to writing truly idiomatic Go.

Function Anatomy #

A function declaration in Go follows a consistent order. The components of a function declaration can be visualized in the following diagram:

flowchart LR
    Func["func (Keyword)"] --> Name["add (Function Name)"]
    Name --> Params["(a int, b int) (Parameters & Types)"]
    Params --> Ret["int (Return Type)"]
    Ret --> Body["{ return a + b } (Code Block/Body)"]
//  keyword  name       parameters           return type
//     ↓      ↓            ↓                   ↓
    func  add      (a int, b int)           int    {
        return a + b
    }

Each part has its rules:

// The simplest function — no parameters, no return
func sayHello() {
    fmt.Println("Hello!")
}

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

// With a return value
func square(n int) int {
    return n * n
}

// With multiple parameters and a return value
func divide(a, b float64) float64 {
    return a / b
}

Parameters — Pass by Value #

All parameters in Go are passed by value — the function receives a copy of the value sent, not a reference to the original. Changes to a parameter inside the function don’t affect the caller’s original variable:

func doubleIt(n int) {
    n = n * 2  // only modifies the local copy
    fmt.Println("inside the function:", n)
}

func main() {
    x := 5
    doubleIt(x)
    fmt.Println("outside the function:", x)  // still 5!
}
// Output:
// inside the function: 10
// outside the function: 5

To modify the original value, use a pointer (covered in the Data Types article) or return a new value:

// The idiomatic Go way: return a new value, don't mutate
func double(n int) int {
    return n * 2
}

x := 5
x = double(x)  // assign the result to x
fmt.Println(x)  // 10

Shortening Consecutive Same-Type Parameters #

When several consecutive parameters have the same type, the type only needs to be written once at the end:

// Verbose — the type is written repeatedly
func add(a int, b int, c int) int { return a + b + c }

// Idiomatic — the type is shortened
func add(a, b, c int) int { return a + b + c }

// Mixed — different types are still written individually
func createUser(name, email string, age int, active bool) *User {
    return &User{Name: name, Email: email, Age: age, Active: active}
}

Multiple Return Values #

This is one of Go’s features that most sets it apart from other languages. Functions can return more than one value:

// Two return values
func minMax(nums []int) (int, int) {
    if len(nums) == 0 {
        return 0, 0
    }
    min, max := nums[0], nums[0]
    for _, n := range nums[1:] {
        if n < min { min = n }
        if n > max { max = n }
    }
    return min, max
}

func main() {
    minimum, maximum := minMax([]int{3, 1, 4, 1, 5, 9, 2, 6})
    fmt.Println(minimum, maximum)  // 1 9
}

The (result, error) Pattern — Go’s Standard Convention #

Multiple return values are most often used to return both a result and an error. This is an extremely consistent convention across the entire Go standard library and ecosystem:

// The error is always the LAST return value — this is a mandatory convention
func parseAge(s string) (int, error) {
    age, err := strconv.Atoi(s)
    if err != nil {
        return 0, fmt.Errorf("parseAge: invalid input %q: %w", s, err)
    }
    if age < 0 || age > 150 {
        return 0, fmt.Errorf("parseAge: age %d outside the valid range", age)
    }
    return age, nil
}

func main() {
    // Always handle both return values
    age, err := parseAge("25")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Age:", age)

    // Ignore one with the blank identifier — be careful with errors!
    age2, _ := parseAge("30")  // only safe if you're SURE there's no error
    fmt.Println("Age 2:", age2)
}
Don’t ignore errors with _ carelessly. result, _ := someFunc() is syntactically valid but dangerous — if the function fails, result will hold a zero value and the program may misbehave without a clear error message. Always handle errors unless there’s a very strong reason not to.

Named Return Values #

Return values can be named, making them variables usable directly inside the function body:

// Without named returns — unclear which value is which from the signature
func getStats(nums []float64) (float64, float64, float64) { ... }

// With named returns — the signature is more descriptive
func getStats(nums []float64) (min, max, avg float64) {
    if len(nums) == 0 {
        return  // "naked return" — returns min, max, avg which are still zero
    }
    min, max = nums[0], nums[0]
    sum := 0.0
    for _, n := range nums {
        if n < min { min = n }
        if n > max { max = n }
        sum += n
    }
    avg = sum / float64(len(nums))
    return  // naked return — returns min, max, avg which are already filled
}

Named returns are most useful for:

  • Documenting the meaning of each return value in the signature
  • Short functions where naked returns are still easy to understand
  • Deferred functions that need to modify the return value
// A defer modifying a named return value — a powerful pattern
func readFileWithCleanup(path string) (content []byte, err error) {
    f, err := os.Open(path)
    if err != nil {
        return  // naked return: content=nil, err=the error from Open
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr  // assign to the named return 'err'
        }
    }()

    content, err = io.ReadAll(f)
    return  // naked return: content and err from ReadAll
}
Avoid naked returns in long functions. When a function is more than 15-20 lines, naked returns make it hard for readers because they have to scroll up to see the return variable names. In long functions, prefer explicit returns: return content, err.

Variadic Functions #

Variadic functions accept an unspecified number of arguments. The variadic parameter is marked with ... before its type and must always be the last parameter:

// numbers is []int inside the function
func sum(numbers ...int) int {
    total := 0
    for _, n := range numbers {
        total += n
    }
    return total
}

func main() {
    fmt.Println(sum())              // 0    — valid, numbers = []int{}
    fmt.Println(sum(1))             // 1
    fmt.Println(sum(1, 2, 3))       // 6
    fmt.Println(sum(1, 2, 3, 4, 5)) // 15

    // Spread a slice into a variadic with the ... operator
    numbers := []int{10, 20, 30}
    fmt.Println(sum(numbers...))      // 60
}

Variadic with Regular Parameters #

// Regular parameters first, variadic at the end
func logWithPrefix(prefix string, messages ...string) {
    for _, msg := range messages {
        fmt.Printf("[%s] %s\n", prefix, msg)
    }
}

logWithPrefix("INFO", "server started", "listening on :8080")
logWithPrefix("ERROR", "database connection failed")
// Output:
// [INFO] server started
// [INFO] listening on :8080
// [ERROR] database connection failed

Variadic vs Slice Parameter #

// Variadic — the caller doesn't need to build a slice
func sumVariadic(nums ...int) int { ... }
sumVariadic(1, 2, 3)     // ✓ natural
sumVariadic(numbers...)    // ✓ spread a slice

// Slice parameter — the caller must build a slice
func sumSlice(nums []int) int { ... }
sumSlice([]int{1, 2, 3}) // must wrap with []int{}
sumSlice(numbers)          // ✓ slice directly

Use variadic when callers more often pass values directly one by one. Use a slice parameter when the data is already in slice form.


Functions as First-Class Citizens #

In Go, functions are values just like int or string. You can store them in variables, pass them as arguments, and return them from other functions.

Function Types #

Every function has a type defined by its signature:

// Type: func(int, int) int
add := func(a, b int) int { return a + b }

// Type: func(string) bool
isLong := func(s string) bool { return len(s) > 10 }

// Type: func() error
connect := func() error { return db.Connect() }

// Defining named function types
type Transformer func(string) string
type Predicate func(int) bool
type Handler func(http.ResponseWriter, *http.Request)

Anonymous Functions #

Anonymous functions are functions without a name — they can be stored in variables, passed as arguments, or called immediately:

func main() {
    // Stored in a variable
    multiply := func(a, b int) int {
        return a * b
    }
    fmt.Println(multiply(3, 4))  // 12

    // IIFE — Immediately Invoked Function Expression
    result := func(x int) int {
        return x * x
    }(5)
    fmt.Println(result)  // 25

    // Passed as an argument
    numbers := []int{3, 1, 4, 1, 5, 9}
    sort.Slice(numbers, func(i, j int) bool {
        return numbers[i] < numbers[j]  // ascending
    })
    fmt.Println(numbers)  // [1 1 3 4 5 9]
}

Closures — Functions That Remember State #

A closure is a function that “captures” and “remembers” variables from the scope where it was created, even after that scope has finished. Captured variables are called captured variables:

func makeCounter(start int) func() int {
    count := start  // count is captured by the closure below
    return func() int {
        count++
        return count
    }
}

func main() {
    counter1 := makeCounter(0)
    counter2 := makeCounter(100)

    fmt.Println(counter1())  // 1
    fmt.Println(counter1())  // 2
    fmt.Println(counter1())  // 3
    fmt.Println(counter2())  // 101  — state is separate from counter1
    fmt.Println(counter1())  // 4    — counter1 continues from where it left off
}

Captured variables are shared among all closures that capture them from the same scope — this is why closures are so useful for hidden state.

Closures as Middleware or Decorators #

// Logger decorator — wraps any function with logging
func withLogging(name string, fn func() error) func() error {
    return func() error {
        start := time.Now()
        fmt.Printf("[%s] starting...\n", name)

        err := fn()  // call the original function

        elapsed := time.Since(start)
        if err != nil {
            fmt.Printf("[%s] failed in %v: %v\n", name, elapsed, err)
        } else {
            fmt.Printf("[%s] finished in %v\n", name, elapsed)
        }
        return err
    }
}

// Usage
processData := withLogging("processData", func() error {
    time.Sleep(100 * time.Millisecond)
    return nil
})
processData()
// Output:
// [processData] starting...
// [processData] finished in 100.12ms

// Closure-based rate limiter
func rateLimiter(maxPerSec int) func() bool {
    tokens := maxPerSec
    lastRefill := time.Now()
    return func() bool {
        now := time.Now()
        elapsed := now.Sub(lastRefill).Seconds()
        tokens += int(elapsed * float64(maxPerSec))
        if tokens > maxPerSec {
            tokens = maxPerSec
        }
        lastRefill = now

        if tokens <= 0 {
            return false
        }
        tokens--
        return true
    }
}

Higher-Order Functions — Map, Filter, Reduce #

A higher-order function is one that accepts or returns another function. This pattern is very common in idiomatic Go:

// Map — transform each element
func mapInts(s []int, fn func(int) int) []int {
    result := make([]int, len(s))
    for i, v := range s {
        result[i] = fn(v)
    }
    return result
}

// Filter — select elements satisfying a predicate
func filterInts(s []int, fn func(int) bool) []int {
    var result []int
    for _, v := range s {
        if fn(v) {
            result = append(result, v)
        }
    }
    return result
}

// Reduce — aggregate all elements into one value
func reduceInts(s []int, initial int, fn func(int, int) int) int {
    result := initial
    for _, v := range s {
        result = fn(result, v)
    }
    return result
}

func main() {
    numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

    // Multiply everything by 2
    doubled := mapInts(numbers, func(n int) int { return n * 2 })
    fmt.Println(doubled)  // [2 4 6 8 10 12 14 16 18 20]

    // Take only the even ones
    evens := filterInts(numbers, func(n int) bool { return n%2 == 0 })
    fmt.Println(evens)  // [2 4 6 8 10]

    // Sum everything
    total := reduceInts(numbers, 0, func(acc, n int) int { return acc + n })
    fmt.Println(total)  // 55

    // Chain: take evens, multiply by 3, sum
    result := reduceInts(
        mapInts(
            filterInts(numbers, func(n int) bool { return n%2 == 0 }),
            func(n int) int { return n * 3 },
        ),
        0,
        func(acc, n int) int { return acc + n },
    )
    fmt.Println(result)  // (2+4+6+8+10)*3 = 90
}

defer — Guaranteed Cleanup #

defer postpones the execution of a statement until the containing function finishes — whether it finishes normally, or because of a return or even a panic. This guarantees cleanup code always runs:

func readFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()  // GUARANTEED to be called when readFile() finishes

    return io.ReadAll(f)
}

func withDB(fn func(*sql.DB) error) error {
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return err
    }
    defer db.Close()  // the connection is always closed

    return fn(db)
}

LIFO Order — Last In, First Out #

Multiple defers in one function execute in reverse order of their calls:

func main() {
    fmt.Println("start")
    defer fmt.Println("defer 1 — executed third")
    defer fmt.Println("defer 2 — executed second")
    defer fmt.Println("defer 3 — executed first")
    fmt.Println("done")
}
// Output:
// start
// done
// defer 3 — executed first
// defer 2 — executed second
// defer 1 — executed third

This LIFO order is very useful for nested lock/unlock — the first unlock is always the last lock:

func safeTransaction(mu1, mu2 *sync.Mutex) {
    mu1.Lock()
    defer mu1.Unlock()  // executed second (after mu2)

    mu2.Lock()
    defer mu2.Unlock()  // executed first
    
    // critical operation
}

Defer Arguments Are Evaluated Eagerly #

This is a gotcha that often surprises people: the arguments of a deferred function are evaluated when the defer is called, not when the deferred function executes:

func example() {
    x := 10
    defer fmt.Println("value of x:", x)  // x=10 is evaluated NOW

    x = 20
    fmt.Println("x now:", x)
}
// Output:
// x now: 20
// value of x: 10  ← not 20! because x=10 was captured when defer was called

// To use the latest value, use a closure
func example2() {
    x := 10
    defer func() {
        fmt.Println("value of x:", x)  // x is evaluated when the closure runs
    }()

    x = 20
    fmt.Println("x now:", x)
}
// Output:
// x now: 20
// value of x: 20  ← the closure reads the latest x

Recursion #

Functions in Go can call themselves. Recursion is useful for problems with a naturally recursive nature like tree traversal, divide and conquer, or mathematical computations:

// Recursive Fibonacci — easy to understand but inefficient (O(2^n))
func fib(n int) int {
    if n <= 1 {
        return n
    }
    return fib(n-1) + fib(n-2)
}

// Fibonacci with memoization — efficient O(n)
func fibMemo(n int, memo map[int]int) int {
    if n <= 1 {
        return n
    }
    if v, ok := memo[n]; ok {
        return v
    }
    result := fibMemo(n-1, memo) + fibMemo(n-2, memo)
    memo[n] = result
    return result
}

// Recursive tree traversal
type TreeNode struct {
    Val   int
    Left  *TreeNode
    Right *TreeNode
}

func inorderTraversal(node *TreeNode, result *[]int) {
    if node == nil {
        return
    }
    inorderTraversal(node.Left, result)
    *result = append(*result, node.Val)
    inorderTraversal(node.Right, result)
}

Naming and Size Best Practices #

FUNCTION NAMING:
  ✓ Use verbs or verb phrases: getUser, calculateTotal, sendEmail
  ✓ Exported: PascalCase — GetUser, CalculateTotal
  ✓ Unexported: camelCase — getUser, calculateTotal
  ✓ Short names for local functions: parse, build, validate
  ✗ Avoid redundancy with the package: user.GetUser → user.Get

FUNCTION SIZE:
  ✓ One function, one responsibility (Single Responsibility)
  ✓ Ideally fits on one screen (< 40-50 lines)
  ✓ If a function needs more than 3 indentation levels, split it into subfunctions
  ✗ Avoid "god functions" that do too many things at once

PARAMETERS:
  ✓ Maximum 3-4 parameters — beyond that, consider a struct
  ✓ Use a struct for configuration with many optional options
  ✗ Avoid boolean parameters that drastically change function behavior

Complete Example Program #

The following program builds a text processing pipeline using various function concepts:

package main

import (
    "fmt"
    "strings"
    "unicode"
)

// Function types for the pipeline
type TextProcessor func(string) string
type TextFilter func(string) bool

// Pipeline — run a series of processors sequentially
func pipeline(processors ...TextProcessor) TextProcessor {
    return func(text string) string {
        result := text
        for _, proc := range processors {
            result = proc(result)
        }
        return result
    }
}

// Processor factory functions — return a TextProcessor
func trimmer() TextProcessor {
    return strings.TrimSpace
}

func normalizer() TextProcessor {
    return func(s string) string {
        // Normalize repeated spaces
        words := strings.Fields(s)
        return strings.Join(words, " ")
    }
}

func lowercaser() TextProcessor {
    return strings.ToLower
}

func replacer(old, new string) TextProcessor {
    return func(s string) string {
        return strings.ReplaceAll(s, old, new)
    }
}

func censor(words []string) TextProcessor {
    badWords := make(map[string]bool)
    for _, w := range words {
        badWords[strings.ToLower(w)] = true
    }

    return func(s string) string {
        tokens := strings.Fields(s)
        for i, t := range tokens {
            // Take only letters for checking
            clean := strings.Map(func(r rune) rune {
                if unicode.IsLetter(r) {
                    return unicode.ToLower(r)
                }
                return -1
            }, t)
            if badWords[clean] {
                tokens[i] = strings.Repeat("*", len(t))
            }
        }
        return strings.Join(tokens, " ")
    }
}

// Filter factory functions
func minLength(n int) TextFilter {
    return func(s string) bool {
        return len(strings.TrimSpace(s)) >= n
    }
}

func notEmpty() TextFilter {
    return func(s string) bool {
        return strings.TrimSpace(s) != ""
    }
}

// Process a batch of texts with a pipeline and filters
func processTexts(texts []string, proc TextProcessor, filters ...TextFilter) []string {
    var results []string

    for _, text := range texts {
        processed := proc(text)

        // Apply all filters — the closure captures filters
        passes := true
        for _, filter := range filters {
            if !filter(processed) {
                passes = false
                break
            }
        }

        if passes {
            results = append(results, processed)
        }
    }

    return results
}

// Text statistics using closures for the accumulator
func makeStatsCollector() (func(string), func() map[string]int) {
    stats := map[string]int{
        "total":    0,
        "words":    0,
        "chars":    0,
        "maxWords": 0,
    }

    collect := func(text string) {
        words := len(strings.Fields(text))
        stats["total"]++
        stats["words"] += words
        stats["chars"] += len(text)
        if words > stats["maxWords"] {
            stats["maxWords"] = words
        }
    }

    getStats := func() map[string]int {
        // Return a copy so it can't be modified from outside
        result := make(map[string]int)
        for k, v := range stats {
            result[k] = v
        }
        return result
    }

    return collect, getStats
}

func main() {
    rawTexts := []string{
        "  Hello,   World!  ",
        "",
        "  Go is   AWESOME for backend  ",
        "   ",
        "The quick brown fox jumps over the lazy dog",
        "  spam   content   here  ",
        "Building scalable systems with Go",
    }

    // Build a pipeline with several processors
    proc := pipeline(
        trimmer(),
        normalizer(),
        lowercaser(),
        replacer("go", "golang"),
        censor([]string{"spam"}),
    )

    // Process with filters
    results := processTexts(
        rawTexts,
        proc,
        notEmpty(),
        minLength(10),
    )

    // Collect statistics using closures
    collect, getStats := makeStatsCollector()

    fmt.Println("=== Text Processing Results ===\n")
    for i, text := range results {
        fmt.Printf("%d. %s\n", i+1, text)
        collect(text)
    }

    stats := getStats()
    fmt.Printf("\n=== Statistics ===\n")
    fmt.Printf("Total texts processed : %d\n", stats["total"])
    fmt.Printf("Total words           : %d\n", stats["words"])
    fmt.Printf("Total characters      : %d\n", stats["chars"])
    fmt.Printf("Most words            : %d words\n", stats["maxWords"])
    if stats["total"] > 0 {
        fmt.Printf("Average words/text    : %.1f\n",
            float64(stats["words"])/float64(stats["total"]))
    }

    // Demonstrate defer
    fmt.Println("\n=== Defer Demo ===")
    func() {
        fmt.Println("Function started")
        defer fmt.Println("Cleanup 1 — executed last first")
        defer fmt.Println("Cleanup 2 — executed very first")
        fmt.Println("Function finished")
    }()
}

Summary #

  • Pass by value — functions receive copies of parameters; use pointers or return new values to modify original data.
  • Same-type parameters can be shortened: func f(a, b, c int) instead of func f(a int, b int, c int).
  • Multiple return values — the (result, error) pattern is Go’s standard convention; the error is always the last return value.
  • Named returns are useful for documenting signatures and for defers that modify returns — avoid naked returns in long functions.
  • Variadic functions with ...T — callers can pass values directly; spread slices with slice....
  • Functions are first-class citizens — storable in variables, passable as arguments, returnable from other functions.
  • Closures capture variables from their creation scope — useful for hidden state, factory functions, and middleware.
  • defer guarantees cleanup always runs; multiple defers execute LIFO; defer arguments are evaluated eagerly when defer is called.
  • Higher-order functions (map, filter, reduce) enable clean logic composition that can be tested separately.
  • One function, one responsibility — if a function is too long or has too many parameters, split it into smaller functions.

← Previous: Loops   Next: Struct →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact