Should you rewrite Fortran in Rust or wrap it?
Decide when to rewrite Fortran in Rust, when to wrap a proven kernel, and how to compare parity, maintenance risk, and hardware cost.

A Fortran kernel that has produced trusted answers for twenty years does not become bad code because the application around it has become awkward. If the kernel has a narrow boundary, repeatable builds, and an owner who can still diagnose it, wrapping it behind a modern interface is usually the cheaper and safer decision. The source language alone is not a reason to rewrite working mathematics.
The decision changes when the old kernel controls deployment, blocks hardware choices, hides mutable state, or demands skills the organization can no longer supply. Then preservation has a recurring price. A Rust port can cost less than another cycle of special compilers, obsolete hosts, manual releases, and incidents that only one retired engineer understands. The hard part is comparing those costs without treating age as a defect or past correctness as proof of future operability.
Separate the value of the algorithm from the cost of its container
A proven algorithm and its Fortran implementation are related assets, but they are not the same asset. The equations, coefficients, convergence rules, and accepted edge behavior may deserve preservation even when the build system and runtime assumptions do not.
Teams often call a kernel "proven" when they mean that production has depended on it for years. That history matters, but it answers only one question: has the complete system produced acceptable results for the inputs it actually received? It does not establish that the code is portable, free of undefined behavior, safe under concurrency, or understandable after its current maintainer leaves. Long service can even hide dependencies because nobody has rebuilt the kernel on a clean machine recently.
Write down what makes the kernel worth keeping before choosing a treatment. The useful inventory is concrete:
- The mathematical model and which version of it the business accepts
- Input ranges seen in production, including invalid and degenerate cases
- Required precision, rounding behavior, and convergence tolerances
- Runtime and memory limits that affect a real batch or request
- Compiler flags, linked libraries, data files, and initialization order
That inventory exposes an important distinction. Numerical parity means the new execution stays within an agreed tolerance. Behavioral parity also covers errors, warnings, iteration counts, output ordering, NaN handling, timeouts, and side effects. A port can meet the first definition and break the application under the second. A wrapper can preserve both, but only if its boundary does not quietly change representation or lifecycle.
The age of the source belongs near the bottom of the decision record. Put the observable obligations at the top. If nobody can state them, neither wrapping nor rewriting is ready. You first need to recover the contract from code and production evidence.
A narrow and stable boundary favors wrapping
Wrap the kernel when callers can describe it as a small set of deterministic operations with ordinary numeric inputs and outputs. A good candidate looks more like a library than an application: initialize immutable tables, pass arrays and scalar options, compute, return results and status.
Count the boundary rather than the lines of Fortran. A 300,000-line solver exposed through six stable operations can be easier to contain than a 6,000-line routine that reads global files, mutates COMMON blocks, writes reports, and calls back into a user interface. The second kernel has a large behavioral surface even though its source is smaller.
A wrapper is attractive when all of these conditions hold:
- A supported compiler can reproduce the binary on the intended hosts
- The kernel has tests or recorded cases with trusted outputs
- Calls do not depend on hidden process state, or that state can be isolated
- Data conversion costs remain small beside computation time
- Security fixes and diagnostics can reach the boundary without editing the mathematics
The wrapper should own validation, memory limits, version reporting, telemetry, and translation between application types and Fortran types. It should not pretend to repair numerical behavior. Keeping that line clear lets application engineers improve operations without casually changing the result contract.
There is also a useful organizational test: can a new engineer rebuild the library, run its cases, and locate a failed call without asking the original author? If yes, keeping the kernel is a controlled dependency. If no, the wrapper may only conceal an orphaned program. Documentation alone does not fix that; the build and diagnosis path must work on an empty machine.
Use the C ABI as a small, boring seam
Fortran and Rust can share a dependable boundary through the C application binary interface, provided the Fortran side uses ISO_C_BINDING instead of compiler-specific symbol conventions. The seam should expose fixed-width numeric types, explicit array lengths, flat buffers, and integer status codes.
This Fortran entry point makes the layout visible:
module kernel_api
use, intrinsic :: iso_c_binding
implicit none
contains
subroutine evaluate(n, x, scale, y, status) bind(C, name="kernel_evaluate")
integer(c_int), value :: n
real(c_double), intent(in) :: x(n)
real(c_double), value :: scale
real(c_double), intent(out) :: y(n)
integer(c_int), intent(out) :: status
if (n < 1 .or. scale <= 0.0_c_double) then
status = 1_c_int
return
end if
call legacy_evaluate(n, x, scale, y)
status = 0_c_int
end subroutine evaluate
end module kernel_api
The Rust side keeps the unsafe operation in one small module and presents a checked slice API to the rest of the application:
unsafe extern "C" {
fn kernel_evaluate(
n: i32,
x: *const f64,
scale: f64,
y: *mut f64,
status: *mut i32,
);
}
pub fn evaluate(x: &[f64], scale: f64) -> Result<Vec<f64>, KernelError> {
let n = i32::try_from(x.len()).map_err(|_| KernelError::InputTooLarge)?;
if x.is_empty() || !scale.is_finite() || scale <= 0.0 {
return Err(KernelError::InvalidInput);
}
let mut y = vec![0.0_f64; x.len()];
let mut status = 0_i32;
unsafe {
kernel_evaluate(n, x.as_ptr(), scale, y.as_mut_ptr(), &mut status);
}
match status {
0 => Ok(y),
code => Err(KernelError::Fortran(code)),
}
}
This is intentionally unimpressive code. That is a virtue at a language boundary. The Rust Nomicon describes foreign function calls as unsafe because the compiler cannot verify the other language's contract. Keep the unsafe block small enough to audit and make every precondition visible before the call.
The GNU Fortran interoperability manual explains how interoperable procedures and types map through BIND(C) and ISO_C_BINDING. Follow that mechanism rather than relying on a compiler's usual name mangling. An exported symbol discovered with a binary inspection tool is evidence of today's build, not a stable interface for tomorrow's compiler.
Do not send Rust structs, Fortran derived types, allocatable arrays, or language-native strings across the first version of this seam. Flatten them. For matrices, document dimensions, leading dimension, and storage order. For text, pass a byte buffer with an explicit length and encoding rule. Boring representations prevent expensive ambiguity.
Hidden state is where wrappers fail
A wrapper fails when it makes a stateful program look like a pure function without controlling the state. SAVE variables, COMMON blocks, cached work arrays, environment variables, unit numbers, working-directory files, and floating-point mode can all change the outcome of an apparently identical call.
Concurrency usually reveals the lie first. Two web requests enter the wrapper at once, both update the same saved workspace, and one response contains values derived from the other's inputs. A mutex may restore correctness, but it also serializes throughput. Process isolation can preserve parallelism at the price of memory and startup overhead. Neither option is automatically wrong; both must appear in the capacity model.
Initialization is another common break. The original executable may read coefficients, set a random seed, or call a setup routine before the numeric path. A library extraction that exports only the final subroutine can return plausible numbers from uninitialized or default state. Those numbers are more dangerous than a crash because monitoring may accept them.
Map the complete call lifecycle:
- Start a fresh process and record every file, environment value, and library loaded.
- Trace initialization until the first numeric operation can run.
- Call the same case twice and compare all outputs and status values.
- Interleave two different cases, then repeat them in separate processes.
- Force invalid input, allocation failure where practical, and nonconvergence.
This sequence tells you whether the kernel can live inside a multithreaded service, needs a single worker queue, or belongs in isolated worker processes. It also identifies error paths that STOP the process, write to standard output, or leave partial results behind. A wrapper cannot translate an error after the Fortran runtime has terminated its host.
Array layout deserves its own check. Fortran stores arrays in column-major order; Rust libraries often assume row-major order. A copied matrix can look dimensionally correct while representing its transpose or a scrambled stride. Test a nonsquare matrix with unique values, because square or symmetric cases conceal the mistake. If conversion copies dominate the call time, change the boundary to accept the kernel's native layout rather than paying for two full memory passes.
Prove parity with production-shaped evidence
Parity requires an executable comparison, not a code review and not a handful of golden outputs. Run the old and candidate paths against the same recorded inputs, capture their complete observable results, and classify every difference.
Start with production traffic or batch records after removing data that the test environment should not hold. Recorded evidence carries combinations that synthetic tests miss: empty groups beside large groups, odd ordering, stale flags, values close to a threshold, and retries after partial work. Add constructed cases for bounds and numerical stress, but do not let them replace actual operating shapes.
A useful harness emits a record like this for every call:
{
"case_id": "settlement-004812",
"operation": "evaluate",
"input_digest": "sha256:...",
"old": {"status": 0, "iterations": 7, "output": [1.25, 3.5]},
"candidate": {"status": 0, "iterations": 7, "output": [1.25, 3.5]},
"comparison": {"max_abs": 0.0, "max_rel": 0.0, "accepted": true}
}
Do not choose one global epsilon because it is convenient. Absolute tolerance works near zero; relative tolerance works as magnitudes grow; some outputs require units-based limits; exact integers and status values need exact equality. NaNs need an explicit policy because ordinary equality treats them differently from finite values. Signed zero can matter when later operations inspect the sign.
Compare failures with the same care as successful calculations. If the old path reports nonconvergence after 40 iterations and the new path returns the last estimate as success, the arrays may look close while the contract has changed. If output ordering is unspecified in source but downstream code has depended on it for years, production behavior has made that order part of the migration obligation until callers change.
Keep the harness after release. It becomes the guardrail for compiler upgrades, optimization flag changes, library replacements, and later Rust work. CodeHero uses this kind of parity harness against recorded production traffic while modernizing the architecture, because a passing unit suite alone cannot prove that a legacy system still behaves the same at its edges.
Rewrite when ownership costs recur
Port the kernel when keeping it creates a repeating operational tax that a wrapper cannot remove. The strongest triggers concern ownership and deployment, not taste in languages.
A port deserves serious consideration when the approved Fortran compiler does not support the target operating environment, the build relies on abandoned libraries, or each release requires a host that the organization is already trying to retire. The same applies when production diagnosis stops at an opaque binary and failures cannot be tied to inputs, phases, or resource use.
Staffing matters, but "we do not hire Fortran developers" is too weak by itself. A wrapped stable kernel may need little language work. Measure actual demand: how often does the algorithm change, how many incidents require source diagnosis, who reviews compiler upgrades, and what happens when the current owner is unavailable? Porting merely to match the majority language can spend a large budget without lowering those burdens.
Change frequency makes the case stronger. If product work regularly adds model terms, changes convergence rules, or needs the kernel to participate in request cancellation and structured error handling, every change crosses the seam. The wrapper grows policy that belongs inside the computation. Rust then offers direct ownership of memory, explicit result types, controlled parallelism, and tooling that more of the application team can operate.
There is a separate security and isolation argument. If the kernel consumes untrusted files, performs unchecked indexing, or runs in the same process as an exposed service, containment may be mandatory even before a port. Run it in a restricted worker process with input limits while the rewrite proceeds. Rust reduces many memory errors in rewritten code, but it does not prove the mathematics, prevent resource exhaustion, or make unsafe foreign libraries safe.
A useful decision record converts recurring pain into annual engineering effort and infrastructure cost. Include compiler licensing, special build hosts, release labor, incident dependence, idle capacity caused by serialization, and blocked platform changes. Do not invent a monetary value for "modernity." Put a value on work the organization actually performs.
Compiler settings belong in the behavior contract
A compiler command is part of the kernel's effective source. Optimization flags, floating-point options, target architecture, linked math libraries, and even link order can change numerical results or expose assumptions that an older build happened to tolerate.
Recover the exact build before evaluating either a wrapper or a port. Save the compiler identity and version, every compile and link flag, preprocessor definitions, library versions, and environment variables used by the build. Capture the commands from the build tool rather than copying a comment from an old operations document. Then rebuild in a clean environment and compare the reference cases. If a clean rebuild already differs, the team has a reproducibility problem before migration begins.
Aggressive floating-point optimization needs particular care. A compiler may reassociate operations, contract a multiply and add, treat NaNs as impossible, or assume that signed zero has no meaning when given permissive math flags. Those transformations can improve speed while changing convergence at a threshold. The correct question is not whether an option follows a strict standard in the abstract. Ask whether the production result contract permits the change, and prove the answer with the harness.
External numeric libraries also belong in the record. A call named DGEMM may retain the same interface while a different BLAS implementation changes reduction order, threading, CPU dispatch, and performance. That is usually acceptable within a sound tolerance, but it can alter borderline solver behavior or oversubscribe a service whose outer layer also creates threads. Record the library implementation and its thread settings with each benchmark.
Create a build manifest beside every candidate artifact. It can be simple:
kernel_version=2026.08
compiler=gfortran
compiler_version=<captured from build>
compile_flags=<captured from build>
blas_implementation=<name and version>
target_cpu=<declared target>
source_digest=<repository revision>
test_corpus=<immutable corpus revision>
The angle brackets are fields to fill, not permission to leave the data unknown. Have the build produce this manifest automatically and expose it through a version operation or startup log. When an output changes after deployment, operators can tell whether code, compiler, library, or input corpus moved.
For wrapping, this discipline turns the Fortran binary into a governed component instead of an unexplained file copied between servers. For porting, it gives Rust engineers the actual behavior they must match. Rust builds need the same treatment: lock dependency versions, record the compiler toolchain, declare target CPU features, and keep performance flags separate from parity flags until their effects are measured.
Avoid making bit-for-bit equality the universal goal. It may be required for a few regulated records or binary checkpoints, but it can freeze a compiler and hardware path indefinitely. Most numeric systems need scientifically or financially meaningful tolerances plus exact agreement on discrete decisions. Document which outputs fall into each category. A Boolean eligibility result cannot drift by an epsilon, even if it comes from floating-point work.
One final trap is comparing an old conservative build against a candidate tuned with every available optimization and then attributing the result to language. Run a matrix that separates implementation from compiler settings: reference Fortran settings, tuned Fortran settings, conservative Rust settings, and tuned Rust settings. That comparison tells you whether the proposed savings come from the port, from a compiler update, or from allowing a different numerical contract.
Hardware economics can overturn a correct wrapper
A wrapped kernel can preserve results perfectly and still become the expensive option when its execution model prevents the organization from using available hardware efficiently. Measure the complete workload on the hardware you intend to operate, including data conversion, process boundaries, memory movement, and queueing.
Do not assume Rust will be faster. Mature Fortran compilers generate excellent numeric code, and established kernels may already call tuned BLAS or LAPACK routines. A literal port can lose vectorization, allocate more often, or replace a tuned library call with an ordinary loop. Language choice does not erase memory bandwidth or algorithmic complexity.
The economic pressure usually appears elsewhere. A kernel may be compiled only for an old architecture, depend on a vendor runtime that constrains deployment, or serialize work through global state. It may copy large arrays several times to fit a new service boundary. Cloud instances then sit underused while requests wait behind one compute lane. Those are measurable system costs, and they can justify a port even if a single isolated Fortran call remains fast.
Benchmark representative distributions, not one friendly case. Record at least throughput, tail latency, peak resident memory, bytes copied at the boundary, and time spent waiting for the serialized region. Run warm and cold cases when initialization is material. Pin compiler versions and flags in the result so another engineer can reproduce it.
Then test the cheapest alternatives to a rewrite. Removing an unnecessary transpose, pooling worker processes, updating the compiler, or replacing a file interface with an in-memory buffer may recover enough capacity. If it does, the wrapper has earned another term. If the kernel still blocks the selected architecture or accelerator path, the port has a specific performance obligation rather than a vague promise to be faster.
A Rust port should preserve behavior, not Fortran syntax
A successful port translates the computational model and then gives it a design that Rust engineers can own. Transliteration preserves old control flow, global arrays, sentinel values, and indexing habits while adding borrow-checker battles around them. The result is harder to review than either original.
Freeze the reference build before editing. Give it reproducible compiler flags, immutable test data, and a machine-readable invocation. The reference does not need to be pretty; it needs to remain available until the replacement has survived production comparison.
Split the port along mathematical boundaries that the parity harness can observe. Good units include preprocessing, coefficient selection, one solver iteration, convergence evaluation, and result formatting. Port one unit, call it from the existing path if practical, and compare intermediate values. This narrows a discrepancy to one stage instead of asking engineers to inspect an entire solver.
Make numeric choices explicit in Rust. State whether values are f32 or f64, how integer conversion handles overflow, which reduction order is acceptable, and whether fused multiply-add may change rounding. Preserve the accepted algorithm first. Improve it only in a separately reviewed change with its own evidence, because combining language migration and model revision destroys the clean reference.
Model failures as typed results rather than magic values, but retain the old outward behavior until callers deliberately adopt the new contract. If the Fortran path emits a warning and a usable estimate, the first Rust release should not silently convert that case into a fatal error. Better APIs are useful only when the surrounding system changes with them.
Keep unsafe Rust at foreign boundaries and audited library calls. Pure Rust can still panic on indexing, allocate without a practical limit, or produce NaNs from valid floating-point operations. Set limits and return failures on the service boundary. Memory safety solves a class of faults, not the operating contract.
Make the decision reversible until evidence closes it
The safest program delays the irreversible choice while improving both options. First create a reproducible Fortran build and a parity harness. Next place the existing kernel behind the interface you would want even if the implementation changed. That work is required for a sound wrapper and remains useful for a port.
Run the wrapped version under representative load. You now have evidence about conversion cost, concurrency, failure isolation, and operational ownership. If it meets the service objective and the team can maintain its build, stop. Keeping good Fortran is an engineering decision, not a failure of ambition.
If it misses, the same interface becomes the boundary for the Rust replacement, and the same recorded cases judge each migrated unit. Deploy with shadow comparison where the environment permits it: execute the candidate without letting it control the response, record bounded differences, and inspect cases outside tolerance. Protect sensitive inputs and cap the extra compute load; shadow execution is a test technique, not permission to duplicate data carelessly.
Set exit criteria before the port gathers momentum. Criteria should cover accepted parity, performance on the intended hosts, failure behavior, operational diagnostics, and removal of the old runtime dependency. Also define the rollback window and the evidence needed to retire the reference. Without those terms, teams keep two implementations indefinitely and pay both ownership costs.
The decision can now be stated without ideology. Wrap when the computation is stable, the boundary is narrow, and the toolchain remains operable. Rewrite when repeated ownership or hardware constraints cost more than a measured port, and when the organization can prove the new implementation against the old one. If you cannot yet prove either claim, spend the next engineering day on the harness, not on translating a loop.
FAQ
Is old Fortran code automatically unsafe?
No. Age and language do not establish whether a kernel is safe or correct. Inspect its input handling, memory behavior, mutable state, compiler assumptions, and deployment boundary before making that judgment.
Can Rust call a Fortran library directly?
Yes, through a compatible C ABI exposed with Fortran's ISO_C_BINDING and BIND(C). Keep the foreign call in a small unsafe Rust module and pass simple numeric buffers, lengths, and status codes.
Will rewriting Fortran in Rust make it faster?
Not necessarily. Mature Fortran code may already vectorize well or call tuned numeric libraries. Benchmark the full workload, because copies, serialization, memory use, and deployment constraints often matter more than loop syntax.
How do we compare floating-point results after a port?
Use output-specific absolute, relative, or units-based tolerances and define policies for NaN, infinities, and signed zero. Compare statuses and convergence behavior too; close arrays do not prove equal behavior.
Should we wrap Fortran in the same process as a web service?
Only if you have verified its state, error handling, and concurrency behavior. A kernel that calls STOP, mutates saved work arrays, or reads process-global files often belongs in an isolated worker process.
What is the biggest risk in a Fortran wrapper?
Hidden state is usually the most expensive surprise. A clean function signature can conceal initialization order, COMMON blocks, files, random seeds, and non-thread-safe workspaces.
How much of the kernel should we port at once?
Port the smallest mathematical unit that the parity harness can observe. Comparing intermediate stages makes numerical drift much easier to locate than replacing the full solver in one release.
Do we need a Fortran specialist after wrapping the kernel?
You need someone who can rebuild, diagnose, and review changes, but that may not require a full-time specialist. If only one unavailable person can perform those tasks, the dependency is not under control.
When does compiler cost justify a Rust rewrite?
When licensing, restricted build hosts, release labor, or blocked target platforms create a recurring cost larger than the measured port and its validation. Put real invoices and engineering hours in the comparison.
Can we improve the algorithm during the Rust migration?
Do it as a separate change after behavioral parity. Mixing a language port with a model revision removes the trusted reference and makes every discrepancy harder to explain.