32 min read

> *"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.

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 inquire to ask the runtime about a file or unit — the full sequential-file toolkit, plus a look at direct access.
  • Configure a run from a namelist file, 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 iostat and iomsg, 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 FORMAT statements; old code is full of both, and §7.4's namelist predates 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 '(...)' or write(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 the 8.2 in f8.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.0 with f6.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 to es for 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 1 meant "new page," a + meant "overprint." Generations of Fortran programmers learned to start a format with 1x to protect that first column. Modern gfortran writing to a file or terminal does not interpret carriage control, so a plain print '(a)', 'hello' prints hello with 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 why print '(a)', x and print *, x differ 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.2 is essentially f'{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)', row formats 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 (equivalently write(*, *) x) and read *, x (equivalently read(*, *) 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 *, name given New York reads only New. To read a string containing spaces, either quote it in the input ('New York') or — better for whole lines — read it with an explicit a format: 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 one read consumes exactly one line.

🔄 Check Your Understanding 1. What is the difference between the two asterisks in write(*, *) x? 2. Why does print *, 'done' produce a leading space but print '(a)', 'done' does not? 3. You read *, city from the line San Diego. What ends up in city, and how would you read the whole two-word name?

Answers

  1. The first * is the unit (standard output); the second is the format (list-directed). print is shorthand for write(*, *) to standard output.
  2. 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.
  3. List-directed input stops at the first blank, so city gets San. Read the whole line with an explicit a edit 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 read and write is 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, one write's worth of bytes. Units 5, 6, and 0 are 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 with newunit.

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/close connect 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 recl are processor-dependent. gfortran counts bytes, so an 8-byte double needs recl=8; some compilers (notably Intel, by default) count 4-byte words, where the same record wants recl=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 the inquire statement's iolength= specifier — inquire(iolength=len) a_double gives the right recl for 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 single read(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 matching write(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: namelist is 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 a read.

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 name in the file is not in the group. A file that says &params when your code declares /config/, or that sets n_step when the variable is n_steps, raises a nonzero iostat (§7.6) — so always read a namelist with iostat, and report the iomsg. 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, alpha and sets all three defaults before reading. What are nx, ny, and alpha afterward? 2. Why is namelist better 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

  1. alpha becomes 0.5; nx and ny keep their defaults, because a namelist read only assigns the names actually present in the file.
  2. 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.
  3. 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 lossywrite(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' at open time; you write(u) x and read(u) x with no format. A traditional unformatted sequential file wraps each write'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 with pos= — 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 in write, 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/fromfile and np.save. write(u) array to a stream file and numpy.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 (iostat and iomsg). Optional specifiers on any I/O statement that turn a fatal error into a reportable condition. iostat=ios sets the integer ios to 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=msg fills the character variable msg with a human-readable description of what went wrong. Because the positive error values are processor-defined, you compare against the named constants iostat_end and iostat_eor from iso_fortran_env rather 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 do

The bug is the hard-coded -1. The value the runtime assigns to iostat at 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, with iostat_end imported from iso_fortran_env. (There is a second latent bug: a genuine read error, which sets ios positive, is not handled at all and would be added into total as a garbage x — hence the separate if (ios /= 0) branch in the correct version above.)

🔗 Connection: the iostat/iomsg mechanism 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, alongside error stop, the debugging flags -fcheck=all and -fbacktrace, and the trapping of floating-point exceptions. When Chapter 13 says "the iostat idea, 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 do iostat values of zero, a negative number, and a positive number each mean? 2. Why should an end-of-file test use iostat_end rather than -1? 3. What does iomsg give you that iostat alone does not?

Answers

  1. 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.
  2. The exact negative value used at end-of-file is processor-defined. iostat_end (from iso_fortran_env) is the portable name for it; -1 is a common but non-guaranteed value that makes the code non-portable.
  3. iomsg fills 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.

  1. (Ch. 3) In §3.7 you met f7.2. What exactly does it print for the value 3.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).

  2. (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.

  3. (Ch. 6) In write_field(f, filename), f is declared real(dp), intent(in) :: f(:,:). Why intent(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.

  4. (Ch. 6) write_field is a subroutine, not a function. Given what the chapter said about the two, why is that the right choice here?

    AnswerIt 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.