nativegate
← Blog

How to Modernize Legacy Fortran Without Rewriting It

If you have a validated Fortran codebase — reservoir model, thermal solver, structural codes, anything built between 1980 and 2010 — the instinct at some point is "rewrite it in Python." That instinct is usually expensive and occasionally dangerous. Rewritten code has to re-earn validation from scratch, and the people who can check the answers are often the people whose discipline knowledge outlived every framework since.

The alternative: don't rewrite the numerics. Replace the interface around them. This post describes that path concretely.

What "modernization" actually means here

A legacy Fortran program usually has two parts that age differently:

  • The numerical core — solvers, correlations, property routines. Old, tested, correct. It ages badly in interface (fixed-format, COMMON blocks, deck-style I/O) but not in substance.
  • The shell — the main program, the file formats, the batch scripts, the console output. This is what people actually hate about it, and this is the part worth replacing.

Modernization, in this framing, means: keep the first part byte-compatible, expose the second part's forgotten routines through a modern interface — Python functions first, then an HTTP API and a container if you need them.

The path, step by step

1. Discover what you actually have

Before binding anything, know what exists. In nativegate that is ngate detect — point it at a directory of Fortran sources and get an inventory of modules, subroutines, and the COMMON-block state they share, rather than reading fixed-form decks by eye. On our own legacy example — a black-oil PVT keywarn of seven fixed-form F77 decks from the 1988–2004 era wrapped in an F90 facade — the deck headers lie about what's actually in there; parsing beats squinting.

2. Record the baseline before you touch anything

This is the step most modernization projects skip, and it's the one that makes the rest defensible. Run the known configurations, capture the outputs, and commit them. In nativegate terms:

ngate golden record
$ ngate golden record petro_api # -> services/petro_api/golden.json

Every future change — regenerated bindings, new compiler, new flags — gets measured against this file with ngate golden verify. If a float moved, you hear about it before your users do.

3. Expose a chosen surface, not the whole file

A 4,000-line deck over-scanned by a binding generator produces an SDK nobody asked for. Pick the routines an external caller would actually need — ten is usually enough — and bind exactly those. The rest of the code stays compiled-in and reachable through the ones you exposed. Projection, not total exposure.

4. Generate, build, verify

From one nativegate.yaml, nativegate generates the f2py bindings, the CMake build, an installable wheel, a pytest suite, and a FastAPI service. The acceptance run is then:

verify the answers did not move
$ ngate build petro_api && ngate golden verify petro_api4 entry point(s) unchanged (0 not covered).

That line — numbers unchanged, coverage stated — is the deliverable. It is what turns "we modernized the code" from a claim into a repeatable check.

5. Package and deploy at your own pace

The wheel is an ordinary Python package; nothing at runtime depends on the tool that generated it. If a scripted pipeline is enough, stop there. If people outside your team need the model, the same service runs under Docker (ngate docker) and lands in Kubernetes manifests (ngate k8s) with health probes and a non-root user already set up. You can stop at any rung of this ladder without discarding the rung below it.

What this approach deliberately does not promise

It does not convert your Fortran into readable Python. The numerics stay Fortran. The deliverable is a modern interface over the same compiled code — which is precisely why the numerical regression check is meaningful. If the generators were also rewriting the math, "the answers did not change" would stop being a useful claim.

It also does not make the service multi-tenant-safe. Fortran one call at a time per process — the generated service holds a lock across each native call, because COMMON blocks are process-global storage, so throughput comes from more processes, not more concurrent requests in one of them.

Where to start on your own code

The smallest honest first day of work: run a discover pass, record a golden baseline on one configuration, bind a single routine, and get ngate golden verify to print "unchanged" on it. If that works on your deck, the rest is the same pattern scaled up. You don't have to decide between "living on a batch script" and "a full rewrite" — those aren't the only two states your code can be in.

Try it on your codebase → Also see: F2PY vs nativegate