min, max & clear Built-ins — Senior Level¶
Table of Contents¶
- Introduction
- Why These Are Built-ins: The Design Rationale
- The Cost Argument: Built-in vs Generic Function
- Floating-Point Semantics as an API Decision
- The
math.Min/math.MaxLegacy and Why It Stays clearand the NaN-Key Motivationclear, Memory, and Reference Hygiene- Designing APIs Around Ordered Built-ins
- Migration at Scale
- Interaction with the
cmp,slices, andmapsPackages - Code Review Heuristics
- Anti-Patterns
- Senior-Level Checklist
- Summary
Introduction¶
A senior engineer's interest in min, max, and clear is not "how do I call them" but "why did the language take the unusual step of adding three built-ins in one release, what guarantees do they encode, and how do I steer a large codebase onto them without introducing behavioural drift." These are small features, but they are language changes — predeclared identifiers that every Go program now sees — and the reasoning behind them is a useful case study in API design under backward-compatibility constraints.
The mechanical content is in junior.md and middle.md. This file is about the design, the trade-offs, and the migration.
After reading this you will: - Articulate why min/max/clear are built-ins rather than library functions - Reason about their performance relative to the generic alternatives they replace - Treat their floating-point semantics as a deliberate API contract - Use clear correctly for both correctness (NaN keys) and memory hygiene - Drive a large-scale migration off hand-rolled helpers - Apply review heuristics that catch the real bugs these features introduce
Why These Are Built-ins: The Design Rationale¶
Adding to the set of predeclared identifiers is a heavy hammer. Every existing program that used min, max, or clear as ordinary identifiers had to keep working — and they do, because Go's predeclared identifiers can be shadowed by local declarations. A package-level func max(...) or a local min := ... still wins over the built-in. This is the compatibility escape hatch that made the addition safe.
So why not just ship cmp.Or-style library functions? Three reasons drove the built-in decision:
-
Constant evaluation. Only a built-in (or operator) can be evaluated at compile time.
min(64, 128)as an array size, orconst C = max(A, B), requires the operation to be a constant expression. A function call — generic or not — is a runtime construct and cannot appear where a constant is required. This alone forecloses the library-function approach for the constant use cases. -
Variadic over any count with no allocation. A generic
Max[T](a, b T)covers two operands.Max[T](xs ...T)covers any count but allocates a slice for the variadic pack. The built-inmax(a, b, c, d)covers any fixed count with zero allocation and no slice. Built-ins are exempt from the normal "variadic means slice" cost. -
Universality and discoverability.
min/maxare so fundamental that requiring an import (and a choice of which package —math?constraints? a local helper?) created friction and fragmentation. Making them predeclared gives the ecosystem one canonical spelling, the waylenandappendare canonical.
clear is built-in for a different reason: it expresses an operation (empty a map including unreachable keys; bulk-zero a slice) that cannot be written in Go at all with the same guarantees. A library Clear(m) looping delete cannot remove NaN keys. Only the runtime, reached through a built-in, can.
The Cost Argument: Built-in vs Generic Function¶
Before 1.21, the idiomatic "modern" max was a generic function:
This is correct and, after monomorphization and inlining, often compiles to the same machine code as the built-in for a two-argument call. So the runtime cost difference for max(a, b) is frequently nil — both become a compare-and-move.
The differences that matter are elsewhere:
- Compile-time constants. The generic function can never produce a constant. The built-in can. This is a capability gap, not a speed gap.
- Variadic without allocation.
Max(xs...)allocates;max(a, b, c)does not. For a fixed set of three or four values, the built-in is strictly cheaper. - Instantiation and binary size. Each distinct type instantiation of a generic
Maxemits code (or shares a gcshape stencil). The built-in adds nothing to the binary beyond the inlined comparison at each call site — which the generic version also does once inlined, but the dictionary/stencil machinery is avoided entirely. - Inlining reliability. The built-in is lowered directly by the compiler; it is always "inlined" in the sense that there is never a call. A generic helper relies on the inliner's budget and heuristics, which usually but not always fire.
The honest senior framing: for a simple two-argument call the performance is a wash, and the built-in wins on capability (constants), allocation (variadic), and clarity (no import, one spelling). Do not migrate for speed; migrate for those.
Floating-Point Semantics as an API Decision¶
The language designers had to choose how min/max treat NaN and signed zero. The chosen contract:
- NaN propagates. Any NaN argument yields a NaN result.
- Negative zero orders below non-negative zero.
min(-0.0, 0.0) == -0.0;max(-0.0, 0.0) == 0.0. - Infinities are extremes.
This is a total, consistent ordering decision, and it is the right one for a language built-in for two reasons. First, NaN propagation is fail-loud: if a NaN reaches your reduction, the result is visibly NaN rather than silently the largest non-NaN value. A "skip NaN" semantics would hide data corruption. Second, the signed-zero rule makes min/max a total order consistent with < extended to handle the -0 == 0 tie deterministically, which keeps sorting and reduction predictable.
The cost of this contract is that callers with NaN-bearing data must filter explicitly. That is a deliberate trade: the language refuses to guess what you meant by max(value, NaN). As an API author building on top, you inherit the same decision — if your function reduces user floats, decide and document whether NaN is an error, is filtered, or is allowed to poison.
The math.Min/math.Max Legacy and Why It Stays¶
math.Min and math.Max predate the built-ins by a decade. They are float64-only and have their own documented special cases (e.g. math.Max(x, +Inf) = +Inf, math.Max(x, NaN) = NaN, math.Max(+0, ±0) = +0). The Go documentation explicitly notes that the built-in max/min differs from these for the NaN-and-infinity combinations: the math functions were specified to a strict IEEE-754-style maxNum/minNum intent, while the built-ins are defined directly by the spec.
Why keep math.Min/math.Max at all, now that built-ins exist? Backward compatibility: removing them would break the ecosystem, and Go's compatibility promise forbids it. They also serve the narrow set of callers who genuinely want the IEEE-754 documented behaviour. But for new code the guidance is unambiguous:
- New code: use the built-ins. Generic over ordered types, no allocation, constant-foldable.
math.Min/math.Max: only when you need their exact documented IEEE special cases, or are maintaining float-only code that already uses them.
A senior reviewing a PR that introduces math.Max(a, b) on two int (which does not even compile) or on two floats where the built-in would do should redirect to max. The reverse — replacing a deliberate math.Max with the built-in — must be checked against the NaN/signed-zero behaviour the original relied on.
clear and the NaN-Key Motivation¶
The headline reason clear exists for maps is a correctness bug that was previously unfixable in pure Go: a float, complex, or interface-wrapping-NaN key cannot be removed by delete, because delete(m, k) must find the key, and NaN != NaN guarantees the lookup misses.
m := map[float64]int{}
m[math.NaN()] = 1
m[math.NaN()] = 1 // a second, distinct, also-unreachable entry
len(m) // 2
for k := range m {
delete(m, k) // removes nothing
}
len(m) // 2 — the NaN keys are permanently stuck
clear(m)
len(m) // 0
Before clear, the only way to "empty" such a map was to allocate a new one (m = make(...)), losing the backing storage and any aliases to the old map. clear empties in place. For any code that aliases a map (passes it to multiple owners, stores it in a struct) and needs a true reset, clear is the only correct option when NaN keys are even theoretically possible — which includes any map[float64]V fed by external numeric data.
This is the kind of feature that justifies a built-in: it does something the language could not previously express, and it fixes a latent bug in a large class of existing code.
clear, Memory, and Reference Hygiene¶
Beyond the NaN case, clear is a memory-hygiene tool for slices, and seniors should know exactly what it does and does not guarantee.
Dropping references¶
A []*Conn, []string, or []any that is truncated with s = s[:0] keeps the underlying array — and therefore keeps the old elements reachable, pinning them against the garbage collector. In a pooled or long-lived slice this is a memory leak.
func release(buf []*Conn) {
clear(buf) // drop every *Conn so the GC can reclaim them
pool.Put(buf[:0]) // return an empty-but-allocated slice
}
clear(buf) zeroes the len(buf) range, dropping those references. If the slice was previously grown and shrunk, references can also live in the capacity region beyond len; clear(buf[:cap(buf)]) zeroes the whole backing array.
What clear does NOT guarantee¶
clear is not a secure-erase primitive. The compiler and runtime may keep copies of values that passed through registers or were copied by value; clearing a slice that held secret bytes reduces but does not eliminate the residue. For cryptographic key material, use a dedicated wipe (and accept that Go gives no hard guarantee). For ordinary "don't leak the previous request's data into the next one," clear is the right and sufficient tool.
clear vs reallocation¶
clear(m)/clear(s) retain the allocation; m = make(...)/s = make(...) discard it and allocate fresh. In hot loops, clear-and-reuse avoids per-iteration allocation and GC pressure. The trade-off: a retained large map that is mostly empty wastes memory. If a map ballooned to a huge size once and is now small, m = make(...) to shed the oversized buckets can be the better choice. Profile before assuming clear-and-reuse is always cheaper.
Designing APIs Around Ordered Built-ins¶
When you build helpers on top of the built-ins, a few design decisions recur:
- Constrain with
cmp.Ordered, not a custom interface.func Clamp[T cmp.Ordered](x, lo, hi T) T { return min(max(x, lo), hi) }is the idiomatic shape. It works for every ordered type and composes with the built-ins natively. - Decide NaN policy at the boundary. If a public function takes floats and reduces them, document whether NaN poisons, is filtered, or errors. Do not leave it implicit — callers will be surprised by poisoning.
- Prefer
slices.Min/slices.Maxfor collections and reserve the built-ins for fixed argument sets. Wrappingslices.Maxin your own API is usually unnecessary indirection. - Validate
lo <= hiin clamp helpers if the bounds are caller-supplied;clamp(x, 10, 5)produces a nonsense result silently. - Expose
clear-and-reuse in pooled APIs rather than reallocation, and document that callers must not retain references to elements after release.
Migration at Scale¶
Migrating a large codebase off maxInt/minInt/maxF helpers:
- Gate on Go version. Bump
go.modtogo 1.21+ across all modules first; the built-ins do not exist below that. - Mechanize integer call sites.
maxInt(a, b)→max(a, b)is a safe, semantics-preserving rewrite for integers. Agoplsrename-free rewrite or aeg/gofmt -rrule handles the bulk. - Audit float call sites individually. A
maxFbuilt onif a > bmatches the built-in; one built onmath.Maxmay differ on NaN/signed zero. These need a human to confirm the data cannot be NaN, or to preservemath.Maxdeliberately. - Replace
delete-loops withclearwhere maps are emptied — and especially flag anymap[float64]Vfor the NaN-key correctness fix. - Run the test suite, then delete the dead helpers.
deadcode/unusedlinters confirm nothing references them. - Add a lint rule forbidding new
maxInt/minIntdefinitions so the helpers do not creep back.
The risk surface is almost entirely in floats. Integer and string migrations are mechanical and safe.
Interaction with the cmp, slices, and maps Packages¶
Go 1.21 shipped the built-ins alongside the cmp, slices, and maps standard packages, and they are designed as a coherent set:
cmp.Orderedis the constraint that lets your generics accept exactly the typesmin/maxaccept.cmp.Compare/cmp.Lessgive a three-way / boolean comparison for sorting;min/maxgive the reduction. Usecmp.Lesswhen you need to sort, the built-ins when you need an extreme.slices.Min/slices.Maxare the collection counterparts, implemented over the built-ins. They panic on empty input; the built-ins require ≥1 argument at compile time.maps.Clone/maps.Copypair withclearfor the reset-and-rebuild pattern:clear(dst); maps.Copy(dst, src)reusesdst's storage.
Knowing the division of labour prevents the common mistake of reaching for the wrong member — e.g. allocating a slice to call max instead of calling slices.Max, or writing a manual delete loop instead of clear.
Code Review Heuristics¶
Things to flag when reviewing code that uses these built-ins:
max/minon floats that could be NaN with no filtering — ask whether poisoning is intended.math.Max/math.Minin new code — redirect to the built-in unless the IEEE behaviour is specifically needed.clear(s)where the author seems to expect length 0 — they probably wants = s[:0].s = s[:0]on a pointer slice without a precedingclear— potential reference leak.- A
deleteloop emptying amap[float64]V— should beclearfor NaN-key correctness. clampwith caller-supplied bounds and nolo <= hicheck.- A reintroduced
maxInt/minInthelper — should use the built-in. clear-and-reuse on a map that grew very large once — may waste memory vs reallocation; check the size profile.
Anti-Patterns¶
- Using
math.Max/math.Minin new code by habit. They are float-only, do not fold to constants, and have legacy IEEE semantics. Use the built-ins. - Treating
clear(s)as truncation. It zeroes; it does not shorten. Pair withs = s[:0]when you need emptiness. - Emptying maps with
deleteloops. Slower, more code, and broken for NaN keys. Useclear. - Allocating a slice to call
max(xs...). It does not compile, and the fix isslices.Max, not a temporary slice. - Relying on
min/maxto skip NaN. They propagate it; a single NaN poisons the result. - Clear-and-reuse on a one-time-huge map without measuring — retained oversized buckets can waste more than the allocation you saved.
- Reintroducing per-package
maxInthelpers after migration, fragmenting the codebase again. - Forgetting the ≥1-argument rule in code generators, emitting
max()for empty inputs. - Using
clearas a security wipe and assuming the bytes are unrecoverable — it is hygiene, not a guarantee.
Senior-Level Checklist¶
- Explain the three design reasons
min/maxare built-ins (constants, variadic-no-alloc, universality) - Explain why
clearhad to be a built-in (NaN keys, in-place reset) - State the floating-point contract and why NaN-propagation is the right default
- Redirect new
math.Min/math.Maxusage to the built-ins, with the one exception - Use
clearfor both NaN-key correctness and reference hygiene - Know when clear-and-reuse beats reallocation, and when it does not
- Design helpers with
cmp.Orderedand explicit NaN policy - Run a scaled migration off hand-rolled helpers, auditing floats individually
- Place each task with the right package: built-ins,
slices.*,cmp.*,maps.* - Apply the review heuristics that catch poisoning, leaks, and truncation confusion
Summary¶
min, max, and clear are deliberately built-ins, not library functions. min/max had to be built-ins to fold to compile-time constants, to be variadic without allocation, and to give the ecosystem one canonical, import-free spelling. clear had to be a built-in because emptying a map including its unreachable NaN keys, and bulk-zeroing a slice in place, cannot be expressed in pure Go with the same guarantees. The floating-point contract — NaN propagates, negative zero orders below non-negative zero, infinities are extremes — is a deliberate fail-loud, total-order decision that callers with NaN data must respect by filtering.
The senior responsibilities are: migrate off hand-rolled helpers (mechanical for ints and strings, individually audited for floats and math.Max call sites); use clear for the NaN-key correctness fix and for reference hygiene in pooled slices, while knowing it is neither truncation nor a secure wipe; weigh clear-and-reuse against reallocation by profile rather than reflex; and review for NaN poisoning, reference leaks, and the truncation confusion. The features are small, but they retire a decade of fragmented helpers and close a latent correctness gap — which is exactly why they earned their place in the language rather than a package.
In this topic
- junior
- middle
- senior
- professional