> *"Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're
Prerequisites
- 2
- 5
- 6
- 7
- 9
- 11
Learning Objectives
- Generalize the iostat mechanism from Chapter 7 to memory allocation with stat= and errmsg=, and guard every allocate so a failure is a message rather than an abort.
- Distinguish stop from error stop, choose meaningful exit codes, and explain how a program reports success or failure to the shell, a Makefile, or a CI system.
- Turn on the development flags — -fcheck=all, -fbacktrace, and -ffpe-trap — read the runtime diagnostics they produce, and say which to remove for a production build.
- Locate a logic bug with gdb and a memory error with valgrind, and match each tool to the class of fault it catches.
- Write assertions and preconditions, and apply defensive patterns that fail early and loudly instead of computing on bad data.
- Recognize and fix Fortran's classic bugs: uninitialized variables, out-of-bounds access, integer overflow, precision loss, and the implicit-save trap.
In This Chapter
- Overview
- Learning Paths
- 13.1 Errors as Values: iostat Revisited, stat/errmsg Introduced
- 13.2 stop vs error stop, and the Exit Code
- 13.3 The Development Flags: -fcheck, -fbacktrace, -ffpe-trap
- 13.4 Debuggers: gdb for Logic, valgrind for Memory
- 13.5 Assertions, Preconditions, and Defensive Programming
- 13.6 The Usual Suspects
- Project Checkpoint
- Summary
- Spaced Review
- What's Next
Chapter 13: Error Handling, Debugging, and Defensive Programming
"Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?" — Brian W. Kernighan and P. J. Plauger, The Elements of Programming Style
Overview
Every program you have written so far in this book has been optimistic. It assumed the config file exists, that the allocation succeeds, that the array index is in range, that the number you divided by is not zero, and that the variable you read had first been set. Those assumptions hold in a demo. They do not hold in a simulation that runs for three days on a cluster, reads a config a colleague edited, sizes a grid from a parameter you typed at 2 a.m., and marches a nonlinear system toward the exact regime where numbers blow up. In real scientific computing, the question is never whether something will go wrong — it is whether your program will tell you, clearly and early, or whether it will sail on computing garbage and hand you a plausible-looking wrong answer a week later.
This chapter is about the difference between those two outcomes, and it is one of the most practically valuable in the book. We will do three related things. First, error handling: how to ask each operation that can fail — an allocation, an I/O statement — whether it succeeded, and how to halt deliberately, with a message and an exit code the outside world understands, when it did not. Second, debugging: the compiler flags that turn silent corruption into a loud diagnostic, and the two tools — gdb and valgrind — that let you watch a program from the inside and catch it touching memory it should not. Third, defensive programming: the habit of writing code that checks its own assumptions, so a bug is caught at its source instead of ten thousand timesteps downstream. None of this is glamorous, and all of it is what separates a script from an instrument.
In this chapter, you will learn to:
- Generalize the
iostatidea from Chapter 7 to allocation withstatanderrmsg, and guard everyallocate. - Halt with
error stopinstead ofstop, choose an exit code, and report failure to the shell and to the automation that runs your code. - Compile with
-fcheck=all,-fbacktrace, and-ffpe-trapto catch out-of-bounds accesses, get a stack trace, and trap a floating-point exception the instant aNaNis born. - Drive gdb to a breakpoint and inspect a live Fortran array, and run valgrind to find a leak or a dangling read.
- Write an assertion, check preconditions, and adopt the defensive-programming patterns that keep numerical code honest.
- Name and disarm the usual suspects: uninitialized variables, out-of-bounds indices, integer overflow,
precision loss, and the notorious implicit
save.
Learning Paths
How to read this chapter by track. - 🔬 Scientist ("I need a right answer, not a plausible one") — §13.1 (guard allocations), §13.5 (assertions and preconditions), and §13.6 (the classic bugs) are your core. Skim the gdb/valgrind mechanics in §13.4; know they exist and return when you need them. - 📖 Standard — read straight through. Error handling is the one topic in Part II that no track should skip, as the part introduction warns. - 🔧 Legacy ("I inherited old code") — §13.6 is your survival kit: uninitialized variables and implicit
saveare endemic in old code, and §13.4's debuggers are how you understand a program nobody documented. - ⚡ HPC ("my run is expensive and long") — §13.2 (error stophalts all images in a parallel run) and §13.3 (-ffpe-trapto catch aNaNearly in a week-long simulation) are yours; and note where §13.5 says to remove the checks for production.
13.1 Errors as Values: iostat Revisited, stat/errmsg Introduced
Fortran's philosophy of error handling is refreshingly plain, and you have already met half of it. Many languages signal failure by throwing something — an exception that unwinds the call stack until someone catches it. Fortran, with a few deliberate exceptions we will meet, does the opposite: an operation that can fail offers you an output that reports whether it did, and hands control straight back to you. You ask; you decide. No hidden control flow, nothing unwinding behind your back — which, not coincidentally, is exactly the sort of predictability the optimizer loves.
You saw the pattern in Chapter 7: the
iostat/iomsg specifiers turn a fatal I/O error into a value you can test. iostat=ios sets ios to zero
on success, a negative value at end-of-file, and a positive value on an error, and — crucially — lets the
program continue; iomsg=msg fills a string with a human-readable description. That is the shape of all
Fortran error handling, and this chapter's first job is to point out that the same shape appears wherever an
operation can fail. The first place beyond I/O is memory allocation.
An allocate can fail. The obvious way is running out of memory — you asked for a grid larger than the
machine has — but there are quieter ways too, such as allocating an array that is already allocated. By
default, a failed allocate aborts your program with a terse runtime message, which is exactly the
behavior you do not want in a long run that has other things it could do (fall back to a smaller grid, write
a checkpoint, report cleanly). The fix is the same idea as iostat, spelled stat and errmsg.
Definition (
statanderrmsg). Optional specifiers onallocateanddeallocatethat turn a fatal memory error into a reportable value — the allocation counterpart ofiostat/iomsg.stat=ssets the integersto zero on success and a nonzero, processor-dependent value on failure (out of memory, an already-allocated object, an already-deallocated one), and lets the program continue instead of aborting.errmsg=msgfills the character variablemsgwith a description of the failure. This is "theiostatmechanism from Chapter 7, generalized" to memory: the same ask-then-decide pattern, a different verb.
Here is the guarded pattern in full. We allocate a field, check the status, and — to show a deterministic
failure you can reproduce — deliberately allocate the same array a second time, which is an error condition
the runtime reports through stat rather than aborting:
program allocation_stat
use, intrinsic :: iso_fortran_env, only: dp => real64, error_unit
implicit none
real(dp), allocatable :: big(:)
integer :: stat
character(len=256) :: errmsg
! First allocation, guarded. A grid too large for memory would set stat /= 0
! HERE, with a message, instead of aborting the program.
allocate(big(1000000), stat=stat, errmsg=errmsg)
if (stat /= 0) then
write(error_unit, '(a)') 'first allocate failed: ' // trim(errmsg)
error stop 3
end if
print '(a, i0)', 'first allocate : ok, size = ', size(big)
! Allocating an ALREADY-allocated variable is an error condition. Without stat=
! this line would abort; with it, we catch it and carry on.
allocate(big(2000000), stat=stat, errmsg=errmsg)
if (stat /= 0) then
print '(a)', 'second allocate: FAILED (already allocated)'
write(error_unit, '(a)') ' message (stderr): ' // trim(errmsg)
end if
deallocate(big, stat=stat)
print '(a, i0)', 'deallocate stat = ', stat
end program allocation_stat
$ gfortran -std=f2018 -Wall -O2 example-01-allocation-stat.f90 -o alloc && ./alloc
first allocate : ok, size = 1000000
second allocate: FAILED (already allocated)
deallocate stat = 0
The three lines on standard output are fully determined: the first allocation succeeds, so size(big) is
one million; the second fails because big is already allocated, so we report it and leave big untouched;
the deallocate then succeeds with stat = 0. The one thing we do not pin down is the text of errmsg,
which we route to standard error. Its wording is processor-dependent — a representative gfortran message is
Attempting to allocate already allocated variable 'big' — and a program should print such text but never
branch on it. Branch on the integer stat; show the string to a human.
Notice two habits already on display, both of which the rest of the chapter formalizes. Diagnostics go to
standard error (error_unit from iso_fortran_env), not standard output, so a user piping your results
to a file still sees the complaint on the terminal and does not find an error message wedged into their
data. And a genuine failure ends in error stop with an integer — the deliberate, informative halt we turn
to next.
🔗 Connection: the symmetry is worth fixing in your mind because it repeats. I/O uses
iostat/iomsg(Chapter 7); allocation usesstat/errmsg; and in the parallel chapters the coarray statements (Chapter 32) take astat=too, with named constants likestat_failed_imagefor a node that has died mid-run. One pattern — ask whether it worked, then decide — covers I/O, memory, and distributed execution. Learn it once here.⚠️ Common Pitfall: the value the runtime puts in
staton failure is processor-dependent, exactly like the positiveiostaterror values of Chapter 7. Never writeif (stat == 151) …against a magic number you saw once; the only portable test isif (stat /= 0). If you need to distinguish kinds of allocation failure, theiso_fortran_envmodule supplies the named constantstat_failed_image(and, for stopped images,stat_stopped_image) for the coarray cases; for ordinary allocation, nonzero-means-failure is all the standard promises.
13.2 stop vs error stop, and the Exit Code
When a program decides it cannot sensibly continue, it must stop — but how it stops carries information that the world outside the program reads. A program is not an island; it is a process launched by a shell, a Makefile, a batch scheduler, or a continuous-integration runner, and when it ends it hands that launcher a single small integer: its exit code. By universal convention, zero means success and any nonzero value means failure. Getting this right is the difference between a nightly pipeline that notices your solver crashed and one that cheerfully feeds its garbage output to the next stage.
Fortran gives you two ways to terminate on purpose. You have used stop; now meet its serious sibling.
Definition (
error stop).error stopterminates the program immediately as an error termination, reporting failure to the environment with a nonzero exit code. Plainstopis a normal termination with a zero exit code by default. Both accept an optional stop code — an integer or a character constant — whicherror stopuses as (or maps to) the process's exit status and which the runtime also echoes to standard error. The two differ in intent and, in a parallel program, in force:error stophalts every image at once, wherestopis a more orderly collective shutdown (Chapter 32).
Here is the whole idea in a program small enough to hold in your head. It validates a parameter that, in the real solver, would have come from a namelist, and refuses to run on a nonsensical value:
program exit_codes
use, intrinsic :: iso_fortran_env, only: error_unit
implicit none
integer :: n_steps
n_steps = -5 ! pretend this arrived from a config file
if (n_steps <= 0) then
write(error_unit, '(a, i0)') 'fatal: n_steps must be positive, got ', n_steps
error stop 2 ! deliberate error termination, exit code 2
end if
print '(a)', 'run complete' ! (not reached in this run)
end program exit_codes
Compile it, run it, and ask the shell what exit code it produced. Because the runtime also prints its own
ERROR STOP line to standard error, you see two messages — yours and the system's — and then the code:
$ gfortran -std=f2018 -Wall exit_codes.f90 -o exit_codes
$ ./exit_codes
fatal: n_steps must be positive, got -5
ERROR STOP 2
$ echo $?
2
Everything here is deliberate. Our diagnostic — what went wrong, with the offending value — goes to
standard error. Then error stop 2 ends the process with exit code 2, which echo $? reads back. (The
ERROR STOP 2 line is emitted by the gfortran runtime; its exact form is version-dependent, so treat it as
representative — but the exit code you chose is not.) Had we written a bare stop instead, the program
would have ended with code 0, telling the shell "all is well" — a lie that a Makefile would believe.
Why choose the code? Because distinct codes let automation distinguish failure modes without parsing text.
A common discipline, which the Project Checkpoint adopts, is to reserve small integers per category — say,
2 for a bad configuration and 3 for an allocation failure — so a wrapper script can branch on $? and
react appropriately. One caveat worth knowing: on Unix the exit status is taken modulo 256, so keep your
codes in the range 1–255 (a stop 256 would arrive as 0, which would read as success). Small, meaningful,
positive codes are the rule.
stop |
error stop |
|
|---|---|---|
| Termination kind | normal | error |
| Default exit code | 0 (success) |
nonzero (gfortran: 1) |
With a numeric code N |
exits with N |
exits with N |
| Runtime message | none for bare stop |
echoes ERROR STOP … to stderr |
| Parallel (coarrays) | orderly collective stop | immediate, all images |
| Use it for | a successful or early normal end | a failure the caller must notice |
🚪 Threshold Concept — your program's exit code is a promise to everything that runs it. Beginners think of a program as ending when it prints its last line. Professionals know a program ends by reporting — handing the shell a verdict that scripts, schedulers, and CI systems act on without ever reading the output.
error stopwith a chosen code is how you tell the truth about failure to a machine. Once you see the exit code as the program's word, you will never end a failed run with a barestopagain, and you will understand why a solver that "worked" but returned code1is a solver that told you it did not.📜 From History:
error stopis young — it arrived in Fortran 2008, alongside coarrays, and its parallel semantics are the reason it exists in that standard: with many images running at once, you need a way to say "stop everyone, now, this is fatal," distinct from an orderlystop. FORTRAN 77 had onlySTOP, and the notion of a meaningful exit code was left to folklore and platform convention. That the language grew a first-class error termination is one more small piece of the theme that modern Fortran is a modern language: it takes its place in a toolchain of shells and build systems seriously.🐍 Python Comparison: Python signals failure by raising an exception, which unwinds the stack until a
try/exceptcatches it — or, if none does, ends the interpreter with a traceback and a nonzero exit code. Fortran has no exceptions: there is notry, noexcept, no stack unwinding. You get the value returns of §13.1 for recoverable conditions anderror stopfor fatal ones. This is a deliberate design choice, not a gap — hidden non-local control flow is exactly what a compiler cannot optimize across, so a language built for speed prefers explicit status you test in the open. It is also, insys.exit(2), the one place Python and Fortran agree: an integer exit code is the lingua franca of the shell.
13.3 The Development Flags: -fcheck, -fbacktrace, -ffpe-trap
The compiler is your most powerful debugging tool, and most of its help is switched off by default because it costs speed. While you are developing, you pay that cost gladly, because the flags turn three whole categories of silent misbehavior into loud, located diagnostics. All three are catalogued in Appendix C; here is what they do and how to read what they say.
-fcheck=all — runtime checks. This flag inserts checks the standard does not require: array-bounds
verification, allocation status, pointer association, and more. The headline is bounds checking. Consider an
out-of-bounds access, the most common array bug there is:
program bounds_demo
implicit none
integer :: a(5), i
a = 0
i = 7
a(i) = 1 ! index 7 into a 5-element array — out of bounds
print *, a
end program bounds_demo
Compiled plainly, this is undefined behavior: gfortran writes to whatever memory lies past a, and the
program may print garbage, crash, or — worst of all — appear to work while quietly corrupting a neighbor.
Compiled with the check, it stops at the offending line with a message that names the array, the dimension,
and the bound:
$ gfortran -std=f2018 -Wall -fcheck=all -g bounds_demo.f90 -o bounds && ./bounds
At line 6 of file bounds_demo.f90
Fortran runtime error: Index '7' of dimension 1 of array 'a' above upper bound of 5
That text block is a representative gfortran message — the exact wording varies by version, and we did
not run the program to obtain it; we are telling you the shape of what you will see. The value is immense:
without the flag you have a heisenbug that moves when you look at it; with it, you have a line number.
-fbacktrace — a stack trace on a crash. When a program dies from a signal, this flag prints the chain
of procedure calls that led there, so you see not just where but how you arrived. Pair it with -g,
which emits the debug information that lets the trace carry file names and line numbers. It costs nothing at
run time (it only acts when the program is already crashing), so there is little reason ever to omit it
during development.
-ffpe-trap — catch a floating-point exception at birth. This is the subtlest and, for numerical code,
the most valuable. By default, IEEE 754 arithmetic
(Chapter 20) does not crash on a bad
operation — it produces a special value and keeps going. Divide by zero and you get Infinity; compute
$0/0$ or $\sqrt{-1}$ and you get a NaN (Not a Number); these then propagate silently through every
subsequent calculation, so a single bad operation at step 3 can poison a field that you only notice is NaN
at step 30,000. Trapping changes that.
Definition (floating-point exception trap). A floating-point exception is a condition the CPU can raise for an anomalous arithmetic operation:
invalid(e.g. $0/0$ or $\sqrt{-1}$, yieldingNaN),zero(division of a nonzero by zero, yieldingInf),overflow(a result too large to represent), and the benign-but-constantunderflowandinexact. To trap an exception (gfortran's-ffpe-trap=invalid,zero,overflow) is to ask the hardware to halt the program at the exact instruction that raised it, instead of silently producingNaN/Inf. Combined with-fbacktrace, it converts "the field becameNaNsometime in the last hour" into "theNaNwas created on this line."
The demonstration is a division by zero, shown two ways:
program fpe_demo
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: x, y
x = 0.0_dp
y = 1.0_dp / x ! divide by zero
print '(a, f0.1)', 'y = ', y
end program fpe_demo
Without trapping, the program runs to completion and prints a special value; with trapping, it stops at line 6, and the backtrace points a finger:
$ gfortran -std=f2018 -Wall fpe_demo.f90 -o fpe && ./fpe
y = Inf
$ gfortran -std=f2018 -Wall -ffpe-trap=invalid,zero,overflow -g -fbacktrace fpe_demo.f90 -o fpe && ./fpe
Program received signal SIGFPE: Floating-point exception - erroneous arithmetic operation.
Backtrace for this error:
#0 ...
#1 ...
at fpe_demo.f90:6
Again the crash text is representative and version- and platform-dependent — we did not execute the program,
and the #0/#1 frames and addresses will differ on your machine — but the essential facts are reliable: the
untrapped run prints an infinity and continues, and the trapped run dies at the line that divided by zero.
For a solver that can go unstable, this is how you find the first bad step.
⚠️ Common Pitfall — trap the right exceptions. Trap
invalid,zero, andoverflow; do not addunderfloworinexact. Underflow (a result rounding to zero) and inexact (any result that is not representable exactly, which is almost every real operation — recall from Chapter 20 that $0.1$ is not exact in binary) occur constantly in correct code; trapping them would halt a healthy program instantly. A second subtlety: because trapping is a global hardware mode, it can also fire inside a third-party library that deliberately computes withNaN/Infas sentinels. If a trap fires in code you did not write, that is why — and it is a good reason to keep the trap in your development build and understand exactly what it is telling you.🧩 Try It Yourself. Type the
bounds_demoprogram above. Compile it without-fcheckand run it — note what it prints (it may look harmless). Now recompile with-fcheck=all -gand run it again. Predict, before you run, what the second version will say and on which line. The gap between "seems fine" and "runtime error, line 6" is precisely the value of the flag, and feeling that gap once will make you reach for-fcheck=allfor the rest of your career.⚡ Performance Note:
-fcheck=allis a development tool, not a production one. Bounds checking adds a comparison to every array access, which in a tight stencil loop can cost you a large fraction of your speed. The discipline — spelled out in Chapter 30 — is to build with-fcheck=all -g -O0while developing and to remove-fcheckfor production runs, switching to-O2or higher.-fbacktraceand-ffpe-trapare cheap enough to keep on in many production builds;-fcheckis the one you take off. That performance is not accidental is a theme of this book, and here it is a direct instruction: know which flags to strip when the run has to be fast.
13.4 Debuggers: gdb for Logic, valgrind for Memory
Flags catch faults at the moment they happen. Sometimes you need to stop time and look around — inspect a
variable, step one line at a time, walk back up the call stack. That is a debugger, and for gfortran the two
that matter are gdb (for logic and control flow) and valgrind (for memory errors). Neither requires
you to change your code; both want you to have compiled with -g (and, ideally, -O0, so the optimizer has
not rearranged the code out from under the line numbers).
gdb — watch a program from the inside. You compile with debug info, launch the program under gdb, set a
breakpoint where you want to pause, run, and then inspect. gdb understands Fortran: it knows arrays are
1-based, it prints whole arrays and array slices in Fortran syntax, and it shows you locals by name. A
session to inspect a solver's field at a chosen step looks like this — presented as a representative
transcript, not a run we performed:
$ gfortran -std=f2018 -Wall -g -O0 heat.f90 -o heat
$ gdb ./heat
(gdb) break heat.f90:42 # pause when execution reaches line 42
(gdb) run
Breakpoint 1, heat () at heat.f90:42
42 call step(field, alpha, dt)
(gdb) print nx # inspect a scalar
$1 = 5
(gdb) print field%u(1:3, 2) # a Fortran array slice — 1-based, as you wrote it
$2 = (100, 0, 0)
(gdb) next # run the current line, stay in this procedure
(gdb) step # like next, but descend INTO a called procedure
(gdb) backtrace # who called whom to get here
(gdb) continue # resume until the next breakpoint
The moves are always the same handful: break to choose where to stop, run to start, print to look,
next/step to advance one line (stepping over or into a call), backtrace (bt) to see the call
chain, and continue to let it go. The exact $1`, `$2 output and line numbers above are illustrative; what
is reliable is the vocabulary, which is worth committing to memory because it does not change from program to
program.
💡 Intuition:
-fcheckand gdb answer different questions.-fcheckanswers "did something illegal happen, and where?" — it is a smoke detector you leave armed. gdb answers "why is this variable wrong?" — it is you walking through the house with a flashlight after the alarm. Reach for the flag first (it is free to leave on and often names the line outright); reach for the debugger when you know what is wrong but not why.
valgrind — catch memory crimes. Some bugs are not about logic but about memory: reading uninitialized
storage, reading or writing past the end of an allocation, using memory after it was freed (the dangling
pointer of Chapter 11), or leaking memory you allocated and
never freed. valgrind runs your program on a synthetic CPU that watches every memory access and reports each
crime with a stack trace. You compile with -g and simply run under it:
$ gfortran -std=f2018 -Wall -g -O0 leaky.f90 -o leaky
$ valgrind --leak-check=full ./leaky
==12345== Invalid read of size 8
==12345== at 0x... : MAIN__ (leaky.f90:19)
==12345== Address 0x... is 0 bytes inside a block of size 8 free'd
==12345== at 0x... : deallocate ...
...
==12345== LEAK SUMMARY:
==12345== definitely lost: 8 bytes in 1 blocks
That transcript is representative — the addresses and the ==PID== prefix will differ, and we did not run
it. What valgrind reliably gives you is the kind of error (an invalid read of freed memory; a definite
leak) and the line that committed it. This is the tool that finds the dangling-pointer bug from Chapter 11
— where a pointer still aliases memory freed through another pointer — precisely because that bug passes
every test you can write in the language itself.
🔗 Connection: here is one more argument for the "prefer
allocatable" rule of Chapter 11. A plainallocatablearray is freed automatically when it goes out of scope and can never be aliased, so it cannot leak and cannot dangle — two whole categories of valgrind error that a pointer can produce and an allocatable cannot. The solver'sfield_tholds its grid in anallocatablecomponent for exactly this reason. When you do use pointers or call into C (Chapter 14), valgrind is your safety net.⚠️ Common Pitfall: valgrind is thorough but slow — commonly 10–30× slower than a native run (a Tier-2 order of magnitude, not a promise), because it instruments every memory access. Run it on a small test case, not your production grid. For the specific case of stack and global buffer overflows, and for much faster instrumented runs, gfortran also offers
-fsanitize=address(AddressSanitizer) and-fsanitize=undefined, which you compile into the program; they catch an overlapping-but-different set of faults at a fraction of valgrind's slowdown. Different tools, different coverage — keep both in the kit.🔄 Check Your Understanding. 1. You compile with
-fcheck=alland the program stops with "Index '7' … above upper bound of 5." Which class of bug is this, and what would the same program most likely have done without the flag? 2. Which tool would you reach for to find a memory leak, and which to understand why a scalar has the wrong value at line 42? 3. Why must you not addunderflowto your-ffpe-traplist?Answers
1. An out-of-bounds array access. Without-fcheckit is undefined behavior — most likely it would write past the array and either print garbage, crash unpredictably, or silently corrupt adjacent memory. 2. valgrind (--leak-check=full) for the leak; gdb (breakpoint at line 42,invalid,zero, andoverflow.
13.5 Assertions, Preconditions, and Defensive Programming
So far we have handled failures the language and runtime hand us. Defensive programming is the deliberate practice of manufacturing your own checks — encoding, in executable form, the beliefs your code depends on, so that a violated belief stops the program at its source instead of silently producing a wrong number far downstream.
Definition (defensive programming). Defensive programming is writing code that actively verifies its own assumptions — validating inputs (preconditions), checking that computed results are sane (postconditions), and guarding against the states it is not built to handle — and that fails early, loudly, and with a diagnostic when an assumption is violated. Its goal is to convert a subtle, far-downstream wrong answer into an immediate, localized, diagnosable halt. The single most useful tool of the discipline is the assertion.
Definition (assertion). An assertion is a statement, placed in the code, that a condition the programmer believes must be true at that point actually is; if it is false, the program halts at once with a message, because a false assertion means a bug has already occurred. Fortran has no built-in
assert(unlike C'sassert.h), so you write one: a small procedure that does nothing when the condition holds anderror stops with a message when it does not.
The assertion procedure is a handful of lines, and once written it serves your whole program:
module asserts
use, intrinsic :: iso_fortran_env, only: error_unit
implicit none
private
public :: assert
contains
subroutine assert(condition, message)
logical, intent(in) :: condition
character(len=*), intent(in) :: message
if (.not. condition) then
write(error_unit, '(a)') 'ASSERTION FAILED: ' // message
error stop 1
end if
end subroutine assert
end module asserts
Now use it to guard a routine's preconditions (what must be true on entry) and postconditions (what must be true of the result). A function computing a mean depends on a nonempty array, and its result must lie between the smallest and largest inputs — both are beliefs worth asserting:
program defensive_demo
use asserts, only: assert
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: v(4) = [2.0_dp, 4.0_dp, 6.0_dp, 8.0_dp]
print '(a, f6.2)', 'mean = ', mean(v)
contains
function mean(x) result(m)
real(dp), intent(in) :: x(:)
real(dp) :: m
call assert(size(x) > 0, 'mean: array must be non-empty') ! precondition
m = sum(x) / real(size(x), dp)
call assert(m >= minval(x) .and. m <= maxval(x), 'mean: out of range') ! postcondition
end function mean
end program defensive_demo
$ gfortran -std=f2018 -Wall -O2 example-02-assertions.f90 -o defensive && ./defensive
mean = 5.00
The mean of $[2,4,6,8]$ is $20/4 = 5.00$, and because $2 \le 5 \le 8$ the postcondition holds, so the
program runs to completion and prints the answer. The assertions are invisible when the code is correct —
which is the point. But hand mean an empty array and the precondition fires at the entry to mean, not
somewhere deep in a division, printing (representatively) ASSERTION FAILED: mean: array must be non-empty
and ending with a nonzero exit code. The bug is reported one line from where it lives, instead of surfacing
as a mysterious NaN in your output file an hour later.
🚪 Threshold Concept — an assertion is an executable belief. A comment that says "n is always positive here" is a hope; the reader trusts it, the compiler ignores it, and when it quietly stops being true nothing happens until the wrong answer appears.
call assert(n > 0, 'n must be positive')is the same belief made executable — it cannot rot silently, because the instant it becomes false the program says so, at that line. This reframes what assertions are for: not to handle errors you expect (that is whatstatandiostatare for), but to catch the "impossible" situations that reveal a bug in your own logic. The two are different jobs. Recover from what the world does to you; assert what you believe about yourself.
Defensive programming is broader than the assert call, and the chapter's specific tools all fit under it:
- Validate at the boundary. Check a configuration or a procedure argument the moment it arrives, before it can contaminate a computation — the Project Checkpoint validates the whole config up front.
- Guard every operation that can fail. Every
opengets aniostat(Chapter 7); everyallocategets astat(§13.1). No unchecked failure points. - Initialize everything. An unset variable is a bug waiting for a platform to change (§13.6); give every variable a value before you read it.
- Report to standard error, with context. A diagnostic should name what was expected and what was
seen —
'n_steps must be positive, got -5'beats'bad input'every time.
⚡ Performance Note — make your checks removable. Assertions and bounds checks cost cycles, and in a hot loop that cost is real. The professional pattern is to make them vanish in production. One clean way is a compile-time switch: declare
logical, parameter :: checking = .true.in a module and writeif (checking) call assert(...). When you flipcheckingto.false., the condition is a compile-time constant, and the optimizer deletes the dead branch entirely — you pay nothing. (The C-and-Python world does the same thing withNDEBUGandpython -O; Fortran programmers often reach for the C preprocessor, a.F90file and#ifndef NDEBUG, for the identical effect.) The discipline mirrors §13.3's rule for-fcheck: check exhaustively while developing, strip the cost for the production run, and — per Chapter 30 — record which build you used.🔗 Connection: assertions and preconditions are the inline half of correctness; the external half is testing — running the code against known answers, which is Chapter 37's subject (pFUnit unit tests and regression tests against the analytical solution). They are complements: an assertion checks an invariant on every run in production, where a test checks a specific case before you ship. A codebase wants both — assertions to catch the impossible in the field, tests to catch the wrong before it leaves.
13.6 The Usual Suspects
Every language has a rogues' gallery of characteristic bugs, and knowing Fortran's by name is half of never being caught by them. Here are the five the outline names, each shown as the mistake and then the fix.
1. Uninitialized variables. Fortran does not zero your local variables. A freshly declared local
holds whatever bits were in that memory, and reading it before you assign it is undefined behavior — which is
insidious precisely because the garbage is often zero by luck, so the bug hides on your machine and appears
on the cluster. The fixes are layered: initialize explicitly before first use; compile with -Wall, which
warns about many (not all) use-before-set cases; and, to catch the rest, compile with -finit-real=snan
(Appendix C), which fills uninitialized reals with a signaling NaN so that using one trips
-ffpe-trap=invalid at the exact line — turning a silent bug into §13.3's loud one.
2. Out-of-bounds access. Indexing a(0) or a(n+1), often from an off-by-one in a loop or from
forgetting that Fortran arrays are 1-based (Chapter 5).
Silent by default; caught immediately by -fcheck=bounds (included in -fcheck=all), as §13.3 showed. This
is the single best reason to keep -fcheck=all on during development.
3. Integer overflow. A default integer (32-bit) holds values up to huge(0_int32) = 2,147,483,647.
Exceed it and it wraps silently to a negative number. The classic scientific trap is a product of grid
dimensions — nx*ny*nz for a large 3-D grid, or a total element count — that quietly exceeds two billion.
Watch it happen, and watch a 64-bit integer fix it:
program overflow_and_save
use, intrinsic :: iso_fortran_env, only: int32, int64
implicit none
integer(int32) :: big32
integer(int64) :: big64
integer :: k
do k = 1, 3; call buggy_counter(); end do ! implicit-save trap (suspect 5)
do k = 1, 3; call good_counter(); end do
big32 = huge(0_int32)
print '(a, i0)', 'huge(int32) = ', big32
big32 = big32 + 1_int32 ! signed overflow: wraps
print '(a, i0)', 'huge + 1 (int32) = ', big32
big64 = int(huge(0_int32), int64) + 1_int64 ! 64-bit: no overflow
print '(a, i0)', 'huge + 1 (int64) = ', big64
contains
subroutine buggy_counter()
integer :: count = 0 ! initializer in the declaration => IMPLICIT SAVE
count = count + 1
print '(a, i0)', 'buggy count = ', count
end subroutine buggy_counter
subroutine good_counter()
integer :: count ! no initializer: a fresh local each call...
count = 0 ! ...reset explicitly on entry
count = count + 1
print '(a, i0)', 'good count = ', count
end subroutine good_counter
end program overflow_and_save
$ gfortran -std=f2018 -Wall -O2 example-03-usual-suspects.f90 -o suspects && ./suspects
buggy count = 1
buggy count = 2
buggy count = 3
good count = 1
good count = 1
good count = 1
huge(int32) = 2147483647
huge + 1 (int32) = -2147483648
huge + 1 (int64) = 2147483648
The integer arithmetic is exact and hand-checkable: huge(0_int32) is 2,147,483,647; adding one to a 32-bit
signed integer overflows and wraps to $-2{,}147{,}483{,}648$ (gfortran uses two's-complement wraparound); the
same computation in a 64-bit integer gives the correct 2,147,483,648. The fix in real code is to size counts
and index products as integer(int64) whenever they might exceed two billion. (A note on standards
correctness: signed integer overflow is not defined by the standard — gfortran wraps, but a program should
not rely on it. You can make overflow trap instead with -fsanitize=undefined or -ftrapv; we show the
wrap here only to make the danger visible.)
4. Precision loss. Reals are approximate (Chapter 20),
and three habits go wrong: comparing them with == (if (x == 0.3_dp) is almost never what you want — use
abs(x - 0.3_dp) < tol); mixing single and double precision so a real(dp) computation is silently degraded
by a single-precision literal (always write the kind suffix, 1.0_dp); and the integer-division trap you met
in Chapter 3, where 1/2 is 0
because both operands are integers. The unifying fix is the discipline this book has taught since Chapter 3:
one precision kind everywhere (real(dp)), kind-suffixed literals, and tolerance comparisons for reals.
5. Implicit save. This is the trap that catches the reader off guard, and the program above shows it.
When you give a local variable an initializer in its declaration — integer :: count = 0 — Fortran does
not re-initialize it on each call. Instead the variable is implicitly given the save attribute: it is
initialized once, before the program starts, and retains its value between calls. So buggy_counter
prints 1, 2, 3 across three calls, not the 1, 1, 1 a newcomer expects. The good_counter version separates
declaration from assignment — integer :: count then count = 0 — so count is reset on every entry and
prints 1, 1, 1. The rule: an initializer in a declaration means "save," not "reset." If you want a fresh
value each call, assign it in an executable statement; if you genuinely want persistence, write save
explicitly to say so on purpose.
🐛 Find the Bug. A colleague writes an accumulator subroutine and cannot understand why the running total keeps growing across calls that should each start fresh:
fortran subroutine accumulate(x) real(dp), intent(in) :: x real(dp) :: total = 0.0_dp ! "start at zero every call" total = total + x print *, 'total = ', total end subroutine accumulate
Diagnosis
The initializer= 0.0_dpin the declaration makestotalimplicitlysaved: it is set to zero once, before the program runs, and thereafter keeps its value between calls, so the total accumulates across every call — the opposite of the comment's intent. The fix is to separate the declaration from the reset:real(dp) :: totalon one line andtotal = 0.0_dpas the first executable statement. If persistence were desired, the honest spelling is an explicitreal(dp), save :: total = 0.0_dp, so the intent is stated, not stumbled into.🔧 Modern vs Legacy: implicit
saveis a special hazard when you modernize old code (Part IV). FORTRAN 77 code frequently relied on locals retaining their values between calls — sometimes on purpose, often by accident of how a particular compiler laid out static memory. When you convert such a routine, aDATA-initialized or saved local that you "clean up" into a modern initializer keeps its save semantics, while one you rewrite to assign on entry loses them — and if the old code secretly depended on persistence, your tidy modern version changes the answer. The lesson of the "legacy code is not a burden" theme applies precisely here: understand why the old code held state before you change how it does. When in doubt, makesaveexplicit so the intent is on the page.🔄 Check Your Understanding. 1. Why can an uninitialized-variable bug pass all your tests on your laptop and then fail on a cluster? 2.
integer :: n = 0inside a subroutine — isnreset to 0 on every call? What attribute does the initializer silently confer? 3. A grid is $2000 \times 2000 \times 2000$. Why mightnx*ny*nzcomputed as defaultintegergive a negative number, and what is the fix?Answers
1. An uninitialized local holds whatever was in that memory; it is often zero by luck on one machine and something else on another, so the bug is platform-dependent.-finit-real=snanplus-ffpe-trap=invalidmakes it fail loudly everywhere. 2. No — it is not reset each call. The initializer confers an implicitsave, sonis initialized once and keeps its value between calls. Assignn = 0in an executable statement for per-call reset. 3. $2000^3 = 8\times10^9$ exceeds the 32-bit signed limit of about $2.1\times10^9$, so the product overflows and wraps negative. Compute it ininteger(int64)(e.g. size the operands as 64-bit).
Project Checkpoint
Your solver reads a namelist config (Chapter 7)
and holds its state in a field_t derived type (Chapter 9). It is
also, right now, credulous: hand it nx = 0, a negative dt, or a grid too large for memory, and it will
either crash cryptically or — worse — allocate something nonsensical and march forward. This checkpoint makes
it robust. We add a validate_config routine that checks the configuration before any memory is
touched, we guard the field allocation with stat, and we error stop with a clear message and a distinct
exit code on each kind of failure.
The heart of the increment is the validator — every precondition the run depends on, checked at the boundary:
subroutine validate_config(nx, ny, dt, alpha)
integer, intent(in) :: nx, ny
real(dp), intent(in) :: dt, alpha
! Preconditions: a physical run needs a positive grid, timestep, and diffusivity.
if (nx < 1 .or. ny < 1) then
write(error_unit, '(a, i0, a, i0)') 'config error: grid must be positive, got ', nx, ' x ', ny
error stop 2
end if
if (dt <= 0.0_dp) then
write(error_unit, '(a, es10.2)') 'config error: dt must be positive, got ', dt
error stop 2
end if
if (alpha <= 0.0_dp) then
write(error_unit, '(a, es10.2)') 'config error: alpha must be positive, got ', alpha
error stop 2
end if
end subroutine validate_config
The driver then validates, allocates the field guarded, and only afterward sets up the plate. With a valid
configuration (nx = 5, ny = 4, dt = 0.25, alpha = 1.0e-4) it runs cleanly:
$ gfortran -std=f2018 -Wall -fcheck=all -g project-checkpoint.f90 -o checkpoint && ./checkpoint
grid validated : 5 x 4
sum(u) : 400.00
ready to step (Chapter 24).
The field is $5 \times 4$; we set the hot top row u(1,:) = 100 across all four columns, so sum(u) = 4
\times 100 = 400.00, everything else being zero. The full, self-contained program — bundling a small
kinds module so it builds alone — is code/project-checkpoint.f90, with the hand-computed output.
Three decisions carry the chapter's lessons. Validate before you allocate: a bad grid is caught before a
single byte is requested, and the message names the offending value. Distinct exit codes: configuration
failures error stop 2, an allocation failure error stop 3, so a wrapper script can tell "you gave me
nonsense" apart from "the machine ran out of memory" by reading $? alone. And build with -fcheck=all -g
while developing (note the flag in the compile line above), so an off-by-one in the stencil you write in
Chapter 24 is a runtime error
with a line number, not silent corruption — then strip -fcheck for the production runs of
Chapter 30. When the validator folds into the
heat_types module, field_init's own allocate gains a stat= check by the same pattern. There is one
more precondition worth adding as the solver becomes real: the five-point stencil needs an interior, so a
robust validate_config will eventually require nx >= 3 .and. ny >= 3, and — once you meet the CFL
condition in Chapter 24 —
will check that dt is small enough that the explicit scheme does not blow up into the very NaNs that
-ffpe-trap would otherwise catch for you. Robustness is not a one-time step; it is a habit the rest of the
book keeps building.
Summary
This chapter turned optimistic code into robust code: code that asks whether each operation worked, halts deliberately when it cannot go on, and checks its own beliefs.
| Concept | The short version |
|---|---|
stat / errmsg |
The iostat idea for memory: allocate(a(n), stat=s, errmsg=msg). s = 0 on success, nonzero (processor-dependent) on failure. Guard every allocate. |
error stop vs stop |
stop = normal end, exit code 0. error stop [N] = error end, nonzero exit code (chosen N), halts all images in parallel. Report failure so the shell/CI notices. |
| Exit codes | 0 = success, nonzero = failure; taken mod 256 on Unix. Use distinct small codes per failure category; read with echo $?. |
-fcheck=all |
Runtime checks (bounds, allocation, pointers). Catches out-of-bounds with a line number. Development only — remove for production. |
-fbacktrace |
Print a call-stack trace on a crash. Pair with -g. Cheap; keep it on. |
-ffpe-trap=invalid,zero,overflow |
Halt at the operation that makes a NaN/Inf, instead of letting it propagate silently. Never trap underflow/inexact. |
| gdb / valgrind | gdb: breakpoint + print/next/step/bt to see why a value is wrong. valgrind: find leaks, dangling reads, out-of-bounds — slow, small cases. |
| assertion | A hand-written assert(cond, msg) that error stops if cond is false. An executable belief for the "impossible," distinct from stat/iostat for the expected. |
| defensive programming | Validate inputs (preconditions), check results (postconditions), guard every failure point, initialize everything, report to error_unit with context. Fail early and loud. |
| The usual suspects | Uninitialized variables; out-of-bounds (-fcheck); integer overflow (use int64); precision loss (== on reals; kind suffixes; integer division); implicit save (an initializer in a declaration means "save," not "reset"). |
The two things to memorize. First, stat/errmsg is iostat/iomsg for memory — guard every
allocate, test the integer, show the message, and error stop with a chosen code so failure is visible to
the outside world. Second, an initializer in a declaration confers an implicit save — integer :: n =
0 inside a procedure is set once and persists, not reset per call; assign in an executable statement when
you want a fresh value.
The heat-solver piece added this chapter: validate_config, checking nx, ny, dt, and alpha at
the boundary; a stat-guarded field allocation; distinct error stop codes for config vs allocation
failure; and the habit of building with -fcheck=all -g while developing.
Spaced Review
Retrieval practice on the two chapters this one generalizes and builds on — I/O error handling (Chapter 7) and pointers (Chapter 11). Answer before peeking.
-
(Ch. 7) After a
read, what doiostatvalues of zero, a negative number, and a positive number each mean — and how is this the same shape as thestatyou met in §13.1?
Answer
Zero = success; negative = end-of-file (or end-of-record); positive = an error. `stat` on `allocate` uses the same pattern — zero for success, nonzero for failure — because both are the one Fortran idea: an operation that can fail returns a status you test, rather than throwing. (There is no end-of-file analogue for `allocate`, so `stat` is simply zero/nonzero.) -
(Ch. 7) Why should an end-of-file test compare
iostatagainst the named constantiostat_endrather than the literal-1, and how does the same reasoning apply to the value instat?
Answer
The exact value the runtime assigns at end-of-file is processor-dependent (commonly `-1`, but not guaranteed), so portable code uses `iostat_end` from `iso_fortran_env`. The same reasoning forbids testing `stat` against a magic number like `151`: the nonzero failure value is processor-dependent, so the only portable test is `stat /= 0`. -
(Ch. 11) A pointer
qstill aliases memory that was freed through another pointerp. Why does this dangling pointer defeat ordinary testing, and which of this chapter's tools would catch a read through it?
Answer
After `deallocate(p)`, `q` still holds the old address and may even report `associated(q) == .true.`, so no in-language test detects it — it is undefined behavior that "passes." valgrind (§13.4) catches the invalid read of freed memory at the offending line; `-fsanitize=address` can too. The language-level fix from Chapter 11 is discipline: `nullify` every alias after `deallocate`. -
(Ch. 11) The solver's
field_tstores its grid as anallocatablecomponent, not apointercomponent. Give two ways this choice removes an entire class of the bugs §13.4's valgrind hunts for.
Answer
A plain `allocatable` is freed automatically when it goes out of scope, so it cannot **leak** (no forgotten `deallocate` on an error path); and it can never be aliased, so it cannot **dangle** (no second pointer to be left pointing at freed memory). Two whole valgrind categories — definite leaks and dangling reads — become impossible, which is half of Chapter 11's "prefer `allocatable`" argument.
What's Next
Part II is complete. Your solver is now organized into modules, its state lives in a purpose-built type, and
— as of this chapter — it validates its inputs and fails loudly and clearly when something is wrong. That is
code an engineer would be willing to maintain, and it is exactly the code you want before you connect it to
the wider world. Chapter 14
opens Part III by teaching Fortran to talk to C — the iso_c_binding module, the bind(c) attribute,
and the art of passing arrays and structs across the language boundary. Error handling comes with you: C
reports failure through return codes and the global errno, not through stat or error stop, so calling
between the languages means translating between two error conventions. The robustness discipline you built
here is what lets you do that translation without losing the thread of what went wrong. Next, we cross the
border.