
Go Defensive
- 897 installs
- 137 repo stars
- Updated June 20, 2026
- cxuu/golang-skills
go-defensive is a Go coding skill that enforces defensive copying of slices and maps at every API boundary so callers cannot mutate internal package state after a function returns.
About
go-defensive is a Go style skill from cxuu/golang-skills based on the Uber Style Guide for preventing accidental mutation of shared slice and map backing arrays at API boundaries. The skill shows bad patterns like assigning caller slices directly to struct fields and good patterns using make plus copy for slices and equivalent defensive copies for maps. Backend developers reach for go-defensive when writing setters, constructors, and repository methods that accept []T or map[K]V from external callers, tests, or HTTP handlers. Applying go-defensive early avoids subtle data races and state corruption in services where returned references would let callers modify internal collections after the function exits.
- Prevents mutation bugs by copying slices and maps at API boundaries
- Follows Uber Go Style Guide rules for receiving and returning reference types
- Includes both slice copy via make+copy and map copy via range loop patterns
- Applies to both inbound parameters and outbound return values
- Works with mutex-protected internal state to avoid exposing mutable internals
Go Defensive by the numbers
- 897 all-time installs (skills.sh)
- +37 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #446 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cxuu/golang-skills --skill go-defensiveAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 897 |
|---|---|
| repo stars | ★ 137 |
| Security audit | 3 / 3 scanners passed |
| Last updated | June 20, 2026 |
| Repository | cxuu/golang-skills ↗ |
How do you prevent slice mutation at Go API boundaries?
Enforce defensive copying of slices and maps at every Go API boundary so internal state cannot be accidentally mutated by callers.
Who is it for?
Go backend developers writing libraries or services that accept slices and maps from callers and must protect internal state.
Skip if: Teams working only with value-type structs, immutable protobuf messages, or languages without reference-type collections.
When should I use this skill?
A developer passes or stores []T or map[K]V parameters in Go structs and needs Uber-style defensive copy guidance.
What you get
Defensively copied slice and map fields in Go structs with make/copy patterns at every public API boundary.
- Defensive copy implementations for slices and maps
- Refactored setter methods
Files
Go Defensive Programming Patterns
Compatibility: Crypto examples may use crypto/rand.Text, which requires Go 1.24+.Resource Routing
references/BOUNDARY-COPYING.md- Read when copying slices/maps across API boundaries.references/GLOBAL-STATE.md- Read when introducing or removing package globals.references/MUST-FUNCTIONS.md- Read when deciding whether a panic-on-error helper is acceptable.references/PANIC-RECOVER.md- Read when evaluating panic, recover, or crash containment.references/TIME-ENUMS-TAGS.md- Read when handling time types, enum zero values, or struct tags.
Defensive Checklist Priority
When hardening code at API boundaries, check in this order:
Reviewing an API boundary?
├─ 1. Error handling → Return errors; don't panic (see go-error-handling)
├─ 2. Input validation → Copy slices/maps received from callers
├─ 3. Output safety → Copy slices/maps before returning to callers
├─ 4. Resource cleanup → Use defer for Close/Unlock/Cancel
├─ 5. Interface checks → Route compile-time assertions to go-interfaces
├─ 6. Time correctness → Use time.Time and time.Duration, not int/float
├─ 7. Enum safety → Start iota at 1 so zero-value is invalid
└─ 8. Crypto safety → crypto/rand for keys, never math/rand---
Quick Reference
| Pattern | Rule | Details |
|---|---|---|
| Boundary copies | Copy slices/maps on receive and return | BOUNDARY-COPYING.md |
| Defer cleanup | defer f.Close() right after os.Open | Below |
| Interface check | Compile-time satisfaction assertion | See go-interfaces |
| Time types | time.Time / time.Duration, never raw int | TIME-ENUMS-TAGS.md |
| Enum start | iota + 1 so zero = invalid | Below |
| Crypto rand | crypto/rand for keys, never math/rand | Below |
| Must functions | Only at init; panic on failure | MUST-FUNCTIONS.md |
| Panic/recover | Never expose panics across packages | PANIC-RECOVER.md |
| Mutable globals | Replace with dependency injection | Below |
---
Verify Interface Compliance
Route compile-time interface assertions to go-interfaces. Use this skill only to notice API-boundary robustness risk; the interface skill owns when an assertion is appropriate and the exact assertion shape.
Copy Slices and Maps at Boundaries
Slices and maps contain pointers to underlying data. Copy at API boundaries to prevent unintended modifications.
// Receiving: copy incoming slice
d.trips = make([]Trip, len(trips))
copy(d.trips, trips)
// Returning: copy map before returning
result := make(map[string]int, len(s.counters))
for k, v := range s.counters { result[k] = v }Defer to Clean Up
Use defer to clean up resources (files, locks). Avoids missed cleanup on multiple return paths.
p.Lock()
defer p.Unlock()
if p.count < 10 {
return p.count
}
p.count++
return p.countDefer overhead is negligible. Place defer f.Close() immediately after os.Open for clarity. Arguments to deferred functions are evaluated when defer executes, not when the function runs. Multiple defers execute in LIFO order.
Struct Field Tags
Advisory: Always add explicit field tags to structs that are marshaled or unmarshaled.
type User struct {
Name string `json:"name" yaml:"name"`
Email string `json:"email" yaml:"email"`
}Field tags are a serialization contract — renaming a struct field without updating the tag silently breaks wire compatibility. Treat tags as part of the public API for any type that crosses a serialization boundary.
Start Enums at One
Start enums at non-zero to distinguish uninitialized from valid values.
const (
Add Operation = iota + 1 // Add=1, zero value = uninitialized
Subtract
Multiply
)Exception: When zero is the sensible default (e.g., LogToStdout = iota).
Time, Struct Tags, and Embedding
Avoid Mutable Globals
Inject dependencies instead of mutating package-level variables. This makes code testable without global save/restore.
type signer struct {
now func() time.Time // injected; tests replace with fixed time
}
func newSigner() *signer {
return &signer{now: time.Now}
}Crypto Rand
Do not use math/rand or math/rand/v2 to generate keys — this is a security concern. Time-seeded generators have predictable output.
import "crypto/rand"
func Key() string { return rand.Text() }For text output, use crypto/rand.Text directly, or encode random bytes with encoding/hex or encoding/base64.
---
Panic and Recover
Use panic only for truly unrecoverable situations. Library functions should avoid panic.
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}Key rules:
- Never expose panics across package boundaries — always convert to errors
- Acceptable to panic in
init()if a library truly cannot set itself up - Use recover to isolate panics in server goroutine handlers
Must Functions
Must functions panic on error — use them only during program initialization where failure means the program cannot run.
var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
var tmpl = template.Must(template.ParseFiles("index.html"))---
Related Skills
- Error handling: See go-error-handling when choosing between returning errors and panicking, or wrapping errors at boundaries
- Concurrency safety: See go-concurrency when protecting shared state with mutexes, atomics, or channels
- Interface checks: See go-interfaces when adding compile-time interface satisfaction checks
- Data structure copying: See go-data-structures when working with slice/map internals or pointer aliasing
Copying Slices and Maps at API Boundaries
Source: Uber Style Guide
Slices and maps contain references to their underlying data. Copy them at API boundaries to prevent callers from mutating internal state (or vice versa).
Receiving Slices and Maps
When a function stores a slice or map passed by the caller, always make a defensive copy. The caller retains the original reference and can modify it after your function returns.
Slices
Bad
func (d *Driver) SetTrips(trips []Trip) {
d.trips = trips // caller can still modify d.trips
}Good
func (d *Driver) SetTrips(trips []Trip) {
d.trips = make([]Trip, len(trips))
copy(d.trips, trips)
}Maps
Bad
func (s *Server) SetConfig(cfg map[string]string) {
s.config = cfg // caller can still modify s.config
}Good
func (s *Server) SetConfig(cfg map[string]string) {
s.config = make(map[string]string, len(cfg))
for k, v := range cfg {
s.config[k] = v
}
}Returning Slices and Maps
When returning internal slices or maps, return a copy to prevent callers from modifying your internal state.
Returning a Map
Bad
func (s *Stats) Snapshot() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
return s.counters // exposes internal state!
}Good
func (s *Stats) Snapshot() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
result := make(map[string]int, len(s.counters))
for k, v := range s.counters {
result[k] = v
}
return result
}Returning a Slice
Bad
func (q *Queue) Items() []Item {
return q.items // caller can append, modify, or reslice
}Good
func (q *Queue) Items() []Item {
result := make([]Item, len(q.items))
copy(result, q.items)
return result
}When Copies Are Not Needed
Defensive copies have a cost. Skip them when:
- The data is immutable by convention and clearly documented
- The slice/map is created fresh for the caller (not stored internally)
- Performance profiling shows the copy is a bottleneck in a hot path
When in doubt, copy. The cost is usually negligible compared to the bugs that shared references cause.
Global State Patterns
Source: Google Style Guide, Effective Go
Global state makes programs harder to test, reason about, and maintain. Dependency injection is the preferred alternative, but some global state is acceptable when used carefully.
When Global State Is Acceptable
Not all package-level variables are harmful. Global state is appropriate when it is truly process-wide and not worth injecting:
- Default instances —
http.DefaultClient,log.Default(),flag.CommandLine - Compiled-once values —
regexp.MustCompile(...)at package level - Registries —
database/sql.Register,image.RegisterFormat - Singleton infrastructure — a process-wide metric collector or trace exporter
Litmus Test for Global Variables
Before adding a package-level variable, ask:
1. Is it truly process-wide? If two goroutines or tests might need different values, it should not be global 2. Does it prevent testing? If tests must save/restore the variable or cannot run in parallel because of it, inject it instead 3. Could it be a constant? If the value never changes after init, prefer const or an unexported var initialized once 4. Does it carry mutable state? Mutable globals are the most dangerous — only acceptable for well-documented, concurrency-safe singletons
Package State API Pattern: New() + Default()
The standard library pattern provides both a customizable constructor and a convenient default. This lets callers use the default for simple cases and inject a custom instance for testing or specialized behavior.
Good
package mylog
type Logger struct {
prefix string
out io.Writer
}
func New(prefix string, out io.Writer) *Logger {
return &Logger{prefix: prefix, out: out}
}
var defaultLogger = New("", os.Stderr)
func Default() *Logger { return defaultLogger }
func (l *Logger) Info(msg string) {
fmt.Fprintf(l.out, "%s%s\n", l.prefix, msg)
}
// Package-level convenience functions delegate to the default instance.
func Info(msg string) { defaultLogger.Info(msg) }// Callers use the default for simple cases
mylog.Info("starting server")
// Tests or specialized code create custom instances
logger := mylog.New("[test] ", &buf)
logger.Info("test message")Standard library examples of this pattern:
log.New()+log.Default()+log.Println()http.NewServeMux()+http.DefaultServeMuxflag.NewFlagSet()+flag.CommandLine
Dependency Injection as the Preferred Alternative
When code needs configurable behavior, accept dependencies as constructor parameters or struct fields instead of reading package-level variables.
Bad
var db *sql.DB
func GetUser(id int) (*User, error) {
return db.QueryRow("SELECT ...", id) // depends on global
}Good
type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
func (s *UserStore) GetUser(id int) (*User, error) {
return s.db.QueryRow("SELECT ...", id)
}Benefits of injection:
- Tests provide mock or in-memory implementations
- Multiple instances can coexist (e.g., read replica vs primary)
- Dependencies are explicit in the constructor signature
Injecting Time
A common case: replacing time.Now for deterministic tests.
Bad
func IsExpired(expiry time.Time) bool {
return time.Now().After(expiry) // untestable
}Good
type Checker struct {
now func() time.Time
}
func NewChecker() *Checker {
return &Checker{now: time.Now}
}
func (c *Checker) IsExpired(expiry time.Time) bool {
return c.now().After(expiry)
}Tests replace now with a fixed function:
c := &Checker{now: func() time.Time {
return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
}}Summary
| Situation | Approach |
|---|---|
| Process-wide singleton (logger, metrics) | Default instance + New() constructor |
| Compiled-once regex or template | Package-level var with MustCompile |
| Registry (database drivers, codecs) | Package-level Register() function |
| Configurable behavior | Dependency injection via constructor |
| Time-dependent logic | Inject func() time.Time |
| Anything tests need to vary | Do not use global state |
Must Functions
Source: Uber Style Guide, Go standard library conventions
Must functions wrap a fallible function and panic on error. Use them only during program initialization where failure means the program cannot run.
Standard Library Examples
// regexp.MustCompile panics if the pattern is invalid
var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
// template.Must panics if template parsing fails
var tmpl = template.Must(template.ParseFiles("index.html"))These are safe because they run at package init time — if they fail, the program cannot function correctly.
When to Use Must
Is this called during program initialization (package-level var, init, main setup)?
├─ Yes → Is failure unrecoverable (config, regex, template)?
│ ├─ Yes → Must is appropriate
│ └─ No → Return error instead
└─ No → Never use Must — return errorAppropriate Uses
- Package-level `var`: Compiling regexps, parsing templates, loading
required config
- `init()` or early `main()`: Setting up resources that must exist for
the program to run
- Test helpers:
t.Fatalis preferred in tests, but Must can be
acceptable for test fixtures
Never Use Must For
- Runtime request handling
- User-supplied input
- Network or file operations that can legitimately fail
- Anything called after program startup
Writing a Must Function
Follow the naming convention MustX where X is the fallible function name:
func MustParseConfig(path string) *Config {
cfg, err := ParseConfig(path)
if err != nil {
panic(fmt.Sprintf("parsing config %s: %v", path, err))
}
return cfg
}Guidelines
- Name:
Mustprefix + the fallible function name (e.g.,MustParse,
MustNew, MustCompile)
- Panic message: Include the input and the error for debuggability
- Document: Always document that the function panics on error
// MustParseConfig parses the config file at path.
// It panics if the file cannot be read or contains invalid configuration.
func MustParseConfig(path string) *Config { ... }Generic Must Helper
For one-off uses, a generic Must helper avoids boilerplate:
func Must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
// Usage at package level
var cfg = Must(ParseConfig("app.yaml"))Relationship to Panic/Recover
Must functions are a controlled use of panic. They should:
- Only run during initialization (so recover is unnecessary)
- Produce clear, actionable panic messages
- Never be used where returning an error is possible
See PANIC-RECOVER.md for the full panic/recover pattern.
Panic and Recover Patterns
Source: Effective Go
Panic Guidelines
panic creates a run-time error that stops the program. Use it only for truly unrecoverable situations.
When to Panic
Real library functions should avoid panic. If the problem can be masked or worked around, let things continue rather than taking down the whole program.
// Acceptable: Truly impossible situation
func CubeRoot(x float64) float64 {
z := x/3
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// A million iterations has not converged; something is wrong.
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}Panic in Initialization
Exception: If a library truly cannot set itself up during init(), it may be reasonable to panic:
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}When Panic IS Acceptable
Beyond initialization, panic is acceptable in these narrow cases:
1. API misuse — analogous to how core language panics on out-of-bounds access. The reflect package uses this approach. 2. Internal implementation detail with matching `recover` at the package boundary. Panic simplifies deeply-nested control flow while the public API still returns errors (the Parse/parseInt pattern below). 3. `panic("unreachable")` after log.Fatal when the compiler can't detect unreachable code.
Parse/parseInt Pattern
Use panic internally to unwind complex recursion, but always convert to an error at the package boundary:
func parseInt(in string) int {
n, err := strconv.Atoi(in)
if err != nil {
panic(&syntaxError{"not a valid integer"})
}
return n
}
func Parse(in string) (_ *Node, err error) {
defer func() {
if p := recover(); p != nil {
sErr, ok := p.(*syntaxError)
if !ok {
panic(p) // not ours — re-panic
}
err = fmt.Errorf("syntax error: %v", sErr.msg)
}
}()
// ... calls parseInt internally
}Key: The type check p.(*syntaxError) ensures only our panics are caught. Unexpected panics (nil pointer, etc.) propagate normally.
---
Recover Patterns
recover regains control of a panicking goroutine. It only works inside deferred functions.
Basic Recovery Pattern
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}Server Goroutine Protection
Isolate panics to individual goroutines in servers:
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work) // Each worker is protected
}
}If do(work) panics, the result is logged and the goroutine exits cleanly without disturbing others.
Package-Internal Panic/Recover
Use panic internally but convert to errors at API boundaries:
// Error is a parse error type
type Error string
func (e Error) Error() string { return string(e) }
// Internal: panic with Error type
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// External API: converts panic to error return
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
defer func() {
if e := recover(); e != nil {
regexp = nil
err = e.(Error) // Re-panics if not our Error type
}
}()
return regexp.doParse(str), nil
}Key points:
- Deferred functions can modify named return values
- Type assertion
e.(Error)re-panics on unexpected error types - Never expose panics to clients—always convert at API boundary
---
Quick Reference
| Pattern | Description |
|---|---|
| Basic recovery | defer func() { if err := recover(); err != nil { ... } }() |
| Server protection | Wrap each goroutine handler in safelyDo |
| Package-internal | Panic internally, recover and return error at API boundary |
| Type-safe recovery | Use type assertion to re-panic on unexpected errors |
When to Use
- Panic: Only for truly unrecoverable situations or init failures
- Recover: Server handlers, package-internal error simplification
- Never: Expose panics across package boundaries—always convert to errors
Time, Struct Tags, and Embedding Patterns
Use time.Time and time.Duration
Always use the time package. Avoid raw int for time values.
Instants
Bad
func isActive(now, start, stop int) bool {
return start <= now && now < stop
}Good
func isActive(now, start, stop time.Time) bool {
return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}Durations
Bad
func poll(delay int) {
time.Sleep(time.Duration(delay) * time.Millisecond)
}
poll(10) // seconds? milliseconds?Good
func poll(delay time.Duration) {
time.Sleep(delay)
}
poll(10 * time.Second)JSON Fields
When time.Duration isn't possible, include unit in field name:
Bad
type Config struct {
Interval int `json:"interval"`
}Good
type Config struct {
IntervalMillis int `json:"intervalMillis"`
}Avoid Embedding Types in Public Structs
Embedded types leak implementation details and inhibit type evolution.
Bad
type ConcreteList struct {
*AbstractList
}Good
type ConcreteList struct {
list *AbstractList
}
func (l *ConcreteList) Add(e Entity) {
l.list.Add(e)
}
func (l *ConcreteList) Remove(e Entity) {
l.list.Remove(e)
}Embedding problems:
- Adding methods to embedded interface is a breaking change
- Removing methods from embedded struct is a breaking change
- Replacing the embedded type is a breaking change
Use Field Tags in Marshaled Structs
Always use explicit field tags for JSON, YAML, etc.
Bad
type Stock struct {
Price int
Name string
}Good
type Stock struct {
Price int `json:"price"`
Name string `json:"name"`
// Safe to rename Name to Symbol
}Tags make the serialization contract explicit and safe to refactor.
Related skills
How it compares
Use go-defensive for concrete Go slice and map copy patterns rather than general immutability guides that do not show struct-boundary code.
FAQ
Why does go-defensive require copying slices in Go?
go-defensive follows the Uber Go Style Guide because slices and maps hold references to shared backing arrays. Assigning a caller slice directly to a struct field lets the caller mutate internal state after the function returns.
What Go pattern does go-defensive recommend for slices?
go-defensive recommends make([]Trip, len(trips)) followed by copy(d.trips, trips) in setters instead of direct assignment. This creates an independent backing array the caller cannot modify.
Is Go Defensive safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.