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 helloprint("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 checkedtotal = 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 arraysimport 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 conformanceimport 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 indexingvalues = [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 declarationscount = 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 stringsname = "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 selectionfor 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 proceduresdef 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_argumentsdef 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 formattingvalue = 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 fileswith 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 builtinsvalues = [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.