> *"Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your
Prerequisites
- 2
- 3
- 5
- 6
Learning Objectives
- Compose a format string from the core edit descriptors (I, F, E, ES, A, X, /) and predict the exact column layout it produces, character for character.
- Use list-directed I/O (print *, read *) for quick input and output, and explain why it trades away control over layout and precision.
- Open, write to, read from, close, and inquire about files, and distinguish sequential access from direct access.
- Read a program's run parameters from a namelist input file, and explain why namelist is the standard configuration mechanism in scientific Fortran.
- Choose unformatted or stream I/O for large numerical datasets, and quantify the precision and speed cost of writing numbers as text.
- Detect and handle I/O errors and end-of-file conditions with iostat and iomsg instead of letting a program crash.
In This Chapter
- Overview
- Learning Paths
- 7.1 Formatted Output and Edit Descriptors
- 7.2 List-Directed I/O: The Easy Way
- 7.3 Files: open, write, read, close, and inquire
- 7.4 namelist: Key/Value Input Files for Scientific Codes
- 7.5 Unformatted and Stream I/O for Large Datasets
- 7.6 Handling I/O Errors: iostat and iomsg
- Project Checkpoint
- Summary
- Spaced Review
- What's Next
Chapter 7: I/O — Reading Data, Writing Results, and Formatted Output
"Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowcharts; they'll be obvious." — Fred Brooks, The Mythical Man-Month
Overview
A program that cannot talk to the outside world is a closed box. It can compute, but it cannot tell you what it found, it cannot be steered without a recompile, and it cannot save a result you might want tomorrow. Input and output — I/O — is the membrane between your computation and everything else: the configuration file that sets a run's parameters, the terminal line that reports progress, the data file your solver leaves behind for the plotting script, the multi-gigabyte checkpoint a week-long simulation writes so a crash does not cost you the week. Getting I/O right is not glamorous, but it is the difference between a program that is a demo and a program that is a tool.
Fortran's I/O is, characteristically, built for numbers in bulk. It gives you exact, column-precise
control over how a value appears — down to the last space and decimal place — through edit descriptors,
the small format language you met a first taste of in Chapter 3.
It gives you a namelist facility that reads a key/value configuration file with a single statement — a
feature so convenient that scientific codes have leaned on it for decades. And it gives you unformatted
and stream I/O for writing arrays as raw bytes, which is how you move a billion numbers to disk without
paying to convert every one of them into decimal text and back. By the end of this chapter your heat
program stops hard-coding its parameters and starts reading them from a file — the moment it becomes
something you can run, not just something you can rebuild.
In this chapter, you will learn to:
- Build format strings from the core edit descriptors —
I,F,E,ES,A,X,/— and predict their output down to the individual space. - Reach for list-directed I/O (
print *,read *) when you want speed of typing over control of layout, and know exactly what you are giving up. - Open files, read and write records, close them, and use
inquireto ask the runtime about a file or unit — the full sequential-file toolkit, plus a look at direct access. - Configure a run from a
namelistfile, the scientific programmer's favorite input format. - Write large datasets as unformatted or stream binary, and say when text is the wrong choice.
- Handle errors and end-of-file gracefully with
iostatandiomsg, so a missing file is a message, not a crash.
Learning Paths
How to read this chapter by track. - 🔬 Scientist — §7.1 (formats), §7.4 (namelist), and §7.6 (error handling) are your working toolkit; §7.4 in particular will change how you configure every run. Skim §7.3's direct-access material. - 📖 Standard — read straight through; I/O underpins the file-format chapters of Part VI and every real program you will write. - 🔧 Legacy — note the sidebars on numbered units and labeled
FORMATstatements; old code is full of both, and §7.4'snamelistpredates the modern era, so you will meet it in codes from the 1980s onward. - ⚡ HPC — §7.5 (unformatted and stream) is the section that matters at scale; text I/O is a classic hidden bottleneck. The scalable, portable answer — HDF5 and NetCDF — is Chapter 25.
7.1 Formatted Output and Edit Descriptors
Every character a Fortran program writes under a format is placed by an edit descriptor — a compact instruction that says how to render one item and how wide to make its field. You met four of them in Chapter 3 just to make your programs print cleanly; this chapter owns the full story, and it starts by naming the object precisely.
Definition (edit descriptor). A code inside a format string — the parenthesized list in
print '(...)'orwrite(unit, '(...)')— that controls how one value is converted to or from characters. A data edit descriptor (i,f,e,es,a) formats a value; a control edit descriptor (x,/, and others) positions text without consuming a value. A descriptor's number, like the8.2inf8.2, gives a field width and, for reals, the number of digits after the decimal point. The whole parenthesized string is the format; the values follow it in the I/O list.
The mental model is a template with typed slots. The format '(a, i0, a, f8.2)' says: print a string, then
an integer in minimal width, then a string, then a real in an eight-character field with two decimals. The
values in the output list are poured into those slots in order. Here is the core set — the seven descriptors
that format everything in this book — in one program:
program edit_descriptors
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer :: n = 42
real(dp) :: t = 21.5_dp
real(dp) :: a = 1.0e-4_dp
real(dp) :: row(4) = [1.0_dp, 22.0_dp, 333.0_dp, 4444.0_dp]
print '(a, i0)', 'n with i0 = ', n ! I: integer, minimal width
print '(a, i6)', 'n with i6 = ', n ! I: integer in a 6-wide field
print '(a, f8.2)', 't with f8.2 = ', t ! F: fixed-point, 2 decimals
print '(a, e12.4)', 'a with e12.4 = ', a ! E: scientific, 0.xxxxE form
print '(a, es12.4)', 'a with es12.4 = ', a ! ES: scientific, mantissa 1-9
print '(a, 5x, a)', 'X inserts', 'five spaces' ! X: blanks, consumes no value
print '(4f9.2)', row ! repeat count: 4 x F9.2
print '(a)', 'a line, then a slash:'
print '(a, /, a)', 'first line', 'second line' ! / : end the record, new line
end program edit_descriptors
$ gfortran -std=f2018 -Wall -O2 example-01-edit-descriptors.f90 -o edits && ./edits
n with i0 = 42
n with i6 = 42
t with f8.2 = 21.50
a with e12.4 = 0.1000E-03
a with es12.4 = 1.0000E-04
X inserts five spaces
1.00 22.00 333.00 4444.00
a line, then a slash:
first line
second line
Trace a few lines, because every space is deliberate. In i6, the value 42 is two characters wide, so it
is right-justified in a six-character field with four leading spaces: 42. In f8.2, 21.50 is five
characters, right-justified in eight, giving three leading spaces: 21.50. The two scientific forms are
worth comparing side by side. e12.4 writes the classic Fortran form with a leading zero, 0.1000E-03
— a mantissa in $[0.1, 1)$ times a power of ten. es12.4 writes scientific form, 1.0000E-04, with
the mantissa in $[1, 10)$ — the convention a physicist expects. Same number, same width, different placement
of the decimal point; es is almost always the one you want for reporting a measured quantity.
The control descriptors carry no value of their own. 5x emits five spaces and moves on; / ends the
current record and begins a new line, which is why one print produced two lines of output. And the repeat
count in '(4f9.2)' applies the f9.2 descriptor four times, once per element of row — that is how you
print a whole array on one line with aligned columns.
💡 Intuition: a format string is a stencil for text. You are not describing the numbers; you are describing the empty boxes the numbers will drop into — how wide, how many decimals, in what order. Once you see the format as the boxes and the I/O list as the things poured into them, mismatches (too many boxes, too few values, a real poured into an integer box) become easy to reason about.
One more behavior you must internalize, because it bites everyone once and is invisible until it does.
⚠️ Common Pitfall: if a number does not fit its field, Fortran does not truncate it — it fills the field with asterisks. Print
4444.0withf6.2(which needs seven characters,4444.00, in a six-wide field) and you get******. This is a feature: a silently truncated number is a lie, so the standard makes an overflow loud instead. When you see a column of asterisks, widen the field —f10.2, or switch toesfor values whose magnitude you cannot predict. Do not "fix" it by narrowing the data.
A second subtlety concerns the very first column of output, and it is a place where old habits mislead.
📜 From History: on the line printers of the 1960s, the first character of every formatted line was carriage control — a blank meant "advance one line," a
1meant "new page," a+meant "overprint." Generations of Fortran programmers learned to start a format with1xto protect that first column. Modern gfortran writing to a file or terminal does not interpret carriage control, so a plainprint '(a)', 'hello'printshellowith no leading space. But list-directed output (§7.2) still emits one leading blank, a faint ghost of the old rule. Knowing the history is how you explain whyprint '(a)', xandprint *, xdiffer in their first column.
Note the compile line above carries no new flags — gfortran -std=f2018 -Wall -O2 is the same command you
have used since Chapter 2. I/O needs no special
switches. The full catalog of descriptors — g, en, d, l, b/o/z for binary/octal/hex,
scale factors, and the rules for how a format reverts when it runs out of descriptors before it runs out
of data — is laid out in Appendix F. The seven
here will carry you through all of Part I and most of the book.
🐍 Python Comparison: an edit descriptor is Fortran's answer to Python's format spec —
f8.2is essentiallyf'{t:8.2f}'. The difference is that Fortran's format is a separate template applied to a whole list of values at once, rather than interpolated into a string. For tabular numeric output that is a genuine advantage:print '(5f9.2)', rowformats and aligns five columns in one statement, where the Python equivalent is a loop or a join. For gluing text together, Python's f-strings are more ergonomic. The tools fit their jobs: Fortran's I/O is optimized for columns of numbers.
7.2 List-Directed I/O: The Easy Way
Sometimes you do not care about exact layout — you are debugging, or dumping a quick value, or reading a few numbers you typed yourself. For that, Fortran offers list-directed I/O, the mode you invoke with a bare asterisk in place of a format.
Definition (list-directed I/O). Input or output in which the processor chooses the formatting, selected by writing
*where a format string would go:print *, x(equivalentlywrite(*, *) x) andread *, x(equivalentlyread(*, *) x). On output, each value is written in a processor-chosen width with a sensible default for its type; on input, values are read by type and separated by spaces, commas, or line breaks. It is the quickest I/O to write and the least controllable.
You have used the output half since Chapter 1. The two asterisks in
write(*, *) are not the same thing, and it is worth separating them once: the first * is the unit
— here, standard output — and the second is the format — here, list-directed. print is simply
shorthand for write(*, ...) to standard output, so print *, x and write(*, *) x are identical.
List-directed output is genuinely convenient for a quick message:
program list_out
implicit none
print *, 'Solver finished; writing results.'
end program list_out
$ gfortran -std=f2018 -Wall list_out.f90 -o lo && ./lo
Solver finished; writing results.
Notice the single leading blank before Solver — that is the list-directed carriage-control ghost from
§7.1, and it is the giveaway that this line used print * rather than print '(a)'. That blank is exactly
why list-directed output is the wrong tool the moment appearance matters: you do not control it. The
same loss of control is sharper for reals. Write a double-precision value list-directed and the processor
prints it at something close to full precision in a width of its own choosing — gfortran might render
21.5_dp as 21.500000000000000 — which is fine for a debug dump and useless for a tidy report. When you
want 21.50 in a known column, you use f8.2. The rule of thumb: list-directed for yourself, formatted
for anyone (or anything) else.
The input half is where list-directed I/O earns its keep. read *, a, b, c reads three values from the
keyboard (standard input), and the runtime does the tedious work of skipping spaces and line breaks and
converting each token to the right type. Given the input line 10 20.5 hello, the statement
read *, i, x, word (with i an integer, x a real, and word a character variable) fills all three,
splitting on whitespace. Commas work as separators too, and a / in the input terminates the read early,
leaving any remaining list items unchanged. This is precisely the parsing you would otherwise write by hand,
handed to you for free.
⚠️ Common Pitfall: list-directed input of character data has a trap. An unquoted token stops at the first blank or comma, so
read *, namegivenNew Yorkreads onlyNew. To read a string containing spaces, either quote it in the input ('New York') or — better for whole lines — read it with an explicitaformat:read '(a)', name. And because list-directed input skips over line breaks looking for the next value, it will happily read across lines in ways that surprise you; when the record structure matters, use an explicit format so onereadconsumes exactly one line.🔄 Check Your Understanding 1. What is the difference between the two asterisks in
write(*, *) x? 2. Why doesprint *, 'done'produce a leading space butprint '(a)', 'done'does not? 3. Youread *, cityfrom the lineSan Diego. What ends up incity, and how would you read the whole two-word name?
Answers
- The first
*is the unit (standard output); the second is the format (list-directed).write(*, *)to standard output.print *is list-directed output, which emits one leading blank (the carriage-control ghost);print '(a)'uses an explicit format, which writes the string exactly with no leading space.- List-directed input stops at the first blank, so
citygetsSan. Read the whole line with an explicitaedit descriptor instead:read '(a)', city(or quote the input as'San Diego').
7.3 Files: open, write, read, close, and inquire
The terminal is one destination; a file is the one that lasts. To read or write a file you connect it to a
unit — a small integer handle — with open, do your reads and writes through that unit, and release
it with close. Two vocabulary terms make the rest of I/O click into place.
Definition (unit and record). A unit is an integer that names an open data channel; every
readandwriteis directed at a unit. A record is the atomic chunk a single I/O statement transfers — for a text file, one line; for unformatted data, onewrite's worth of bytes. Units5,6, and0are conventionally preconnected to standard input, standard output, and standard error, but you should never hard-code a unit number for a file — let the runtime pick one for you withnewunit.
Here is the fundamental pattern: open a file, write some values, close it; then reopen it, read them back, close it again. It is a round trip, so we can hand-verify the whole thing.
program file_io
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: out(3) = [10.0_dp, 20.0_dp, 30.0_dp]
real(dp) :: back(3)
integer :: u, ios, i
character(len=200) :: msg
logical :: ok
! WRITE: create (or overwrite) a text file and write one value per line.
open(newunit=u, file='data.txt', status='replace', action='write', &
iostat=ios, iomsg=msg)
if (ios /= 0) then
print '(a)', 'open for write failed: ' // trim(msg)
error stop
end if
do i = 1, 3
write(u, '(f8.2)') out(i)
end do
close(u)
! INQUIRE: confirm the file now exists before we try to read it.
inquire(file='data.txt', exist=ok)
print '(a, l1)', 'data.txt exists: ', ok
! READ: reopen the same file and read the three values back.
open(newunit=u, file='data.txt', status='old', action='read')
do i = 1, 3
read(u, *) back(i)
end do
close(u)
print '(a, 3f8.2)', 'read back:', back
end program file_io
$ gfortran -std=f2018 -Wall -O2 example-02-file-io.f90 -o fileio && ./fileio
data.txt exists: T
read back: 10.00 20.00 30.00
Every clause on the open statement is doing a job. newunit=u asks the runtime to pick an unused unit
number and hand it back in u — the modern replacement for guessing a free integer, and the reason you will
never see a bare open(17, ...) in this book. file= names the file. status= states your
expectation: 'replace' creates the file or overwrites an existing one, 'old' requires it to already
exist (and errors if it does not), 'new' requires it to not exist, and 'scratch' makes a temporary
file with no name that vanishes on close. action= declares 'read', 'write', or 'readwrite', so
the compiler and runtime can catch a wrong-way transfer. On the read side, read(u, *) reads each line
list-directed, converting the text 10.00 back to the value 10.0. The values round-trip exactly to two
decimals because that is all we wrote.
The inquire statement asks the runtime about the state of the I/O world without transferring any data.
The most useful question is exist= — does this file exist? — which lets you guard an open and fail
gracefully instead of crashing. You can also ask opened= (is this unit or file already connected?),
number= (which unit is this file on?), size= (how many bytes?), and more. The defensive habit is to
inquire(file=..., exist=ok) before opening a file you expect to be there, so a missing input becomes a
clear message rather than a runtime abort — a pattern we lean on in §7.6.
🚪 Threshold Concept. A file is not a special kind of variable; it is a stream of records reached through a unit. Once you hold that picture — a unit is a handle, a record is the thing one statement moves, and
open/closeconnect and disconnect the handle — the whole zoo of I/O statements collapses into one idea. Terminal, text file, binary file, and the internal-string I/O of Chapter 12 are all the same verbs (read,write) aimed at different units. You are never learning "file I/O" and "screen I/O" separately; you are learning one mechanism and changing where the unit points.
Sequential versus direct access. By default a file has sequential access: records are written and
read in order, front to back, like a cassette tape — to reach the hundredth record you pass the first
ninety-nine. That is exactly right for a log, a configuration file, or a results table you process top to
bottom. Sometimes, though, you want to jump straight to record N — record number 5,000 of a fixed-size
dataset — without reading the preceding ones. That is direct access: you declare a fixed record length
at open time with recl=, and then address any record by number with rec=.
program direct_access
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer :: u, i
real(dp) :: v
! Fixed-length records, each holding one 8-byte double, addressable by number.
open(newunit=u, file='rec.dat', access='direct', form='unformatted', &
recl=8, status='replace', action='readwrite')
do i = 1, 3
write(u, rec=i) real(i * 100, dp) ! record 1->100.0, 2->200.0, 3->300.0
end do
read(u, rec=2) v ! jump straight to record 2
close(u)
print '(a, f6.1)', 'record 2 = ', v
end program direct_access
$ gfortran -std=f2018 -Wall -O2 direct_access.f90 -o da && ./da
record 2 = 200.0
We wrote three records and then read only the second, addressing it directly with rec=2. Direct access is
how a program treats a file as a random-access array of records — useful for databases of fixed-size entries
and for restart files you want to index into.
⚠️ Common Pitfall: the units of
reclare processor-dependent. gfortran counts bytes, so an 8-byte double needsrecl=8; some compilers (notably Intel, by default) count 4-byte words, where the same record wantsrecl=2. This is a classic portability trap that makes a direct-access file written by one compiler unreadable by another. If you must be portable, compute the length with theinquirestatement'siolength=specifier —inquire(iolength=len) a_doublegives the rightreclfor your compiler — rather than hard-coding a number.
7.4 namelist: Key/Value Input Files for Scientific Codes
Here is the feature that will most change how you work day to day. A scientific program has parameters — a
grid size, a time step, a diffusivity, a number of steps — and you do not want to recompile every time you
change one, nor write a fragile parser to read them from a file. Fortran solves this at the language level
with namelist, which reads and writes a named group of variables as name = value pairs.
Definition (
namelist). A statement that associates a group name with a list of variables —namelist /config/ nx, ny, alpha— so that a singleread(unit, nml=config)reads a block of the form&config nx=100, alpha=1.0e-4 /and assigns each value to the matching variable by name. Names may appear in any order, any may be omitted (the variable keeps its prior value), and matching is case-insensitive. A matchingwrite(unit, nml=config)dumps the whole group back out. It is Fortran's built-in configuration-file format.
An input file for a namelist group looks like this — readable, self-documenting, and editable by anyone,
no code required:
&config
nx = 5,
ny = 4,
alpha = 0.2,
dt = 0.25,
n_steps = 100
/
The group opens with &config and closes with /. Everything between is name = value pairs, separated by
commas or newlines. To read it, you declare variables, declare the namelist group over them, and read — one
statement does all the parsing:
program namelist_config
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer :: nx, ny, n_steps
real(dp) :: alpha, dt
namelist /config/ nx, ny, alpha, dt, n_steps
integer :: u
! Defaults: used for any name the file does not mention.
nx = 10; ny = 10; alpha = 1.0e-4_dp; dt = 0.01_dp; n_steps = 1000
! Write a config file by hand here so the demo is self-contained;
! in practice a human types this file in a text editor.
open(newunit=u, file='config.nml', status='replace', action='write')
write(u, '(a)') '&config'
write(u, '(a)') ' nx = 5,'
write(u, '(a)') ' ny = 4,'
write(u, '(a)') ' alpha = 0.2,'
write(u, '(a)') ' dt = 0.25,'
write(u, '(a)') ' n_steps = 100'
write(u, '(a)') '/'
close(u)
! The whole point: one statement parses the file.
open(newunit=u, file='config.nml', status='old', action='read')
read(u, nml=config)
close(u)
print '(a, i0)', 'nx = ', nx
print '(a, i0)', 'ny = ', ny
print '(a, f6.3)', 'alpha = ', alpha
print '(a, f6.3)', 'dt = ', dt
print '(a, i0)', 'n_steps = ', n_steps
end program namelist_config
$ gfortran -std=f2018 -Wall -O2 example-03-namelist-config.f90 -o nml && ./nml
nx = 5
ny = 4
alpha = 0.200
dt = 0.250
n_steps = 100
Study what just happened, because it is close to magical the first time. We set five defaults, then read a
file that mentioned all five by name, and every variable took the file's value. Had the file omitted
n_steps, the variable would have kept its default of 1000 — so a namelist file need only list what you
want to change. The order in the file is irrelevant; you could shuffle the lines and get the same result.
The names are matched case-insensitively, so NX, Nx, and nx are the same variable. And you wrote no
parser: the single read(u, nml=config) did all of it.
🔗 Connection:
namelistis not a museum piece — it is how a great deal of production scientific Fortran is configured today. Atmospheric and ocean models, quantum-chemistry packages, and countless in-house codes take their run parameters from namelist files, often several groups in one file (one for the grid, one for the physics, one for the output settings). It is a genuine advantage of the language: the configuration format that other ecosystems reach for YAML or JSON libraries to parse is, in Fortran, three lines of code and aread.
The same facility runs in reverse. write(unit, nml=config) dumps the entire group as a namelist block,
which is invaluable for two things: echoing the exact parameters a run used (write the namelist to the
top of your output file, and the file documents its own provenance), and restart files (dump the state,
read it back to resume). We lean on the echo habit in the Project Checkpoint. One honesty note: the precise
spacing and precision of namelist output are processor-defined — gfortran writes reals at full precision
with its own alignment — so you read a namelist to configure a run, but you rarely try to make its output
pretty. When you need pretty, you format it yourself with §7.1.
⚠️ Common Pitfall: a namelist read fails, sometimes silently to the untrained eye, if the file's group name does not match or a
namein the file is not in the group. A file that says¶mswhen your code declares/config/, or that setsn_stepwhen the variable isn_steps, raises a nonzeroiostat(§7.6) — so always read a namelist withiostat, and report theiomsg. A misspelled key that fails loudly is a five-second fix; one that is silently ignored is an afternoon lost to wondering why your run ignored a parameter.🔄 Check Your Understanding 1. A namelist file lists only
alpha = 0.5. Your program declares/config/ nx, ny, alphaand sets all three defaults before reading. What arenx,ny, andalphaafterward? 2. Why isnamelistbetter than reading parameters positionally (e.g.,read(u, *) nx, ny, alpha)? 3. What are the two delimiters that open and close a namelist group in the input file?
Answers
alphabecomes0.5;nxandnykeep their defaults, because a namelist read only assigns the names actually present in the file.- Namelist is by name, so order does not matter, values are self-documenting, any parameter can be omitted to accept its default, and adding a new parameter does not break old input files. Positional reads are order-dependent, unlabeled, and brittle — insert a parameter and every file must change.
- The group opens with
&groupname(here&config) and closes with/.
7.5 Unformatted and Stream I/O for Large Datasets
Everything so far has written text: human-readable characters, converted from the machine's internal
binary representation of each number. That conversion is a convenience with two costs, and at scale both
costs bite hard. First, it is lossy — write(u, '(f8.2)') x keeps two decimal digits and throws the
rest of the double's fifteen away, so the number you read back is not the number you wrote. Second, it is
slow and bulky — turning a real(dp) into decimal text takes real work, and the text is often larger
than the eight bytes the number actually occupies. For a handful of values, who cares. For a billion-cell
field written every timestep, it is the difference between a simulation that finishes and one that spends
its life formatting.
The fix is to skip the text and write the raw bytes.
Definition (unformatted and stream I/O). Unformatted I/O transfers a value's exact internal bytes with no character conversion, selected by
form='unformatted'atopentime; youwrite(u) xandread(u) xwith no format. A traditional unformatted sequential file wraps eachwrite's data in record markers (a length before and after), so the file knows where each record ends. Stream I/O (access='stream', Fortran 2003) writes the bytes with no markers at all — a pure, positionable byte stream, addressable to the individual byte withpos=— which is what you use to interoperate with C, Python, or any non-Fortran reader.
The round trip, in raw binary, preserving every bit:
program binary_io
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: out(3) = [1.5_dp, 2.5_dp, 3.5_dp]
real(dp) :: back(3)
integer :: u
! Unformatted sequential: write the whole array as one record, no conversion.
open(newunit=u, file='field.bin', form='unformatted', &
status='replace', action='write')
write(u) out ! all three doubles, exact bytes, one statement
close(u)
open(newunit=u, file='field.bin', form='unformatted', &
status='old', action='read')
read(u) back ! read them straight back
close(u)
print '(a, 3f6.2)', 'recovered:', back
end program binary_io
$ gfortran -std=f2018 -Wall -O2 binary_io.f90 -o bin && ./bin
recovered: 1.50 2.50 3.50
The values return exactly — not "to two decimals," but bit for bit — because no conversion happened. And
the whole array moved in a single write(u) out, not a loop: unformatted I/O is happy to transfer an entire
array (indeed an entire derived type, in later chapters) in one statement, which is both convenient and fast.
For the stream variant, change one clause:
open(newunit=u, file='field.raw', access='stream', form='unformatted', &
status='replace', action='write')
write(u) out
close(u)
Now field.raw contains exactly 24 bytes — three 8-byte doubles, nothing else — with no record markers.
That is the file you hand to NumPy (numpy.fromfile('field.raw', dtype='float64')) or to a C program, which
is why stream is the interoperability format. The traditional unformatted-sequential file, by contrast,
carries Fortran-specific record markers that a non-Fortran reader would trip over.
⚡ Performance Note: the gap between text and binary is not subtle at scale. Consider a field of one million doubles. As text at full precision (
es24.16) it is roughly 24 MB and costs a million decimal-conversion operations to write and a million to parse back. As unformatted binary it is exactly 8 MB, written and read in essentially one bulk transfer with no per-value conversion — commonly an order of magnitude faster, and exact. (Those are illustrative orders of magnitude, not a benchmark.) The lesson generalizes: text is for humans and small data; binary is for machines and big data. When a profiler in Chapter 28 shows a solver spending its time inwrite, this is almost always why, and switching to unformatted output is the fix.
There is an honest catch, and it is the reason Part VI exists. Raw unformatted and stream files are not
self-describing and not portable: the bytes carry no record of their shape, their type, their units, or
the byte order (endianness) of the machine that wrote them. Move a little-endian binary file to a big-endian
machine, or forget whether it held real32 or real64, and you have a pile of meaningless bytes. For a
scratch file your own program writes and reads back on the same machine, that is fine. For data you must
share, archive, or read years later, you want a self-describing format — HDF5 or NetCDF — which
stores the shape, type, and metadata alongside the data and handles endianness for you. That is exactly the
subject of Chapter 25,
and the visualization-friendly VTK output your solver will eventually write is
Chapter 26. Unformatted
I/O is the foundation those formats are built on; know it, and know its limits.
🐍 Python Comparison: stream I/O is the Fortran side of NumPy's
tofile/fromfileandnp.save.write(u) arrayto a stream file andnumpy.fromfile(..., dtype='float64')are two ends of the same pipe — provided both agree on dtype and byte order, the two caveats above. This is the plumbing behind the "hot kernel in Fortran, analysis in Python" workflow the book returns to; you will wire it up properly with f2py in Chapter 15, where arrays cross the boundary in memory rather than through a file.
7.6 Handling I/O Errors: iostat and iomsg
I/O is where a program meets the messy real world — a file that is missing, a disk that is full, a config
line with a typo, the end of the input arriving sooner than expected. By default, any of these stops your
program with a runtime error. That is acceptable for a script and unacceptable for a simulation that has
been running for two days. The remedy is to ask each I/O statement whether it succeeded, with the
iostat and iomsg specifiers.
Definition (
iostatandiomsg). Optional specifiers on any I/O statement that turn a fatal error into a reportable condition.iostat=iossets the integeriosto zero on success, a negative value at end-of-file or end-of-record, and a positive value on an error — and, crucially, lets the program continue instead of aborting.iomsg=msgfills the character variablemsgwith a human-readable description of what went wrong. Because the positive error values are processor-defined, you compare against the named constantsiostat_endandiostat_eorfromiso_fortran_envrather than hard-coding numbers.
Two patterns cover almost everything. The first is guarding an open — you saw it in §7.3, and it is
the single most valuable line of defensive I/O you can write:
open(newunit=u, file='config.nml', status='old', action='read', &
iostat=ios, iomsg=msg)
if (ios /= 0) then
print '(a)', 'cannot open config.nml: ' // trim(msg)
error stop
end if
Without iostat, opening a missing file crashes with a terse runtime message. With it, you get to print a
sentence a human can act on ("cannot open config.nml: No such file or directory") and exit cleanly — or fall
back to defaults, or try another path. The error stop here is a deliberate, controlled halt with a nonzero
exit code, which Chapter 13
develops into a full error-handling discipline; for now, read it as "stop, and tell the shell something went
wrong."
The second pattern is the end-of-file read loop — reading an unknown number of records until the file
runs out. This is where the negative iostat and the named constant iostat_end do their work:
program read_loop
use, intrinsic :: iso_fortran_env, only: dp => real64, iostat_end
implicit none
integer :: u, ios, n
real(dp) :: x, total
! Write three values first so the example is self-contained.
open(newunit=u, file='values.txt', status='replace', action='write')
write(u, '(f0.1)') 10.0_dp
write(u, '(f0.1)') 20.0_dp
write(u, '(f0.1)') 30.0_dp
close(u)
! Now read until end-of-file, summing as we go.
open(newunit=u, file='values.txt', status='old', action='read')
total = 0.0_dp
n = 0
do
read(u, *, iostat=ios) x
if (ios == iostat_end) exit ! clean end of file: leave the loop
if (ios /= 0) then ! any other nonzero: a real error
print '(a, i0)', 'read error, iostat = ', ios
exit
end if
total = total + x
n = n + 1
end do
close(u)
print '(a, i0, a, f0.2)', 'read ', n, ' values, sum = ', total
end program read_loop
$ gfortran -std=f2018 -Wall -O2 read_loop.f90 -o rl && ./rl
read 3 values, sum = 60.00
The loop has no idea how many values the file holds; it reads until iostat comes back equal to
iostat_end, then exits cleanly. Any other nonzero iostat is a genuine error — a malformed line, say —
and gets its own branch. This is the canonical way to consume a file of unknown length in Fortran, and it is
robust precisely because it distinguishes the three outcomes the standard defines: success (zero), orderly
end (iostat_end, negative), and error (positive).
🐛 Find the Bug. A colleague's read loop never terminates on some machines and terminates early on others:
fortran do read(u, *, iostat=ios) x if (ios == -1) exit ! "end of file is -1, right?" total = total + x end doThe bug is the hard-coded
-1. The value the runtime assigns toiostatat end-of-file is processor-defined; it is very often-1, but the standard does not promise it, and a compiler is free to use another negative number. The portable fix is to compare against the named constant:if (ios == iostat_end) exit, withiostat_endimported fromiso_fortran_env. (There is a second latent bug: a genuine read error, which setsiospositive, is not handled at all and would be added intototalas a garbagex— hence the separateif (ios /= 0)branch in the correct version above.)🔗 Connection: the
iostat/iomsgmechanism you are learning here is the pattern for error handling in Fortran, and it generalizes. Memory allocation uses the same shape —allocate(a(n), stat=ios, errmsg=msg)— and Chapter 13 builds both into a defensive-programming discipline, alongsideerror stop, the debugging flags-fcheck=alland-fbacktrace, and the trapping of floating-point exceptions. When Chapter 13 says "theiostatidea, generalized," this is the idea it means: ask whether the operation succeeded, and decide what to do, instead of letting the runtime decide by crashing.🔄 Check Your Understanding 1. After a
read, what doiostatvalues of zero, a negative number, and a positive number each mean? 2. Why should an end-of-file test useiostat_endrather than-1? 3. What doesiomsggive you thatiostatalone does not?
Answers
- Zero means success; a negative value means end-of-file (or end-of-record for non-advancing reads); a positive value means an error occurred.
- The exact negative value used at end-of-file is processor-defined.
iostat_end(fromiso_fortran_env) is the portable name for it;-1is a common but non-guaranteed value that makes the code non-portable.iomsgfills a character variable with a human-readable description of the failure ("No such file or directory"), which turns a bare error number into a message you or a user can act on.
Project Checkpoint
Your heat program still hard-codes its parameters. Change one number — the grid size, the time step — and
you recompile. That was tolerable while the program was a teaching sketch; it is intolerable for a tool you
run dozens of times with different settings. This chapter's increment fixes it: the solver now reads its
run parameters from a namelist file and writes its temperature field to a text file. These are the
two I/O procedures the book's canonical heat_io module will own (you will lift them into that module in
Chapter 8); here we build them as
internal procedures, exactly as Chapter 6 built step.
The configuration file, heat.nml, holds the five parameters the run needs:
&config
nx = 4,
ny = 4,
alpha = 1.0e-4,
dt = 0.25,
n_steps = 50,
/
And the driver reads it, sets up the plate (hot top edge, cold interior), and writes the field with
write_field — whose signature, write_field(field, filename), is fixed for the rest of the book:
program heat_checkpoint
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer :: nx, ny, n_steps
real(dp) :: alpha, dt
namelist /config/ nx, ny, alpha, dt, n_steps
real(dp), allocatable :: field(:,:)
integer :: u, ios
character(len=200) :: msg
! Defaults (overridden by anything the file specifies).
nx = 10; ny = 10; alpha = 1.0e-4_dp; dt = 0.5_dp; n_steps = 1000
! Write a self-contained heat.nml so the checkpoint runs on its own.
open(newunit=u, file='heat.nml', status='replace', action='write')
write(u, '(a)') '&config'
write(u, '(a)') ' nx = 4,'
write(u, '(a)') ' ny = 4,'
write(u, '(a)') ' alpha = 1.0e-4,'
write(u, '(a)') ' dt = 0.25,'
write(u, '(a)') ' n_steps = 50,'
write(u, '(a)') '/'
close(u)
! Read the run parameters — guarded, per §7.6.
open(newunit=u, file='heat.nml', status='old', action='read', &
iostat=ios, iomsg=msg)
if (ios /= 0) then
print '(a)', 'cannot open heat.nml: ' // trim(msg)
error stop
end if
read(u, nml=config)
close(u)
! Set up the plate: top edge held hot at 100, everything else cold at 0.
allocate(field(nx, ny))
field = 0.0_dp
field(1, :) = 100.0_dp
! Write the field, then echo the configuration this run used.
call write_field(field, 'heat_field.txt')
print '(a, i0, a, i0)', 'grid : ', nx, ' x ', ny
print '(a, es9.2)', 'alpha : ', alpha
print '(a, f6.3)', 'dt : ', dt
print '(a, i0)', 'n_steps : ', n_steps
print '(a)', 'wrote field : heat_field.txt'
contains
subroutine write_field(f, filename)
real(dp), intent(in) :: f(:,:) ! assumed-shape: any plate size
character(len=*), intent(in) :: filename ! deferred-length dummy
integer :: unit, i, j, nrow, ncol
nrow = size(f, 1)
ncol = size(f, 2)
open(newunit=unit, file=filename, status='replace', action='write')
do i = 1, nrow
write(unit, '(*(f8.2))') (f(i, j), j = 1, ncol) ! one row per line
end do
close(unit)
end subroutine write_field
end program heat_checkpoint
The console echo (hand-computed) confirms the file's values overrode the defaults — the grid is 4 x 4, not
the default 10 x 10:
grid : 4 x 4
alpha : 1.00E-04
dt : 0.250
n_steps : 50
wrote field : heat_field.txt
And heat_field.txt holds the plate itself, row by row, hot edge on top — the first physical output your
solver has ever produced:
100.00 100.00 100.00 100.00
0.00 0.00 0.00 0.00
0.00 0.00 0.00 0.00
0.00 0.00 0.00 0.00
Three choices carry lessons from this chapter. The parameters come from a namelist (§7.4), so a run is
configured by editing a text file, never by recompiling. write_field takes an assumed-shape array
f(:,:) (a habit from Chapter 6) and a deferred-length character
filename character(len=*), so the one routine writes any size of plate to any filename. And the row loop
uses the unlimited-repeat format '(*(f8.2))' (§7.1), which applies f8.2 to as many values as the row
holds — you do not hard-code the column count, so the same statement serves a 4-wide and a 4000-wide plate.
The read_config/write_field pair is the seed of the heat_io module; when you reach
Chapter 8 it moves into a module almost
unchanged, and in Chapter 26
write_field grows a write_vtk sibling so ParaView can animate the result. The interface you settle now is
the interface the rest of the book builds on.
Summary
This chapter gave your programs a voice and ears: the ability to format results precisely, read configuration, persist data, and survive the errors that I/O invites.
| Concept | The short version |
|---|---|
| Edit descriptor | A code in a format string that renders one item. Core set: i (integer), f (fixed-point), e/es (scientific), a (character), x (spaces), / (new record). w.d = width.decimals. |
| Overflow rule | A value too wide for its field prints as ******, never truncated — widen the field or use es. |
| List-directed I/O | print * / read *: the processor picks the format. Fast to write, no layout control, one leading blank on output. Use it for yourself; format for everyone else. |
| Files | open(newunit=u, file=..., status=..., action=...), then read/write through u, then close(u). newunit picks a free unit. inquire(file=..., exist=ok) asks before opening. |
| Sequential vs direct | Sequential = records in order (the default). Direct = access='direct', recl=..., addressable by rec=N. recl units are processor-defined (bytes in gfortran). |
namelist |
namelist /grp/ a, b + read(u, nml=grp) parses a &grp a=1, b=2 / file by name. Order-free, omittable, case-insensitive. The scientific config format. |
| Unformatted / stream | form='unformatted': raw bytes, exact, fast, one statement per array. access='stream': no record markers, byte-addressable, C/Python-compatible. Not self-describing (→ Ch. 25). |
iostat / iomsg |
Turn a fatal I/O error into a value: iostat = 0 (ok) / negative (iostat_end) / positive (error); iomsg = the message. Guard every open; loop reads until iostat_end. |
The two things to memorize. First, f8.2 means eight characters wide with two decimals, right-justified
— internalize the width/decimals grammar and you can predict any formatted line to the column, which is the
whole skill of §7.1. Second, guard I/O with iostat, and compare end-of-file against iostat_end, never
-1 — the cheapest way to turn a crash into a message, and the pattern
Chapter 13 generalizes
to all error handling.
Spaced Review
Four questions reaching back to Chapter 3 (types and
format basics) and Chapter 6 (procedures), the ground this chapter's
write_field stands on.
-
(Ch. 3) In §3.7 you met
f7.2. What exactly does it print for the value3.14159, and for-3.14159? Count the leading spaces.
Answer
`f7.2` rounds to two decimals and right-justifies in a seven-character field. `3.14159` → `3.14` is four characters, so ` 3.14` (three leading spaces). `-3.14159` → `-3.14` is five characters (the minus sign counts), so ` -3.14` (two leading spaces). -
(Ch. 3) Our checkpoint computes nothing new about precision, but recall: why would writing the field with
'(f8.2)'and reading it back not recover the original double-precision values exactly, while the unformatted file of §7.5 does?
Answer
`f8.2` keeps only two decimal digits, discarding the rest of the double's ~15 significant digits — a lossy text conversion. Unformatted I/O writes the exact internal bytes with no conversion, so it round-trips bit for bit. Text is for humans and small data; binary is for exact, bulk data. -
(Ch. 6) In
write_field(f, filename),fis declaredreal(dp), intent(in) :: f(:,:). Whyintent(in), and what does the(:,:)buy us?
Answer
`write_field` only *reads* the field to print it, so `intent(in)` documents and enforces that it cannot modify the caller's data (the compiler rejects any accidental assignment). The `(:,:)` makes it assumed-shape: the array's extents travel with it, so `size(f, 1)` and `size(f, 2)` recover the row and column counts and the one routine writes any plate size — no dimensions passed by hand. -
(Ch. 6)
write_fieldis asubroutine, not afunction. Given what the chapter said about the two, why is that the right choice here?
Answer
It performs an *action* with a side effect (writing a file) and returns no value, which is exactly the job of a subroutine. A function should compute and return one clean value with no side effects; writing to disk is a side effect, so a function would be the wrong tool.
What's Next
Your solver can now be configured and can save its results — but every procedure it owns is still crammed
into one program's contains, and dp is redefined in every file. Chapter 8
fixes that with modules, the foundation of modern Fortran organization. The read_config and
write_field you just wrote will move, almost verbatim, into a heat_io module; dp will live in a
kinds module every file simply uses; and the update logic will become a heat_solver module. The same
explicit interface that contains gave your procedures, a module gives them across files — and your heat
program finally splits into the clean, reusable pieces that a real scientific code is made of. The plumbing
is done; next we build the house.