Skip to main content
skill CX Codex

go-defensive-coding — Claude Code skill: setup and features

Stops panics, slice aliasing, and subtle runtime bugs in Go: typed-nil interfaces, numeric truncation, and loop defer hazards.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add eduardo-sl/go-agent-skills --skill go-defensive-coding -a claude-code
Open on GitHub

What go-defensive-coding does

The skill instructs coding agents on Go defensive programming practices. It flags edge cases that compile without warnings: non-nil interface values wrapping nil pointers, memory retention from sub-slices, and silent data corruption during narrowing numeric conversions.

It operates in two distinct modes. In Harden mode, the agent enforces bounds checks, defensive copies via slices.Clone, and safe map initialisation while writing code. In Review mode, it scans existing packages and flags issues by severity from fatal panics to minor style notes with exact file and line references.

Running automated rule enforcement requires the Go toolchain on your path. Linters like golangci-lint and gosec are optional companions for deeper static checks.

What it helps with

  • Catch typed-nil interface returns before they trigger production panics
  • Prevent slice aliasing bugs caused by append mutations
  • Enforce range checks on narrowing integer conversions
  • Detect deferred cleanups lingering inside loop bodies

Example prompts for the agent

  • Review this package for nil-safety and slice aliasing bugs
  • Harden this data transformer against integer overflow and nil map writes
  • Check why my error interface returns non-nil on success

How to install go-defensive-coding

1Skills CLI (Vercel)

npx skills add eduardo-sl/go-agent-skills --skill go-defensive-coding -a claude-code

2Manually: put the files in place

~/.claude/skills/go-defensive-coding/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/go-defensive-coding/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about go-defensive-coding

How to install go-defensive-coding in Claude Code?

Run npx skills add eduardo-sl/go-agent-skills --skill go-defensive-coding -a claude-code in your terminal. It sets up ~/.claude/skills/go-defensive-coding/SKILL.md.

What does go-defensive-coding do?

It guides AI agents through reviewing and writing defensive Go code, focusing on boundary copying, nil handling, float comparisons, and resource cleanup.

Does this skill handle goroutine leaks or race conditions?

No. The skill deliberately excludes goroutine lifecycle management and data race checks, routing those audits to go-concurrency-review.

Source text

SKILL.md · MIT
---
name: go-defensive-coding
description: >
  Prevent panics, silent corruption, and subtle runtime bugs in Go:
  typed-nil interfaces, slice aliasing, integer overflow on conversion,
  float comparison, defer in loops, defensive copying at API boundaries, and
  zero-value design. Use when hardening code against crashes, reviewing for
  nil-safety, converting between numeric types, or deciding what to copy at
  a package boundary. Trigger examples: "nil pointer panic", "why is my
  error not nil", "slice aliasing", "integer overflow", "compare floats",
  "defer in a loop", "defensive copy", "make this crash-proof".
  Not for: data races and goroutine lifecycle (go-concurrency-review),
  injection and auth (go-security-audit), a panic that already happened
  (go-troubleshooting).
user-invocable: true
license: MIT
compatibility: Designed for Claude Code or similar AI coding agents working on Go projects. Requires the Go toolchain. golangci-lint and gosec are optional, for enforcing the rules below.
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(gofmt:*) Bash(golangci-lint:*) Bash(gosec:*)
metadata:
  author: eduardo-sl
  version: "1.1.3"
---

# Go Defensive Coding

Go has no exceptions and no null-safety in the type system. Every trap below
compiles cleanly, passes review, and fails in production.

Detailed reference material, loaded on demand:

- `references/nil-and-aliasing.md` — the full typed-nil rules, slice
  aliasing scenarios, and memory retention.
- `references/numeric-safety.md` — conversion range checks, overflow
  detection, float and time comparison.
- `references/copies-and-lifecycle.md` — `copylocks`/`noCopy`, `defer` in
  loops, checking deferred `Close` errors, usable zero values.

Read a reference file only when the section below is not enough.

## Operating Modes

- **Harden** — you are writing or changing code. Apply every rule as you go.
- **Review** — you are auditing existing code. Report findings with severity
  (🔴 panic or corruption, 🟡 latent bug, 🟢 style) and cite file:line.

## 1. The Typed-Nil Interface Trap

A non-nil interface can hold a nil pointer. This is the single most common
source of "impossible" nil checks in Go.

```go
type NotFoundError struct{ ID string }

func (e *NotFoundError) Error() string { return "not found: " + e.ID }

// ❌ Bad — returns a non-nil error even on success
func find(id string) error {
    var err *NotFoundError // typed nil
    if id == "" {
        err = &NotFoundError{ID: id}
    }
    return err // interface is (type=*NotFoundError, value=nil) — NOT nil
}

// ✅ Good — return the untyped nil literal
func find(id string) error {
    if id == "" {
        return &NotFoundError{ID: id}
    }
    return nil
}
```

Rules:

- Never declare a concrete error/pointer variable and return it as an
  interface. Return `nil` explicitly on the success path.
- Never store a possibly-nil concrete pointer in an `error`, `io.Reader`, or
  any interface-typed struct field.
- `staticcheck` reports `SA4023` when a comparison with `nil` can never be
  true because of this trap. No tool catches every case.

## 2. Nil Map, Slice, and Channel Behaviour

Memorise this table — half of these are safe and half panic or hang.

| Operation | nil map | nil slice | nil channel |
|---|---|---|---|
| Read / receive | zero value | index panics | blocks forever |
| Write / send | **panics** | `append` works | blocks forever |
| `len` / `cap` | `0` | `0` | `0` |
| `range` | zero iterations | zero iterations | blocks forever |
| `close` | n/a | n/a | **panics** |

```go
// ✅ A nil slice is a valid empty slice — do not guard append
var out []string
out = append(out, "a")

// ❌ A nil map is read-only
var m map[string]int
m["k"] = 1 // panic: assignment to entry in nil map

// ✅ Initialise every map before writing
m := make(map[string]int)
```

Return nil slices, not `[]T{}`. They marshal identically in JSON for
`encoding/json` when the field is `omitempty`, and cost no allocation.
Return an empty non-nil map only when the caller is documented to write to it.

## 3. Slice Aliasing

A slice is a view. `append` writes through that view whenever capacity
allows, mutating data the caller still owns.

```go
a := []int{1, 2, 3, 4}
b := a[:2]
b = append(b, 99) // ❌ overwrites a[2]; a is now [1 2 99 4]
```

```go
// ✅ Full slice expression caps the view — append must reallocate
b := a[:2:2]
b = append(b, 99) // a is untouched
```

Apply this whenever you hand a subslice to code you do not control, and
whenever a struct field holds a subslice of a larger buffer.

A subslice also keeps the entire backing array alive. To release a large
buffer, copy what you need: `head := slices.Clone(buf[:64])`.

## 4. Defensive Copying at Boundaries

Slices and maps are reference types. Storing or returning one without a copy
hands out a mutable handle to your internals.

```go
type Config struct{ hosts []string }

// ❌ Bad — caller can mutate our state, both ways
func NewConfig(hosts []string) *Config { return &Config{hosts: hosts} }
func (c *Config) Hosts() []string      { return c.hosts }

// ✅ Good — copy in, copy out
func NewConfig(hosts []string) *Config {
    return &Config{hosts: slices.Clone(hosts)}
}
func (c *Config) Hosts() []string { return slices.Clone(c.hosts) }
```

Use `maps.Clone` for maps. Both are shallow — a `[]*User` clone still shares
the pointed-to users.

Copy when the value is retained past the call or exposed to a caller. Do not
copy a slice you only read inside the function; that is wasted allocation.

### Preventing accidental copies

A struct holding a `sync.Mutex`, `sync.WaitGroup` or `atomic.Int64` must
never be copied — a value receiver or by-value parameter gives the copy an
independent lock, and both halves believe they are synchronised. `go vet`'s
`copylocks` catches these; for no-copy types without a lock, embed a
`noCopy` marker so `vet` covers them too. Examples:
`references/copies-and-lifecycle.md`.

## 5. Numeric Conversion and Comparison

Go never panics on numeric conversion. It truncates.

```go
// ❌ Silent corruption when the value does not fit
count := int32(int64Total)

// ✅ Range-check before narrowing
if int64Total > math.MaxInt32 || int64Total < math.MinInt32 {
    return fmt.Errorf("total %d out of int32 range", int64Total)
}
count := int32(int64Total)
```

The same applies to `int` → `uint` (negatives wrap to huge values) and to
`len()` results assigned to sized types. `gosec` reports these as G115.

Never compare floats with `==` — compare against a tolerance. Never compare
`time.Time` with `==` — `Equal` compares the instant, while `==` also
compares the monotonic reading and location. Examples:
`references/numeric-safety.md`.

Integer division by zero panics; float division by zero yields `±Inf` or
`NaN`, and `NaN != NaN`. Guard divisors that come from input.

## 6. Resource Lifecycle

- `defer` runs at **function** return, not at the end of a loop body. A
  `defer f.Close()` inside a loop keeps every file open until the loop
  ends — give each iteration its own function scope. The same applies to
  `resp.Body.Close`, `rows.Close`, `mu.Unlock` and `tx.Rollback`.
- Check the error from a deferred `Close` on anything you wrote to: a
  failed flush on close is silent data loss. Assign it to a named `err`
  result when the body succeeded.

Examples: `references/copies-and-lifecycle.md`.

## 7. Zero-Value and Initialisation Safety

Design types so the zero value works, then no constructor can be forgotten.

A `sync.Mutex` zero value is ready, and a nil map can be read; initialise
the map lazily on first write under the lock. Example:
`references/copies-and-lifecycle.md`.

When lazy init must happen exactly once and may race, use `sync.Once`.

Avoid `init()`. It runs before `main`, cannot fail cleanly, cannot be tested
in isolation, and its cross-file order depends on filenames. Use an explicit
`New...` constructor that returns an error.

## Enforce with Tooling

Run these; do not rely on reading alone. Skip and note any tool that is not
installed.

```bash
go vet ./...                                  # copylocks, lostcancel, printf
golangci-lint run                             # errcheck, bodyclose, makezero, sqlclosecheck
staticcheck ./...                             # SA4023 typed-nil comparisons
gosec -include=G115,G104 ./...                # integer overflow, unhandled errors
go test -race ./...                           # aliasing bugs often surface as races
```

Relevant golangci-lint linters: `errcheck`, `bodyclose`, `sqlclosecheck`,
`rowserrcheck`, `makezero`, `nilerr`, `exhaustive`, and `govet` with the
`nilness` analyzer enabled — `go vet` itself does not run `nilness`.

## Verification Checklist

1. No function returns a concrete pointer type as an interface on a success path
2. Every map is initialised before its first write
3. Subslices handed across a package boundary use a full slice expression `a[:n:n]`
4. Slices and maps stored in or returned from a struct are cloned
5. No type holding a mutex or atomic is passed or received by value
6. Every narrowing numeric conversion is range-checked, or documented as bounded
7. No `==` on floats or on `time.Time`
8. Divisors derived from input are checked against zero
9. No `defer` inside a loop body without an enclosing function scope
10. Deferred `Close` on written resources reports its error
11. `go vet`, `golangci-lint` and `go test -race` are clean

© eduardo-sl · MIT · eduardo-sl/go-agent-skills

Similar skills

All skills for Code review and refactoring →

From the same set34

go-troubleshooting A skill that walks the agent through Go runtime diagnosis: panics, deadlocks, goroutine and memory leaks, race reports. Useful when a program crashes or hangs and the cause is unclear. CX Codex +1 ★ 83repo stars go-security-audit Audits Go code for OWASP Top 10 issues, injections, SSRF, and authentication flaws. Runs read-only checks across your repository or pull request diffs. CX Codex +1 ★ 83repo stars go-performance-review Find Go performance bottlenecks and check proposed changes with benchmarks and profiles. Use it to review hot paths or reduce allocations. CX Codex +1 ★ 83repo stars go-observability A skill that sets conventions for Go service telemetry: structured logs with log/slog, OpenTelemetry traces and metrics, SLO alerts. Useful once a service runs in production and you need to see what it does. CX Codex +1 ★ 83repo stars go-concurrency-review Review Go concurrency or build safer concurrent code with checks for goroutine lifecycles, channels, shared state, and context cancellation. CX Codex +1 ★ 83repo stars go-naming Applies Go naming conventions from Effective Go to packages, methods, acronyms, and errors. Helps prevent stuttering names like order.OrderService. CX Codex +1 ★ 83repo stars go-modernize This skill guides Go code updates using newer language and standard-library features. Use it for focused edits or a requested package or module sweep. CX Codex +1 ★ 83repo stars go-error-handling A skill that tells your agent how to create, wrap and inspect Go errors: %w wrapping, sentinel errors, custom types, errors.Is and errors.As. CX Codex +1 ★ 83repo stars

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor