I/O Operations in Erlang
Learn console output, reading input, and file I/O in Erlang using the io and file modules, with tagged tuples for error handling and Docker-ready examples
Input and output are where a pure functional language meets the messy outside world. Erlang keeps its data immutable and its functions honest, but reading a keypress or writing a file is inherently a side effect - something that changes the world beyond the return value. Erlang doesn’t hide this behind a monad the way Haskell does; instead, I/O lives in ordinary functions in the io and file modules, and the language leans on tagged tuples like {ok, Data} and {error, Reason} to make success and failure explicit at every call site.
In the Hello World tutorial you met io:format/1 for printing a single line. Here we go deeper: formatted output with control sequences, reading lines from standard input, writing and reading files, and handling I/O errors without a single try/catch. Because Erlang was born to run telephone switches that must never fall over, its I/O functions return errors as values you pattern-match on, not exceptions you hope to catch.
Every example below is a self-contained escript, so you can run each one with a single Docker command. We’ll build a small languages.txt file, read it back, and see how Erlang reports a missing file - all while staying true to its functional, “let it crash” roots.
Formatted Console Output
io:format/2 takes a format string and a list of arguments. Control sequences begin with ~ and describe how each argument should be rendered - ~s for strings, ~B for integers, ~.2f for a float rounded to two decimals, and ~p to pretty-print any Erlang term.
Create a file named io_operations.erl:
| |
Notice there is no printf with positional % specifiers - the arguments always arrive as a list ([Name, Year]), which fits Erlang’s uniform “everything is a term” model.
Reading Input from Standard Input
io:get_line/1 prints a prompt and returns the line the user types, newline included. Since Erlang strings are lists of characters, we clean up the trailing newline with string:trim/1 before using it.
Create a file named read_input.erl:
| |
This program is interactive: it blocks until a line is available on standard input. When you run it under Docker you can either type a name at the prompt or pipe one in (shown in the Docker section below).
Writing to a File
The file module handles files. The simplest path is file:write_file/2, which writes an entire chunk of iodata (a string, binary, or nested list of them) in one call and returns the atom ok. For incremental writes you open a handle with file:open/2 and reuse io:format/3, passing the file device as the first argument.
Create a file named write_file.erl:
| |
The ok = file:write_file(...) line is doing double duty: it’s a pattern match that asserts the write succeeded. If file:write_file/2 returned {error, Reason} instead, the match would fail and the process would crash - exactly the “let it crash” behavior you want when a write you assumed would work does not.
Reading a File Back
file:read_file/1 slurps an entire file into a binary and returns {ok, Binary}. Binaries are Erlang’s efficient representation for raw bytes and text, and ~s prints them as-is. Run this after write_file.erl so languages.txt exists.
Create a file named read_file.erl:
| |
string:trim/1 and string:split/3 operate directly on the binary, so there’s no need to convert it to a character list first. The all option tells split to break on every newline rather than just the first.
Handling I/O Errors as Values
This is the heart of Erlang’s I/O philosophy. Instead of throwing an exception, file:read_file/1 returns {error, Reason} when something goes wrong - enoent (error: no entry) for a missing file. You pattern-match both outcomes with a case expression and the program keeps running.
Create a file named io_errors.erl:
| |
Because the error is an ordinary return value, control flow stays linear and explicit. You decide, per call, whether a failure is worth handling (case) or worth crashing over (ok = ...).
Running with Docker
| |
Expected Output
Running io_operations.erl:
=== Formatted Output ===
Language: Erlang (released 1986)
Pi to 2 decimals: 3.14
BEAM languages: [erlang,elixir,gleam]
Raw tuple: {ok,200}
[padded ] done
Running read_input.erl with Ada piped in:
Enter your name: Hello, Ada!
Running write_file.erl:
Wrote languages.txt
Appended one line
Running read_file.erl (after the write above):
--- File contents ---
Erlang 1986
Elixir 2011
Gleam 2016
Erlang/OTP 27
Line count: 4
Running io_errors.erl:
Could not read file: enoent
Program continues after the error
Key Concepts
- I/O is a side effect, not a monad - Erlang keeps input/output in plain
ioandfilemodule functions rather than wrapping it in a type; the honesty comes from tagged return values, not the type system. - Tagged tuples encode success and failure -
{ok, Result}and{error, Reason}are the universal convention; you pattern-match them instead of catching exceptions. - Format control sequences start with
~-~s(string),~B(integer),~.2f(rounded float),~p(pretty-print),~w(raw write), and~n(newline), with arguments always passed as a list. ok = file:write_file(...)is an assertion - matching againstokcrashes the process on failure, which is idiomatic “let it crash” for operations you expect to succeed.- Binaries are the efficient text type -
file:read_file/1returns a binary, and modernstringfunctions liketrim/1andsplit/3work on binaries directly. - File handles are explicit -
file:open/2gives you a device you pass toio:format/3, and you mustfile:close/1it yourself. enoentand friends are POSIX error atoms - Erlang surfaces the underlying operating-system error as a readable atom you can match on or log.
Comments
Loading comments...
Leave a Comment