Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Golang max: The Builtin, the Math Package, and Which One You Actually Want

Sean

Platform Writer

Aug 27, 2026
7 min read

Since Go 1.21 you do not need a helper function for max. There is a builtin, it is generic over ordered types, and it takes any number of arguments.

Golang max: The Builtin, the Math Package, and Which One You Actually Want

For most of Go’s life, asking for the maximum of two integers meant writing your own three-line function, because math.Max only spoke float64. That era is over, but the old advice is still all over Stack Overflow, and the two options behave differently in ways that matter once floats and NaN enter the picture.

Table of contents

The builtin: max(a, b, …)

Go 1.21 introduced min and max as builtins. They are not in a package, you do not import anything, and they work on any type that supports the < operator: integers, floats, and strings.

package main

import "fmt"

func main() {
    fmt.Println(max(3, 7))            // 7
    fmt.Println(max(3, 7, 12, 1))     // 12
    fmt.Println(max(2.5, 9.1))        // 9.1
    fmt.Println(max("apple", "pear")) // pear
}

Two properties are worth noticing. First, it accepts any number of arguments greater than one, so max(a, b, c, d) is legal and you do not have to nest calls. Second, it is evaluated at compile time when every argument is a constant, so const limit = max(10, 20) is a valid constant declaration.

The type rules follow Go’s usual ordered-type constraint. All arguments must be of the same type, or be untyped constants that convert to a single type. Mixing an int and a float64 will not compile, which is the correct outcome and saves you a class of silent precision bug.

Why math.Max still exists

math.Max predates generics and has the signature func Max(x, y float64) float64. It is still in the standard library, still works, and is still the right call in exactly one situation: when you need IEEE 754 special-value semantics.

The builtin and math.Max disagree about NaN. math.Max is specified to return NaN if either argument is NaN, and to treat positive infinity and negative zero according to the floating-point standard. The builtin follows the ordering rules of the < operator, and NaN compares false against everything.

import (
    "fmt"
    "math"
)

nan := math.NaN()
fmt.Println(math.Max(nan, 1.0))       // NaN
fmt.Println(math.Max(math.Inf(1), 5)) // +Inf

If your numbers come from parsing user input, sensor data, or a division that might produce NaN, be explicit about which behaviour you want rather than assuming the two are interchangeable. For ordinary application code where NaN is already a bug, the builtin is simpler and avoids the float64 conversion entirely.

The integer problem the builtin solved

Before 1.21, the canonical answer to how you max two ints in Go was to write it yourself, and every codebase had a slightly different copy.

// The pre-1.21 pattern, still in a lot of repos
func maxInt(a, b int) int {
    if a > b {
        return a
    }
    return b
}

// And the float dance, which loses precision above 2^53
func maxIntBad(a, b int) int {
    return int(math.Max(float64(a), float64(b)))
}

The second version is the one to hunt down in an old codebase. Converting an int64 to float64 is lossy above 2^53, so on 64-bit platforms maxIntBad silently returns the wrong answer for large values. It is the kind of bug that never shows up in tests written with small numbers and then surfaces once real IDs or timestamps flow through.

If you are on 1.21 or later, delete these helpers. If you are stuck on an older toolchain, the generic version below is the safe shape.

import "cmp"

func maxOf[T cmp.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

Max over a slice

The builtin takes arguments, not a slice, so max(nums...) does not compile. For a slice you either fold it yourself or reach for the standard library.

import "slices"

nums := []int{4, 19, 2, 33, 7}
fmt.Println(slices.Max(nums)) // 33

slices.Max panics on an empty slice, which is a deliberate choice: there is no sensible maximum of nothing, and returning a zero value would hide the mistake. Guard the call if the slice can legitimately be empty.

func safeMax(nums []int) (int, bool) {
    if len(nums) == 0 {
        return 0, false
    }
    return slices.Max(nums), true
}

For structs, use slices.MaxFunc with a comparison function. It returns the element itself, not just the key, which is usually what you actually wanted.

type Deploy struct {
    ID       string
    Duration int
}

slowest := slices.MaxFunc(deploys, func(a, b Deploy) int {
    return cmp.Compare(a.Duration, b.Duration)
})

Where this shows up in real services

The most common production use of max is not arithmetic, it is clamping. Backoff intervals, connection pool sizes, worker counts, and cache TTLs all want a floor or a ceiling, and the builtin reads better than an if block.

// Exponential backoff with a ceiling
delay := min(baseDelay*(1<<attempt), 30*time.Second)

// Never fewer than 2 workers, never more than the CPU count
workers := max(2, min(runtime.NumCPU(), configured))

That second line is worth copying. A Go service that reads its worker count from an environment variable will eventually be handed a zero by a misconfigured deploy, and a worker pool of zero is a hang, not a crash. Clamping at read time turns a silent outage into a service that runs slightly slower than intended.

The same applies to memory-sensitive limits. If a service sizes a buffer from configuration, clamping the value means a bad config costs you throughput instead of an out-of-memory kill mid-request.

How this fits the rest of the stack

Clamping worker counts and backoff ceilings only matters if you know what the service is allowed to consume. That number comes from the plan it runs on, and the RunxBuild hosting calculator lays the line items out together: the service, the database beside it, the storage, and the bandwidth. Push a Go repo, get a build log and a live route, and the vCPU and RAM figures you are clamping against stop being guesses.

Useful related references:

FAQ

Which Go version added the max builtin?

Go 1.21, released in August 2023, added min and max as builtins alongside the clear builtin. They require no import. If max(3, 7) fails to compile with an undefined error, check the go directive in your go.mod - the language version declared there gates builtin availability, not just the installed toolchain.

Can max take a slice in Go?

No. The builtin takes a fixed argument list, so max(nums...) is a compile error. Use slices.Max(nums) from the standard library instead, or slices.MaxFunc when you need to compare by a field. Note that slices.Max panics on an empty slice, so guard it when the input can be empty.

Is math.Max deprecated now?

No, and it will not be. It has different semantics from the builtin around NaN, infinities, and signed zero, so it remains the correct choice when you need IEEE 754 behaviour. For ordinary integer and float comparisons in application code, the builtin is simpler and avoids the float64 conversion entirely.

Does max work on strings in Go?

Yes. The builtin works on any ordered type, which includes strings. Comparison is byte-wise lexicographic, so uppercase letters sort before lowercase ones because of their byte values. For human-facing sorting you want a collation-aware comparison, not the builtin.

What is the difference between max and slices.Max?

max is a builtin that takes two or more individual arguments and works at compile time on constants. slices.Max is a library function that takes a single slice and works at runtime. Use the builtin when you have named values, and slices.Max when you have a collection.

#golang max#go builtin max#math.Max#go generics#go 1.21