The missing pieces finally arrived
You are writing a utility function to clamp a value. You write if val < low { return low }. You write if val > high { return high }. You copy-paste this logic three times across your codebase. You realize Go has been missing min and max since the language started. Then you look at your server logs. They are a wall of text. You grep for an error and get a thousand hits because the format changed slightly. You want structured logs, but the standard library only gave you log.Printf and string formatting.
Go 1.21 closes these gaps. It adds min and max as built-in generic functions. It introduces log/slog, a structured logging package that produces key-value pairs. It also exposes GODEBUG, an environment variable to control runtime behavior for debugging. These features reduce boilerplate, make logs queryable, and give you knobs to diagnose runtime issues.
Built-in min and max
min and max are now part of the language. They are generic built-ins that work on any ordered type. You can pass integers, floats, or strings. The compiler infers the type from the arguments. You don't need to import a package. You don't need to write a helper function.
Here is the simplest usage: pass two values, get the result.
package main
import "fmt"
func main() {
// min returns the smaller of two comparable values
fmt.Println(min(10, 20)) // 10
// max returns the larger value
fmt.Println(max(3.14, 2.71)) // 3.14
// Strings are compared lexicographically
fmt.Println(min("apple", "banana")) // apple
}
The functions enforce type safety. Both arguments must be the same type. If you mix an int and a float64, the compiler rejects the program with mismatched types int and float64 in call to min. You must cast explicitly. min(int(1), 2.0) works because both arguments are now float64. This prevents silent precision loss and makes the intent clear.
min and max do not work on slices or maps. They take exactly two arguments. If you need the minimum of a slice, you still need a loop or a helper. The built-ins handle the common case of comparing two values.
Gofmt aligns the arguments automatically. Don't fight the formatter. Let the tool decide the indentation. Most editors run gofmt on save, so your code stays consistent without manual effort.
Built-ins are free. Use them.
Structured logging with slog
Structured logging turns your logs into data. Instead of a paragraph of text, each log entry is a set of key-value pairs. You can query logs by field. You can filter by user ID, request ID, or error code. log/slog provides this capability in the standard library.
slog uses a handler to format output. The default handler writes to os.Stderr in a text format. You can switch to a JSON handler for machine parsing. Attributes carry the data. slog.String, slog.Int, and slog.Any create typed attributes. Typed attributes avoid reflection overhead and make the code self-documenting.
Here is a realistic handler that logs a request with metadata.
package main
import (
"log/slog"
"os"
)
func main() {
// JSONHandler produces JSON output for log aggregators
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Info logs include timestamp, level, message, and attributes
logger.Info("server started",
slog.String("addr", ":8080"),
slog.Int("workers", 4),
)
// Error logs use the Error level and can carry error values
logger.Error("failed to bind",
slog.String("addr", ":8080"),
slog.Any("error", fmt.Errorf("address already in use")),
)
}
The output is a JSON object. Each attribute becomes a field. The message is stored in the msg key. The level is stored in the level key. You can parse this with any JSON tool.
slog respects context. You can attach a logger to a context and pass it through your call chain. Functions that take a context should accept it as the first parameter, conventionally named ctx. This is a Go community standard. It makes the dependency visible and allows cancellation to propagate.
package main
import (
"context"
"log/slog"
)
// ProcessRequest handles an incoming request with structured logging
func ProcessRequest(ctx context.Context, userID string) error {
// WithContext retrieves the logger stored in the context
logger := slog.FromContext(ctx)
// Group bundles related attributes under a single key
logger.Info("processing",
slog.Group("user",
slog.String("id", userID),
slog.Bool("active", true),
),
)
return nil
}
With and WithContext let you attach attributes to a logger once and reuse it. This avoids repeating attributes in every log call. Group creates a nested object in the output. It keeps related fields together.
The community accepts the verbosity of slog.String and slog.Int. It makes the types explicit. It avoids the runtime cost of reflection. If you pass a raw value to slog.Any, the logger uses reflection to determine the type. Use typed attributes for performance-critical paths.
Logs are data. Structure them or lose the signal.
Customizing slog handlers
You can customize slog handlers to filter attributes, add trace IDs, or change the output format. The ReplaceAttr option lets you modify attributes before they are written. This is useful for dropping sensitive data or adding global metadata.
Here is how to add a trace ID to every log entry.
package main
import (
"log/slog"
"os"
)
func main() {
// ReplaceAttr modifies attributes before they are written
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Add a trace ID to every log entry
if a.Key == "trace_id" {
return slog.String("trace_id", "abc-123")
}
return a
},
}
// Create a logger with the custom handler options
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Info("request received", slog.String("trace_id", "placeholder"))
}
ReplaceAttr runs for every attribute in every log call. It receives the group path and the attribute. You can drop an attribute by returning an empty slog.Attr. You can rename it by returning a new attribute with a different key. You can transform the value.
Use ReplaceAttr sparingly. It adds overhead to every log call. If you need to add a static value, use With instead. With attaches attributes once and reuses them. It is faster than modifying every call.
Accept interfaces, return structs. slog follows this mantra. Handlers implement the slog.Handler interface. You can write a custom handler to send logs to a database or a remote service. Return a concrete struct from your factory function. Let the caller use the interface.
Runtime control with GODEBUG
GODEBUG is an environment variable that controls runtime behavior. It lets you toggle internal flags for debugging. You set it before running the program. GODEBUG=panicnil=1 go run main.go. The runtime reads the variable and adjusts its behavior.
GODEBUG is not for production configuration. It is for diagnosing issues. Use it to reproduce a bug, test a fix, or understand runtime behavior. Don't use it as a feature flag. Feature flags belong in your application code. GODEBUG flags can change between Go versions. Relying on them breaks compatibility.
The panicnil flag controls how panic(nil) behaves. By default, panic(nil) panics with a nil pointer error. Setting panicnil=1 makes the runtime panic with a *runtime.PanicNilError. This makes stack traces more informative. You can see exactly where the nil panic occurred.
You can list all available flags by running GODEBUG=help go run main.go. The output shows the flag name, description, and default value. Some flags are experimental. Some are deprecated. Check the release notes for your Go version.
GODEBUG is a scalpel. Use it to diagnose, not to configure.
Pitfalls and compiler errors
min and max have strict type requirements. They don't work on mixed types. They don't work on slices. If you pass a slice, the compiler rejects the program with cannot use []int literal as type int in argument to min. You must extract the elements first.
slog attributes are evaluated eagerly. If you pass a function call as an attribute, it runs even if the log level is disabled. logger.Info("msg", slog.Any("data", expensiveCall())) runs expensiveCall() regardless of the level. Use slog.Group or a helper function to defer evaluation if needed.
slog doesn't support fmt verbs. slog.Info("val %d", 5) prints the literal %d. You must use slog.Int("val", 5). This prevents format string bugs and makes the log structure explicit.
The compiler complains with undefined: slog if you forget to import the package. Import log/slog. The package name is slog. The import path is log/slog.
If you use slog.Any with a type that doesn't implement encoding.TextMarshaler or json.Marshaler, the logger uses reflection. This can be slow. Use typed attributes for performance. slog.Int is faster than slog.Any with an integer.
The worst goroutine bug is the one that never logs. Use slog to log goroutine starts and exits. Attach a logger to the context. Pass the context to the goroutine. If the goroutine leaks, you can trace it back to the log entry.
When to use these features
Use min and max when you need to clamp values or find bounds without writing a helper function. Use log/slog when your application needs structured logs that can be parsed by log aggregators. Use fmt.Printf when you are debugging locally and need a quick, human-readable dump. Use GODEBUG when you are diagnosing a runtime behavior change or need to toggle a specific internal flag for testing.
Trust the standard library. It's designed to work together.