I/O Operations in Elixir
Learn console output, reading input, and file handling in Elixir using pipes, streams, and tagged tuples with Docker-ready examples
Input and output are where a program meets the outside world—the terminal, files on disk, and other processes. Elixir treats I/O the way it treats everything else: as functions that transform data. There is no special syntax, just modules like IO and File whose functions you compose with the pipe operator (|>).
Because Elixir is a functional language, its I/O design leans on two idioms you will see throughout this tutorial. The first is tagged tuples: fallible operations return {:ok, value} or {:error, reason}, and you branch on them with pattern matching instead of catching exceptions. The second is a bang convention: a function ending in ! (like File.read!) skips the tuple and raises on failure, which is convenient for scripts where you’d rather crash than continue with bad data.
This tutorial covers writing to the console with more control than IO.puts, reading lines from standard input, writing and reading files, streaming large files line by line, and handling I/O errors the functional way. Every example is a runnable .exs script.
Console Output Beyond puts
You already used IO.puts for Hello World. Elixir gives you several output functions, each suited to a different job—IO.write for output without a trailing newline, IO.inspect for viewing data structures, and Erlang’s :io.format for printf-style formatting.
Create a file named console_output.exs:
| |
IO.inspect is the workhorse of Elixir debugging: because it returns its input unchanged, you can splice it into the middle of a |> chain to peek at a value without breaking the flow. The label: option prefixes the output so you can tell multiple inspects apart.
Reading Input from the Console
IO.gets/1 prints a prompt and reads one line from standard input. The returned string keeps its trailing newline, so you almost always pipe it through String.trim/1. Since input arrives as text, converting to a number is an explicit step.
Create a file named read_input.exs:
| |
Because this script waits for input, you feed it data through a pipe when running non-interactively (shown in the Docker section below). Notice how the pipe operator reads top to bottom: get a line, trim it, convert it—each function transforming the value from the one above.
Writing and Reading Files
The File module handles the filesystem. The bang variants (File.write!, File.read!) raise on failure, which keeps script code short. File.write! truncates by default; pass [:append] to add to an existing file instead.
Create a file named file_io.exs:
| |
File.read! loads the entire file into memory—fine for small files. For large files, File.stream! gives you a lazy Stream that yields one line at a time, so a multi-gigabyte log file never overwhelms memory. Here we pair it with Enum.with_index(1) to number the lines starting from 1.
Handling I/O Errors Functionally
Files may be missing, unreadable, or on a full disk. The non-bang functions (File.read, File.write) return tagged tuples so you can handle every outcome explicitly with case and pattern matching—no exceptions required.
Create a file named file_errors.exs:
| |
The reason in an error tuple is an atom like :enoent (“no such file or directory”) that comes straight from the operating system. Matching on {:ok, _} versus {:error, _} forces you to consider the failure path at compile time—the compiler warns if a case doesn’t cover the shapes it sees. This is Elixir’s alternative to try/catch: make success and failure ordinary values and let pattern matching route them.
Running with Docker
| |
The -v $(pwd):/app mount lets the file examples create poem.txt and greeting.txt in your current directory (both scripts clean up after themselves). The input example adds -i so the container keeps stdin open to receive the piped text.
Expected Output
Running console_output.exs:
Line one
Line two
No newline here... still same line
Running Elixir 1.17
doubled: [2, 4, 6]
Elixir scored 9.50
Running read_input.exs with the piped input Ada and 30 (the prompts and answers share a line because piped input isn’t echoed):
Enter your name: Hello, Ada!
Enter your age: Next year you will be 31
Running file_io.exs:
=== File contents ===
Roses are red
Violets are blue
Elixir is fun
=== Numbered lines ===
1: Roses are red
2: Violets are blue
3: Elixir is fun
Running file_errors.exs (config.txt doesn’t exist, so the first read fails):
Could not read config.txt: enoent
Write succeeded
Hi from Elixir
Key Concepts
- Tagged tuples over exceptions — Fallible I/O returns
{:ok, value}or{:error, reason}, and you branch on them withcaseand pattern matching rather than catching errors. - The bang (
!) convention —File.read!/File.write!raise on failure and return the value directly, ideal for scripts where crashing beats continuing on bad data. IO.inspectreturns its argument — Drop it anywhere in a|>pipeline to inspect a value without altering the data flow; uselabel:to tag the output.- Output functions differ by newline —
IO.putsappends a newline,IO.writedoes not, giving you precise control over formatting. :io.formatfor printf-style output — Elixir borrows Erlang’s format strings (~s,~.2f,~n) when you need aligned or precise numeric formatting.- Streams for large files —
File.stream!yields one line at a time lazily, so file size never dictates memory usage;File.read!loads everything at once and suits small files. - Input is always text —
IO.getsreturns a string with a trailing newline; trim it and convert explicitly (e.g.String.to_integer/1) before treating it as a number.
Running Today
All examples can be run using Docker:
docker pull elixir:1.17-alpine
Comments
Loading comments...
Leave a Comment