Featured image

Gerard Holzmann, with the NASA/JPL Laboratory for Reliable Software, created “The Power of 10: Rules for developing Safety-Critical Software” 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.

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.

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.

Recommendation:

Within a bounded Context unbounded loops should only ever be at boundaries.

  • Make sure unbounded loops are rare and used only when required
  • Make sure all unbounded loops have termination conditions
  • 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. GC pauses aren’t free. 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 throughout Go services use object pools to reduce allocation pressure. This is also directly related to the rule’s intent.
  • Testing with -gcflags="-m" to analyze 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 25 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 approach 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?

I use

  • panic(), or log the error and exit with os.Exit(1) for invariant violations
  • error - for expected failures

Remember, if you are unsure when to use them.

  • panic():
    • For programmer errors/invariants:
    • Violated pre-conditions/post-conditions in your code
    • Runs defers, unwinds the stack. Use when you want cleanup to happen.
    • Possible recovery
  • 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.
  • error pass up the wrapped error or print it if appropriate

The distinction is subtle but real: panic() says “the program logic is broken” 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

Recommendation: Already idiomatic Go - continue declaring variables in the smallest scope needed

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, staticcheck, golangci-lint) in CI
  • Gap: No standard linter for non-error returns (bools, ints, strings)

Recommendation:

  • Make errcheck and staticcheck 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.

Recommendation:

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

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
  • Already following good practice if you disallow unused vars/imports

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) by excluding vendor files.

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

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

test:
	rm -f test/*
	mkdir -p test
	go fmt ./...
	go vet ./...
	staticcheck ./...
	errcheck ./...
	revive -config .revive.toml ./...
	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

.revive.toml

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

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

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