PONYλM2Modula-2

Fortran.CodeCompared.To/Python

An interactive executable cheatsheet comparing Fortran and Python

Fortran 2018 (GCC 16.2) Python 3.14
Hello World & Building
Hello, World
The program block, the implicit none and the end program all disappear. A Python file is executed top to bottom, so there is no program unit to declare.
program hello implicit none print *, "Hello, World!" end program hello
print("Hello, World!")
🚨 implicit none is worth a moment, because its absence is the closest thing Python has to a Fortran hazard. It is the line that stops Fortran inventing a variable from a typo; Python has no such line and will create a variable on first assignment — so totl = 0 followed by total += 1 raises NameError only when the second line runs, and a misspelling on the left-hand side creates a second variable silently. A linter is the substitute.
Nothing is checked before it runs
Both print 15. The difference is the branch that never runs — Fortran refuses to produce a program with a misspelled name in it, and Python never looks.
program checked implicit none integer :: total, index total = 0 do index = 1, 5 total = total + index end do ! A misspelled name is a compile error, even in a branch never taken. if (total > 1000) then print *, "large" end if print *, "total:", total end program checked
total = 0 for index in range(1, 6): total += index if total > 1000: print(mispelled_name) # never evaluated, never complained about print("total:", total) # Nothing looked at that name. It fails only if the branch runs, which # for a threshold like this may be the first time real data arrives.
For scientific code this matters in a specific way: a rarely-taken branch is often the error path, so the bug surfaces during the run that already went wrong. The replacements are real and opt-in — ruff catches undefined names in seconds, mypy or pyright catch type mismatches, and both belong in the project from the first day. What Python gives back is the other half of the loop: no compile step at all, so an idea and a plot are thirty seconds apart.
Arrays Are Still The Point
🚨 Whole-array arithmetic needs NumPy, not Python
🚨 The last line is the trap. a * 2 on a Python list repeats the list and a + b concatenates — both perfectly legal, neither what you meant, and no error to tell you.
program arrays implicit none real :: a(4), b(4), c(4) a = [1.0, 2.0, 3.0, 4.0] b = [10.0, 20.0, 30.0, 40.0] ! Whole-array arithmetic, in the language since Fortran 90. c = a * 2.0 + b print *, c print *, sum(c), maxval(c) end program arrays
# A Python LIST has none of this. There is no elementwise arithmetic, # and this is what a naive port looks like: a = [1.0, 2.0, 3.0, 4.0] b = [10.0, 20.0, 30.0, 40.0] c = [a[index] * 2.0 + b[index] for index in range(len(a))] print(c) print(sum(c), max(c)) # a * 2 on a list REPEATS it, and a + b CONCATENATES. Both are legal, # neither is arithmetic, and this catches every Fortran programmer once: print(a * 2)
A Python list is not an array: it is a growable sequence of pointers to arbitrary objects, so it cannot do elementwise arithmetic and would be slow if it did. NumPy is the actual counterpart to a Fortran array — a typed, fixed-shape, contiguous block with whole-array operators — and the next row shows it. Do not port Fortran array code to Python lists; the result will be correct, unrecognizable and roughly a hundred times slower.
NumPy is the array you were looking for
This is the row that should decide how you port. Every construct lines up: elementwise operators, sum and maxval as reductions, and WHERE as a boolean mask.
program arrays implicit none real :: a(4), b(4), c(4) a = [1.0, 2.0, 3.0, 4.0] b = [10.0, 20.0, 30.0, 40.0] c = a * 2.0 + b ! one expression, no loop where (c > 40.0) c = 0.0 ! masked assignment print *, c print *, sum(c), maxval(c) end program arrays
import numpy a = numpy.array([1.0, 2.0, 3.0, 4.0]) b = numpy.array([10.0, 20.0, 30.0, 40.0]) c = a * 2.0 + b # one expression, no loop c[c > 40.0] = 0.0 # boolean mask assignment — Fortran's WHERE print(c) print(c.sum(), c.max()) # The operators, the reductions and the masking all correspond. What # runs underneath is compiled C — and for the linear algebra, LAPACK # and BLAS, which are themselves Fortran.
The correspondence is not a coincidence — NumPy was designed by people who wanted Fortran's array model in Python, and numpy.linalg calls LAPACK, which is Fortran. So the honest framing of this whole page is that moving to Python means moving to NumPy, and a Fortran programmer already understands NumPy's data model better than most Python programmers do. Two vocabulary notes: Fortran's reshape, transpose, matmul, dot_product and pack are reshape, .T, @, dot and boolean indexing.
Broadcasting is the rule Fortran does not have
Fortran requires shapes to conform and makes you say spread or write the loop. NumPy stretches the smaller operand automatically, which is convenient and is the one place its model is less strict than Fortran's.
program conformance implicit none real :: matrix(2, 3), row(3), result(2, 3) integer :: index matrix = reshape([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]) row = [10.0, 20.0, 30.0] ! Shapes must CONFORM. Adding a rank-1 to a rank-2 is an error, so ! the intent is written out with spread (or a loop): do index = 1, 2 result(index, :) = matrix(index, :) + row end do print *, result(1, :) print *, result(2, :) end program conformance
import numpy matrix = numpy.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) row = numpy.array([10.0, 20.0, 30.0]) # Broadcasting: the shapes are made to conform automatically by # stretching size-1 (or missing) dimensions. result = matrix + row print(result[0]) print(result[1]) # 🚨 Which means a shape mistake often does not error — it produces a # larger array than you meant, silently.
🚨 That looseness is worth being wary of. Adding a shape (3,) to a shape (4, 1) in NumPy produces a (4, 3) array rather than an error, so a transposition mistake can quietly produce a bigger result instead of failing. The defenses are to assert shapes at function boundaries (assert values.shape == (rows, columns)) and to keep dimensions named in the code. Fortran's strictness caught these at compile time when the shapes were known statically.
Indexing & Layout
🚨 Indexing starts at zero, and slices are half-open
🚨 Two changes at once, and the second catches people more than the first: indexing starts at zero, and a slice excludes its upper bound, so values(2:4) becomes values[1:4].
program indexing implicit none integer :: values(5) values = [10, 20, 30, 40, 50] print *, values(1) ! the FIRST element print *, values(5) ! the last print *, values(2:4) ! INCLUSIVE on both ends — three elements ! And the lower bound is yours to choose: block integer :: shifted(0:4) shifted = values print *, shifted(0) end block end program indexing
values = [10, 20, 30, 40, 50] print(values[0]) # the first element print(values[-1]) # the last — negative counts from the end print(values[1:4]) # HALF-OPEN: start is included, stop is not # There is no way to choose the lower bound. It is always zero.
The half-open convention is consistent throughout Python and worth internalizing rather than fighting: len(values[a:b]) is b - a, values[:n] and values[n:] partition without overlap, and range(n) gives exactly the valid indices. The loss that stings for scientific code is the arbitrary lower bound — Fortran's dimension(0:n) or dimension(-1:1) lets an array's indices match the physics, and neither lists nor NumPy offer it. Negative indices count from the end instead, which is a different and useful thing.
🚨 Row-major, and why the loop order flips
🚨 The memory layout is transposed, so the cache-friendly loop nesting is transposed with it. Getting this wrong does not change the answer — it changes the speed, sometimes by a large factor.
program layout implicit none real :: grid(3, 3) integer :: row, column grid = 0.0 ! Fortran is COLUMN-major: the FIRST index varies fastest in memory, ! so the inner loop should walk rows for cache locality. do column = 1, 3 do row = 1, 3 grid(row, column) = real(row * 10 + column) end do end do print *, grid(1, :) print *, grid(:, 1) end program layout
# NumPy is ROW-major by default: the LAST index varies fastest, so the # loop nesting that is cache-friendly is the other way round. # # for row in range(3): # outer # for column in range(3): # inner — walks contiguous memory # # numpy.zeros((3, 3), order="F") asks for Fortran layout, which is what # scipy.linalg wants anyway since it calls LAPACK. grid = [[0.0] * 3 for _ in range(3)] for row in range(3): for column in range(3): grid[row][column] = float((row + 1) * 10 + (column + 1)) print(grid[0]) print([grid[row][0] for row in range(3)])
Three practical consequences. A ported loop nest should have its order flipped, or the array should be created with order="F". An array passed to scipy.linalg is copied into Fortran order if it is not already in it, so order="F" can remove a copy. And f2py handles the transposition at the boundary for you, which is one more reason the next-to-last section argues for wrapping rather than rewriting.
Types & Precision
Declarations go away, and so does the checking
No declarations, no kind, no fixed-length strings. A Python float is always a double, which removes the single-versus-double question that a Fortran programmer manages deliberately.
program declarations implicit none integer :: count real(kind=8) :: ratio ! double precision, explicitly character(len=20) :: label logical :: enabled count = 42 ratio = 0.5d0 label = "widget" enabled = .true. print *, count, ratio, trim(label), enabled end program declarations
count = 42 # an int, of unlimited size ratio = 0.5 # a float — always IEEE 754 double, never single label = "widget" # a str, of any length, immutable enabled = True print(count, ratio, label, enabled) # The annotations exist and are checked by nothing at run time: def scale(factor: float, values: list[float]) -> list[float]: return [value * factor for value in values] print(scale(2.0, [1.0, 2.0]))
Two differences worth knowing for numeric work. A Python int is arbitrary precision, so integer overflow does not exist — a factorial or a large product is exact rather than wrapping, at the cost of speed. And there is no single precision at all in plain Python, so halving your memory footprint means NumPy's dtype=numpy.float32, which is where the kind parameter reappears. The type annotations shown are documentation the interpreter ignores; mypy is what reads them.
Strings stop having a length
A Python string has no declared length and is never blank-padded, so the trim that appears before nearly every use of a Fortran character variable simply disappears.
program strings implicit none character(len=20) :: name character(len=40) :: greeting name = "widget" ! padded to 20 with blanks greeting = "hello, " // trim(name) ! // concatenates print *, trim(greeting) print *, len_trim(name), len(name) end program strings
name = "widget" greeting = "hello, " + name # + concatenates formatted = f"hello, {name}" # or interpolate print(greeting) print(formatted) print(len(name)) # No fixed length, no blank padding, and therefore no trim() before # every use. A string is exactly the characters it holds.
That is one of the larger quality-of-life gains in the move, and it comes with a real capability: Python strings are Unicode throughout, so accented characters and other alphabets need no special handling. The f-string is the idiom to adopt — f"value: {x:.3f}" covers most of what a format statement did, with the same kind of field specification after the colon. Strings are immutable, so building one in a loop should use a list and "".join(parts).
Control Flow
do becomes for, and the bounds change
do index = 1, 5 becomes range(1, 6) — the upper bound moves by one, because Python's ranges exclude their stop.
program loops implicit none integer :: index, total ! Inclusive on both ends, and the step comes last. do index = 1, 5 print *, index end do total = 0 do index = 10, 1, -2 total = total + index end do print *, "total:", total end program loops
# range(start, stop, step) — stop is EXCLUDED. for index in range(1, 6): print(index) total = 0 for index in range(10, 0, -2): total += index print("total:", total) # And the loop you usually want, over the values themselves: for value in [1.5, 2.5]: print(value)
The idiom to adopt is the last loop: Python code iterates the values rather than an index, and reaches for enumerate(values) when it needs both. Two things a Fortran programmer will miss: there is no do concurrent, so nothing tells the implementation that iterations are independent, and there is no loop label to exit or cycle by name — a nested break leaves only the inner loop, and the usual answer is to move the inner loop into a function and return.
select case becomes match
The shapes correspond, with one difference: Fortran's case (2:9) range has no direct match equivalent, so a guard does that job.
program selection implicit none integer :: value integer :: values(4) = [1, 5, 50, 500] integer :: index do index = 1, 4 value = values(index) select case (value) case (1) print *, value, "one" case (2:9) print *, value, "small" case default print *, value, "large" end select end do end program selection
for value in [1, 5, 50, 500]: match value: case 1: print(value, "one") case value_in_range if 2 <= value_in_range <= 9: print(value, "small") case _: print(value, "large")
🚨 One match trap has no Fortran counterpart and bites everyone once: a bare name in a case is a binding, not a comparison, so case limit: matches everything and rebinds limit rather than testing against it. Compare against a literal, a dotted name (case Limits.MAXIMUM:), or use a guard as above. Where the branches are numeric rather than structural, an if/elif chain is clearer and is what most Python code uses.
Subroutines & Functions
One kind of procedure, and no intent
The subroutine-versus-function distinction disappears: everything is def, and a procedure that returns nothing simply returns None. So does intent.
program procedures implicit none real :: values(3) = [1.0, 2.0, 3.0] real :: total call scale_in_place(values, 2.0) total = summed(values) print *, values print *, total contains ! intent(inout) says this one modifies its argument, and the ! compiler enforces it. subroutine scale_in_place(array, factor) real, intent(inout) :: array(:) real, intent(in) :: factor array = array * factor end subroutine scale_in_place function summed(array) result(total) real, intent(in) :: array(:) real :: total total = sum(array) end function summed end program procedures
def scale_in_place(array, factor): # A list argument is passed by reference and mutating it changes # the caller's list. Nothing in the signature says so. for index in range(len(array)): array[index] *= factor def summed(array): return sum(array) values = [1.0, 2.0, 3.0] scale_in_place(values, 2.0) print(values) print(summed(values))
🚨 intent(in), intent(out) and intent(inout) are among the most useful things Fortran gives you, and there is no Python equivalent — a function may modify any mutable argument, and the caller cannot tell without reading the body. The conventions that substitute: return a new value rather than modifying in place, name any function that mutates so it is obvious, and use a tuple or a frozen dataclass for anything that must not change. Note that Python returns multiple values as a tuple, which is more convenient than Fortran's intent(out) arguments.
Optional arguments become defaults
A default value in the signature replaces the optional attribute and the present() check, and keyword arguments let a caller name any parameter in any order.
program optional_arguments implicit none print *, describe("widget") print *, describe("widget", 3) contains function describe(name, count) result(text) character(len=*), intent(in) :: name integer, intent(in), optional :: count character(len=40) :: text integer :: actual actual = 1 if (present(count)) actual = count write (text, '(I0, " x ", A)') actual, trim(name) end function describe end program optional_arguments
def describe(name: str, count: int = 1) -> str: return f"{count} x {name}" print(describe("widget")) print(describe("widget", 3)) print(describe(count=5, name="widget")) # by name, in any order
🚨 One Python trap with no Fortran counterpart, and it is the classic: a default value is evaluated once, when the function is defined. So def collect(values=[]) shares one list across every call that omits the argument, and it accumulates. The rule is to use None as the default and create the mutable value inside the body. Keyword arguments are a genuine gain — a five-parameter call site becomes readable without comments.
Modules & Packaging
module becomes import
A Python module is a file, and importing it makes its names available — which is use, with from module import name playing the part of only:.
module constants implicit none real, parameter :: gravity = 9.80665 contains function weight(mass) result(force) real, intent(in) :: mass real :: force force = mass * gravity end function weight end module constants program use_module use constants, only: weight, gravity implicit none print *, gravity print *, weight(10.0) end program use_module
# constants.py would hold: # # GRAVITY = 9.80665 # # def weight(mass: float) -> float: # return mass * GRAVITY # # and then, in another file: # # from constants import weight, GRAVITY GRAVITY = 9.80665 def weight(mass: float) -> float: return mass * GRAVITY print(GRAVITY) print(weight(10.0))
The differences that matter in practice. A module is loaded once however many files import it, and importing it runs it, so top-level statements are executed at import time — which is why library code puts work inside functions. There is no parameter attribute, so a constant is a convention marked by an upper-case name and can be reassigned by anyone. And there is no separate compilation or .mod file to keep in step, which removes a whole class of build problems.
Packaging is a solved problem here
This is the part of the move a Fortran programmer notices most, and it goes Python's way by a wide margin — for a scientific project the difference is between a day of build archaeology and one command.
! There is no standard package manager. A Fortran project's ! dependencies are handled by: ! ! fpm (the Fortran Package Manager) — real, young, growing ! CMake or Make plus whatever the site administrator installed ! a module system on the cluster (module load netcdf) ! vendoring the source and compiling it yourself ! ! And the .mod files are compiler- and version-specific, so a ! dependency built with a different gfortran may not link. program packaging implicit none print *, "dependency handling is a real cost here" end program packaging
# pip install numpy scipy matplotlib # # A dependency is a wheel — usually precompiled — resolved by a central # index, installed into a virtual environment the interpreter already # searches. Reproducible with a lock file, and it works the same on # every machine. print("dependency handling is a solved problem here")
It is worth knowing why, since it explains what you give up. A Python wheel works everywhere because the interpreter defines the interface; a Fortran .mod file is compiler- and version-specific, so binaries cannot be shared. The counterweight is that the Python numeric stack is itself a wall of compiled dependencies, so a wheel that does not exist for your platform means building NumPy from source — a rarer problem than it was, and a memorable one when it happens. fpm is genuinely improving the Fortran side.
Input & Output
format statements become f-strings
The format specification survives almost intact: F8.3 is 8.3f, I5 is 5d, and ES12.4 is 12.4e — width, then precision, then the conversion.
program formatting implicit none real :: value = 3.14159265 integer :: count = 42 print '(A, F8.3)', "value: ", value print '(A, I5)', "count: ", count print '(A, ES12.4)', "scientific: ", value end program formatting
value = 3.14159265 count = 42 print(f"value: {value:8.3f}") print(f"count: {count:5d}") print(f"scientific: {value:12.4e}")
The f-string is the idiom and it composes better than a format statement, because the thing being formatted is an expression: f"{value * 2:.3f}" and f"{name.upper()}" both work, and there is no chance of the format list drifting out of step with the argument list. What Fortran keeps is repeat counts and complex edit descriptors for tabular output, which in Python means a loop and a width per column — or a library.
Unit numbers become file objects
No unit numbers, no iostat, and no explicit close: the with block closes the file when it ends, including when an error unwinds through it.
program files implicit none integer :: unit_number, status character(len=32) :: line open (newunit=unit_number, file="/tmp/example.txt", status="replace", action="write") write (unit_number, '(A)') "alpha" write (unit_number, '(A)') "beta" close (unit_number) open (newunit=unit_number, file="/tmp/example.txt", status="old", action="read") do read (unit_number, '(A)', iostat=status) line if (status /= 0) exit print *, trim(line) end do close (unit_number) end program files
with open("/tmp/example.txt", "w") as handle: handle.write("alpha\n") handle.write("beta\n") # The file is closed at the end of the block, including if an # exception is raised inside it — so there is no close to forget. with open("/tmp/example.txt") as handle: for line in handle: print(line.rstrip())
Iterating the file object reads it one line at a time, so a file larger than memory needs no special handling — which is the equivalent of the read-until-iostat loop, written as a for. Two vocabulary notes: rstrip() removes the trailing newline that read would have discarded, and a failure raises an exception (FileNotFoundError) rather than setting a status variable you have to check. For numeric data, numpy.loadtxt and numpy.save replace formatted and unformatted reads respectively.
Where The Speed Went
🚨 A Python loop is about a hundred times slower
Both print the same number, and the Python version takes on the order of a hundred times longer. This is the row a Fortran programmer needs before porting anything, not after.
program timing implicit none integer :: index real(kind=8) :: total ! Compiled to a handful of machine instructions per iteration. total = 0.0d0 do index = 1, 1000000 total = total + real(index, kind=8) end do print *, total end program timing
# Every iteration: look up the name, box the integer, dispatch the # operator, allocate the result. That is roughly 50-100x the cost of # the Fortran loop, and it is the single reason a naive port # disappoints. total = 0.0 for index in range(1, 1000001): total += float(index) print(total) # The same work, vectorized — one call into compiled code: # total = numpy.arange(1, 1000001, dtype=numpy.float64).sum()
The rule that follows: a Python loop over numbers is a bug in the design, not a slow implementation. Push the loop into NumPy, where it runs in compiled code, and the gap closes to a small factor. When no array formulation exists, the options are numba (a decorator that compiles the function), Cython, or — best of all if the Fortran already exists — f2py, which is the next section. Do not conclude from this row that Python is unsuitable; conclude that pure-Python numerics are.
What is actually fast in Python
The distinction that does not exist in Fortran and governs everything here: the builtins are compiled C, so sum and max are fast, and only the loop you write yourself is slow.
program builtins implicit none real :: values(5) = [3.0, 1.0, 4.0, 1.0, 5.0] ! Fortran's intrinsics are compiled, and so is everything else ! you write, so there is no fast/slow distinction to learn. print *, sum(values), maxval(values), minval(values) print *, count(values > 2.0) end program builtins
values = [3.0, 1.0, 4.0, 1.0, 5.0] # The builtins are implemented in C, so THESE are fast — the slow # thing is a Python-level loop, not Python. print(sum(values), max(values), min(values)) print(sum(1 for value in values if value > 2.0)) # The habit that follows: reach for a builtin, a comprehension or a # library call before writing a loop, because each of those pushes # the iteration down into compiled code.
This is why idiomatic Python looks the way it does. A comprehension is faster than the equivalent loop because more of the work happens inside the interpreter's C code; sum, sorted, any, all and min/max are all compiled; and a library call is compiled all the way down. The instinct to carry across from Fortran — express the whole operation at once rather than element by element — is exactly the right instinct here, for a different underlying reason.
f2py: Keeping The Fortran
f2py: your Fortran, as a Python function
This is the section that changes what the move means. f2py has shipped inside NumPy since the beginning, and it turns an ordinary Fortran subroutine into an importable Python function with one command.
! fastmath.f90 — ordinary Fortran, with nothing Python-specific in it. ! The only addition is the intent comments, which f2py reads to work ! out which arguments are inputs and which are results. subroutine total_of(values, n, answer) implicit none integer, intent(in) :: n real(kind=8), intent(in) :: values(n) real(kind=8), intent(out) :: answer integer :: index answer = 0.0d0 do index = 1, n answer = answer + values(index) end do end subroutine total_of ! Then one command: ! ! python -m numpy.f2py -c fastmath.f90 -m fastmath
# And it is importable, with the array shapes worked out for you: # # import numpy # import fastmath # # values = numpy.arange(1.0, 1000001.0) # print(fastmath.total_of(values)) # n is inferred from the array # # Note what f2py did: it read intent(in) and intent(out), so the # out argument became the RETURN VALUE and the array length n # disappeared from the signature entirely. print("the Fortran stays Fortran")
What it reads is the intent attributes you were already writing: an intent(out) argument becomes the return value, an array dimension becomes implicit, and the wrapper handles the column-major/row-major transposition at the boundary. So the realistic plan for an existing code is not a rewrite — it is to keep the numerical kernels in Fortran, wrap them, and write the input handling, the parameter sweeps, the plotting and the analysis in Python. That is what a great deal of scientific Python actually is.
What to port, and what to leave alone
The last row, and the practical advice the rest of the page has been building toward: the end state is usually a Fortran core with a Python shell, not a Python program.
! Leave in Fortran: ! - the numerical kernels, especially anything with a loop ! dependency no array expression can capture ! - anything already validated, published against, or regression ! tested — a rewrite means revalidating ! - code that runs on a cluster where the Fortran toolchain is ! what the site supports ! ! The strongest argument for keeping it: a rewrite of working ! numerics buys no new capability and risks a subtle difference in ! results that nobody notices for a year. program keep implicit none print *, "wrap the kernel" end program keep
# Move to Python: # - reading and validating input, and everything about file formats # - parameter sweeps and job orchestration # - plotting, tables and anything a colleague has to look at # - tests, which are enormously easier to write here # - the analysis that used to be a separate script in another # language entirely # # The realistic end state is a Fortran core with a Python skin, not # a Python program. print("write the shell")
The argument for keeping the numerics is stronger than it first looks. A rewrite of working, validated numerical code buys no new capability, and its characteristic failure is not a crash but a small difference in results that nobody notices for a year — floating-point arithmetic is not associative, so even a correct rewrite may not be bit-identical. Meanwhile the things Python is genuinely better at — input handling, orchestration, plotting, testing, sharing — are exactly the parts of a scientific code that are least validated and most painful in Fortran.

Thank you — anything else?