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,resultwill 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 offunc 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 withslice....- 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.
deferguarantees 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.