Intermediate

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/usr/bin/env escript
%% io_operations.erl - Formatted console output in Erlang
main(_) ->
    io:format("=== Formatted Output ===~n"),

    %% ~s prints a string, ~B prints an integer in base 10
    Name = "Erlang",
    Year = 1986,
    io:format("Language: ~s (released ~B)~n", [Name, Year]),

    %% ~.2f rounds a float to two decimal places
    Pi = 3.14159,
    io:format("Pi to 2 decimals: ~.2f~n", [Pi]),

    %% ~p pretty-prints any term (lists, tuples, maps, ...)
    Langs = [erlang, elixir, gleam],
    io:format("BEAM languages: ~p~n", [Langs]),

    %% ~w writes a term in raw form (no pretty formatting)
    io:format("Raw tuple: ~w~n", [{ok, 200}]),

    %% ~-12s left-justifies the string in a 12-character field
    io:format("[~-12s] done~n", ["padded"]).

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:

1
2
3
4
5
6
#!/usr/bin/env escript
%% read_input.erl - Reading a line from standard input
main(_) ->
    Line = io:get_line("Enter your name: "),
    Name = string:trim(Line),
    io:format("Hello, ~s!~n", [Name]).

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/env escript
%% write_file.erl - Writing data to a file
main(_) ->
    %% A list of strings is valid iodata - written in one shot
    Lines = ["Erlang 1986\n", "Elixir 2011\n", "Gleam 2016\n"],
    ok = file:write_file("languages.txt", Lines),
    io:format("Wrote languages.txt~n"),

    %% Open in append mode and stream one more line through io:format/3
    {ok, Fd} = file:open("languages.txt", [append]),
    io:format(Fd, "Erlang/OTP 27~n", []),
    file:close(Fd),
    io:format("Appended one line~n").

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:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env escript
%% read_file.erl - Reading data back from a file
main(_) ->
    {ok, Bin} = file:read_file("languages.txt"),
    io:format("--- File contents ---~n~s", [Bin]),

    %% Split on newlines to count the lines (trim the trailing newline first)
    Lines = string:split(string:trim(Bin), "\n", all),
    io:format("Line count: ~B~n", [length(Lines)]).

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/usr/bin/env escript
%% io_errors.erl - Handling I/O errors gracefully
main(_) ->
    case file:read_file("does_not_exist.txt") of
        {ok, Bin} ->
            io:format("Read ~B bytes~n", [byte_size(Bin)]);
        {error, Reason} ->
            io:format("Could not read file: ~p~n", [Reason])
    end,
    io:format("Program continues after the error~n").

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Pull the official Erlang image
docker pull erlang:alpine

# 1. Formatted console output
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript io_operations.erl

# 2. Reading input - pipe a name in with -i (or type it at the prompt)
echo "Ada" | docker run --rm -i -v $(pwd):/app -w /app erlang:alpine escript read_input.erl

# 3. Write the file (creates languages.txt in the current directory)
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript write_file.erl

# 4. Read it back (run after step 3)
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript read_file.erl

# 5. Error handling for a missing file
docker run --rm -v $(pwd):/app -w /app erlang:alpine escript io_errors.erl

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 io and file module 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 against ok crashes 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/1 returns a binary, and modern string functions like trim/1 and split/3 work on binaries directly.
  • File handles are explicit - file:open/2 gives you a device you pass to io:format/3, and you must file:close/1 it yourself.
  • enoent and friends are POSIX error atoms - Erlang surfaces the underlying operating-system error as a readable atom you can match on or log.

Running Today

All examples can be run using Docker:

docker pull erlang:alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining