Featured image

Gerard Holzmann, with the NASA/JPL Laboratory for Reliable Software, created “The Power of 10: Rules for developing Safety-Critical Code” in 2006.

When writing Go, most of us will never have to develop safety-critical code. However, I feel these concepts are still worth exploring with Go in mind.

I believe applying The Power of 10 lens to your everyday Go code is profoundly powerful. This article details my analysis of each rule and its specific application within the Go ecosystem.

Rule 1 - Avoid complex flow constructs such as goto and recursion Link to heading

Intent: Prevent unpredictable control flow in safety-critical systems.

Go Application:

  • goto exists, but it is rarely used.

  • Go lacks tail call optimization, making iteration faster than recursion.

  • Recursion with a depth that depends on input can exhaust the stack. A goroutine’s stack grows until it reaches its limit (1 GB by default on 64-bit systems), and the runtime then ends the program with a fatal error that recover cannot catch.

  • The original rule also bans setjmp and longjmp. Go’s closest equivalent is panic and recover used as control flow, which exits several function calls at once and hides the path the code takes.

I believe that our designs and code should be as simple as possible. If that means using goto or recursion, then use them. However, I would also argue that reaching for goto or recursion is a code smell, which indicates an unnecessarily complex control flow. In my 20+ years of programming, I have rarely had to reach for recursion and have not touched goto since my days in Basic. If you find yourself reaching for either it should immediately trigger an internal code review or a discussion about simpler design alternatives.

Recommendation:

Avoid goto and recursion unless you are sure you know what you are doing. Do not use panic and recover as control flow.

Rule 2 - All loops must have fixed bounds. This prevents runaway code. Link to heading

Intent: Prove code terminates and has predictable execution time

Go Application:

I am sure we have all written this

for {
}

That is exactly the type of loop they are warning us about. However, I feel we must consider this in the context of Go.

NASA’s code runs on a real-time operating system. Within this operating system is the scheduler or idle loop that every RTOS always has, which is an infinite loop. This loop dispatches fixed-rate tasks whose execution can be bounded and analyzed for worst-case timing. There is more complexity there, I am sure, but for a mental model, that will work.

Remember, models are always incorrect; the question is, are they useful?

Go does not have an RTOS event loop to hook into, so as Go developers we occasionally need to write

for {
}

This rule is still highly applicable. We can enforce it by constraining infinite loops to system boundaries, managed with context-based cancellation.

Crucially, application logic must be isolated from these loops. Amusingly, this architectural necessity aligns perfectly with Test-Driven Development (TDD), which naturally pushes testable logic out of unbounded system loops.

The rule also covers loops that are meant to end but have no fixed limit: retry loops, pagination, and any for condition {} that waits on something outside your control. The paper’s answer is to give the loop an explicit upper bound and return an error when that bound is exceeded. This maps directly to Go.

const maxAttempts = 5

var err error
for range maxAttempts {
	if err = send(msg); err == nil {
		return nil
	}
}
return fmt.Errorf("send failed after %d attempts: %w", maxAttempts, err)

Recommendation:

Unbounded loops should only ever be at boundaries, and they should run under a context.Context that can cancel them.

  • Make sure unbounded loops are rare and used only when required
  • Make sure all unbounded loops have termination conditions
  • Give every loop that is meant to terminate an explicit upper bound, and return an error when it is exceeded
  • Keep unbounded loops as close to the system boundary as possible
  • Make sure unbounded loops are more heavily reviewed when written or changed
  • Keep logic out of the loop itself so the logic can be testable
  • Make sure you can reason about what happens on each iteration of the loop

Rule 3 - Avoid heap memory allocations after initialization Link to heading

Intent: Ensure deterministic memory usage and prevent runtime allocation failures

Go Application:

Go’s design philosophy is fundamentally different from the low-level languages the Power of 10 Rules were written for. Go embraces GC and heap allocation, which eliminates many of these bugs. However, the principles of understanding and controlling your allocation patterns still apply.

While Go constantly allocates (slices, maps, closures) which is contrary to the rule’s spirit, we can make sure we are taking this into account. Garbage collection isn’t free. Go’s collector runs mostly concurrently, so its pauses are short, but it still takes CPU time from your program, and goroutines that allocate heavily are made to help with the collection. Even in a high-throughput API, excessive allocations cause GC pressure and latency spikes. Applying these principles elevates everyday code quality, making precise allocation pattern understanding critical.

Recommendation:

Be intentional about allocations.

  • Pre-allocation is idiomatic in Go. For example, make([]T, 0, capacity) adheres to the spirit of the rule by allocating the necessary memory upfront.
  • sync.Pool exists for this reason. High throughput Go services use object pools to reduce allocation pressure. This is also directly related to the rule’s intent.
  • Testing with -gcflags="-m" analyzes escape behavior and can help refine memory usage in your programs for optimal safety and performance.

Rule 4 - Restrict functions to a single printed page. Link to heading

Intent: Keep functions understandable and maintainable

Function Length & The Go Trade-off: While the debate on restricting function length has generated controversy, the core intent, understandability and maintainability, remains valid. David Farley’s guidelines in Modern Software Engineering offer a more balanced perspective than the “magic bullet” solutions often discussed.

I utilize tooling (like revive, configured at the end of this article) to flag functions over 30 lines or those accepting more than 5 parameters. Every team should establish enforced standards around these complexity metrics.

A common Go exception involves orchestration code: functions may exceed 30 lines due to idiomatic error handling chains. The decisive check remains: Is the function still doing only one thing and doing it well? If yes, the added lines are acceptable.

Go Application:

  • Make sure your function does only one thing and does it well.
  • Set up a reasonable maximum number of lines per function
  • Set up a reasonable maximum number of parameters for a function
  • Functions with too many lines usually show too many responsibilities

Recommendation: Set a soft line limit; if you’re over that soft limit, consider refactoring

Rule 5 - Use a minimum average of two runtime assertions per function Link to heading

Intent: Validate assumptions and fail fast on violations

Go Application:

Go intentionally omits traditional assert statements. However, we achieve the same safety benefits through systematic validation of input (pre-conditions), state, and outputs (post-conditions) instead of relying on implicit assumptions. To accomplish this we make use of standard error checking, guard clauses and invariant checks at system boundaries.

How should we handle them?

The original rule says that when an assertion fails, an explicit recovery action must be taken, such as returning an error to the caller. Go’s error handling already works this way, so I use

  • error - for failed checks and expected failures, so the caller can recover
  • os.Exit() or log.Fatal() - only in main(), when the program cannot run at all

Remember, if you are unsure when to use them.

  • error:
    • For expected failures
    • For violated pre-conditions/post-conditions in your code
    • Pass up the wrapped error or print it if appropriate
  • os.Exit() / log.Fatal():
    • For operational failures in main() such as when a critical dependency is unavailable
    • Immediate termination, no defers. Use when cleanup is impossible/meaningless or you want to ensure termination with no recovery.
  • panic():
    • Avoid it for failed checks. The original rule asks for recovery, and Rule 1 already rules out panic and recover as control flow.
    • recover only works in the goroutine that panicked. An unrecovered panic in any goroutine ends the whole program.

The distinction is subtle but real: an error says “this operation failed, and the caller decides what happens next” while os.Exit() says “the runtime environment isn’t suitable.”

Recommendation:

Inbound (pre-conditions):

  • Input validation at function entry
  • Nil checks on pointers/interfaces
  • Range checks on numeric inputs
  • Length checks on slices/strings

Outbound (post-conditions):

  • Validate return values before returning
  • Ensure error states match return values (non-nil error = zero value returns)
  • Check that the mutated state is consistent
  • Verify resource cleanup happened

Boundaries are contracts. Even if you write both sides and never touch them again, the validation:

  1. Documents what the contract actually is
  2. Catches bugs when code elsewhere changes and violates assumptions
  3. Limits the blast radius when something goes wrong

Rule 6 - Restrict the scope of data to the smallest possible Link to heading

Intent: Limit where state can be modified to reduce bugs

Go Application:

  • Go naturally encourages this through short declarations (:=)
  • Use block-scoped variables and declare close to their use
  • Package visibility (lowercase = unexported by default)
  • Idiomatic: if err := doSomething(); err != nil

There is one trap here. The same := that keeps scopes small also allows shadowing: declaring a new variable in an inner scope with the same name as an outer one, when the intent was to assign to the outer one. The compiler accepts it, and the outer variable is never updated.

var cfg Config
if path != "" {
	cfg, err := read(path) // new cfg, the outer one is untouched
	if err != nil {
		return err
	}
	log.Printf("loaded %s", cfg.Name)
}
return start(cfg) // still the zero value

I consider variable shadowing a bug, not a feature, and I wrote more about that in Unpopular Software Opinions.

Recommendation: Already idiomatic Go - continue declaring variables in the smallest scope needed, and do not shadow variables

Rule 7 - Check the return value of all non-void functions, or cast to void to indicate the return value is useless Link to heading

Intent: Catch errors and unexpected states immediately

Go Application:

  • Go’s compiler provides selective enforcement: it prevents unused declared variables (e.g., x in x, err := fn()), but it silently allows ignoring the entire return set, including errors (fn()).
  • Solution: Use linters (errcheck, golangci-lint) in CI
  • Gap: No standard linter for non-error returns (bools, ints, strings)

Recommendation:

  • Make errcheck mandatory in CI

Rule 8 - Use the preprocessor only for header files and simple macros Link to heading

Original Intent: Avoid complex macro logic and conditional compilation issues

Go has no preprocessor, eliminating C macro issues. However, similar problems can arise from:

  • Complex go generate pipelines that obscure code
  • Excessive build tag combinations creating platform-specific variants

Recommendation:

  • Keep generated code simple and build tags minimal

Rule 9 - Limit pointer use to a single dereference, and do not use function pointers Link to heading

Original Intent: Enable static analysis and prevent complex pointer arithmetic

Go Application:

  • Multiple pointer indirections (**T) exist in Go but are rare and usually unnecessary.
  • Functions in Go are type-safe callable values, not raw memory pointers.
  • Higher-order functions, callbacks, and middleware patterns safely use these callable values.
  • Go’s type system and runtime prevent the unsafe behavior possible with C-style function pointers.
  • The original rule bans function pointers because a static analyzer cannot always tell which function a call will reach. In Go, a function value or an interface can only hold something that matches its declared type, and it cannot be cast to another type without unsafe, so tools can still narrow down which functions a call can reach.

Recommendation:

  • Avoid pointers to pointers (**T)
  • Avoid using unsafe or reflection to bypass these safety guarantees.

Rule 10 - Compile with all possible warnings active; all warnings should then be addressed before the release of the software Link to heading

Original Intent: Catch potential bugs before deployment

Go Application:

  • The Go compiler has errors, not warnings (unused vars/imports = compile error)
  • “Warnings” layer comes from go vet and linters
  • Must integrate staticcheck, golangci-lint into CI pipeline
  • Treat linter findings as build failures

Recommendation:

  • Run go vet and comprehensive linters in CI
  • Configure as blocking checks, not optional warnings
  • The compiler already rejects unused vars/imports, so that part is covered for you

Run comprehensive linters in CI as blocking checks. If it doesn’t pass staticcheck and errcheck, it doesn’t merge. Configure your CI to fail the build, not just report warnings.

For a concrete example of integrating these checks, here is a configuration snippet from a typical Continuous Integration (justfile). Note the selective application of the -gcflags="-m" memory analysis only to application packages (PKGS).

set shell := ["bash", "-cu"]

PKGS := `go list -f '{{.Dir}}' ./cmd/... ./internal/... | tr '\n' ' '`

test:
	rm -f test/*
	mkdir -p test
	go fmt ./...
	go vet ./...
	staticcheck ./...
	errcheck ./...
	revive -config .revive.toml -set_exit_status ./...
	go test ./... -race -vet=all -shuffle=on -count=1 -timeout=30s -coverprofile=test/coverage.out
	go tool cover -func=test/coverage.out
	go tool cover -html=test/coverage.out -o test/coverage.html

test-mem:
	rm -f test/*
	mkdir -p test
	go test {{PKGS}} -race -vet=all -shuffle=on -count=1 -timeout=30s -coverprofile=test/coverage.out -gcflags="-m"

test-open:
	open test/coverage.html

Required Tools

Revive go install github.com/mgechev/revive@latest

Staticcheck go install honnef.co/go/tools/cmd/staticcheck@latest

Errcheck go install github.com/kisielk/errcheck@latest

.revive.toml

[rule.function-length]
  arguments = [0, 30]  # max statements (0 = disabled), max lines

[rule.argument-limit]
  arguments = [5]  # max parameters

[rule.cognitive-complexity]
  arguments = [15]  # optional: catches complex functions