Stop Passing Array Sizes by Hand: Assumed-Shape Arrays in Fortran Modules
Passing array sizes as separate arguments is a legacy habit that defers bugs to runtime. Module procedures with assumed-shape dummy arrays let the compiler check every call — here's a worked normalization kernel and the honest trade-offs.
03 Feb 2026, 06:51 UTC

If you've inherited Fortran code where every call site passes an array and its length as separate arguments — call normalize(x, n) — you've also inherited a class of bugs that only show up at runtime. Pass the wrong n, mismatch the rank, forget an intent, and the failure reads as mysterious memory corruption three subroutines away from the actual mistake.
The durable fix is not a library or a linter. It's a house rule: put procedures in modules, and declare dummy arrays as assumed-shape, e.g. real(real64), intent(inout) :: x(:). The compiler then checks every call against an explicit interface, and the callee asks the array itself for its size and bounds. This post walks through why, using a small normalization kernel as the worked example.
The brittle pattern: explicit-size signatures
Older Fortran (and Fortran written in that style today) passes shape information manually:
subroutine normalize(x, n)
integer, intent(in) :: n
real(8), intent(inout) :: x(n)
...
end subroutineTwo things go wrong here. First, n is a promise the caller makes, not a fact the compiler verifies — nothing stops call normalize(x, n+1) from marching off the end of the array. Second, if this subroutine is called from a scope with no explicit interface (a common situation in legacy code with external procedures), the compiler can't even check that the argument types match. Errors defer to runtime, where they surface as wrong answers or crashes far from the cause.
The module + assumed-shape rewrite
Moving the procedure into a module and using an assumed-shape dummy removes both failure modes:
module normalize_mod
use iso_fortran_env, only: real64
implicit none
contains
subroutine normalize(x)
real(real64), intent(inout) :: x(:)
real(real64) :: lo, range
lo = minval(x)
range = maxval(x) - lo
if (range > 0.0_real64) then
x = (x - lo) / range
else
x = 0.0_real64 ! constant array: define the policy explicitly
end if
end subroutine normalize
end module normalize_modNotice what's missing: there is no n. The dummy x(:) is assumed-shape — it takes its shape from the actual argument at each call. Inside the body, intrinsics like size(x), minval(x), and whole-array assignment all operate on whatever was passed. The zero-range guard matters: a constant input would otherwise divide by zero, and silently producing NaNs is worse than picking a documented convention (here, mapping everything to zero).
The caller only needs use access:
program demo
use iso_fortran_env, only: real64
use normalize_mod, only: normalize
implicit none
real(real64) :: data(5) = [3.0_real64, 1.0_real64, 4.0_real64, &
1.0_real64, 5.0_real64]
call normalize(data) ! whole array
call normalize(data(1:5:2)) ! strided section also works
end program demoWhat the explicit interface actually buys you
Because normalize lives in a module, any program unit that uses the module sees its explicit interface: the compiler knows the number, type, kind, rank, and intent of every dummy argument. Concretely, that means:
- Passing a
real(real32)array, a scalar, or a rank-2 array is a compile-time error, not a runtime surprise. - Passing something
intent(in)-only whereintent(inout)is required is caught immediately. - Assumed-shape, allocatable, and pointer dummies — which require explicit interfaces by the standard — just work, with no hidden interface tricks.
You can verify the checking is real: compile with strict flags (for example gfortran -Wall -fcheck=all or the equivalent for your compiler), then deliberately change the call to pass a scalar and watch the compiler reject it. That thirty-second experiment tells you more about your build's safety net than any style guide.
The trade-off: contiguity and performance
Assumed-shape is the right default, but it isn't free of caveats. The honest list:
- Non-contiguous sections.
data(1:5:2)is legal and correct, but a compiler may pass it via a descriptor or make a temporary copy. For a hot inner-loop kernel, that overhead can matter. Measure with your compiler and flags before assuming; if it hurts, require contiguous arguments (thecontiguousattribute, Fortran 2008) or restructure the call. - It doesn't catch everything. Kind mismatches, wrong rank, and missing
useassociation are still on you — explicit interfaces only help where the compiler can see them. External procedures outside modules get none of this protection. - Numerical policy. The
minval/maxvalnormalization shown here is a teaching example, not a robust scaler. NaNs propagate, infinities produce garbage, and the degenerate-range convention is a choice you must document, not a truth the language provides. - C interoperability. If the routine must be callable from C via
bind(c), assumed-shape dummies need the Fortran 2018 C descriptor mechanism, which is a bigger commitment — many projects keep C-facing wrappers explicit-shape for simplicity.
A house rule worth adopting
The actionable version of all this fits in four lines: new Fortran procedures live in modules; every dummy argument declares an intent; arrays are assumed-shape by default; and any exception — contiguity requirements, C interop, explicit bounds for a measured performance reason — gets a comment explaining why. Then turn on your compiler's interface and bounds checking in CI, and deliberately break one call once to prove the net holds. Legacy-style (x, n) signatures made sense when the language offered nothing better. It has offered something better since Fortran 90; the only cost is the habit.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.