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:
-
gotoexists, 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 withos.Exit(1)for invariant violationserror- 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.
- For operational failures in
errorpass 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:
- Documents what the contract actually is
- Catches bugs when code elsewhere changes and violates assumptions
- 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.,
xinx, 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
errcheckandstaticcheckmandatory 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 generatepipelines 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 vetand linters - Must integrate
staticcheck,golangci-lintinto CI pipeline - Treat linter findings as build failures
Recommendation:
- Run
go vetand 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