Intermediate

I/O Operations in OCaml

Learn input and output in OCaml - formatted console output with Printf, reading from stdin, file reading and writing with channels, and exception-based error handling

Input and output are where OCaml’s functional core meets the messy, stateful outside world. Unlike Haskell, OCaml does not wrap I/O in a monad - it takes a pragmatic, multi-paradigm stance. I/O functions like print_endline and input_line are ordinary functions that perform side effects and return unit (or a value read from the outside), and you sequence them with the humble semicolon.

OCaml’s I/O model is built around channels: an out_channel for writing and an in_channel for reading. The standard channels stdout, stderr, and stdin are always available, and you open your own channels for files. Layered on top of raw channel functions is the Printf module, which provides type-safe, C-style formatted output - the format string is checked at compile time, so %d really does require an int.

In this tutorial you will go beyond the single print_endline from Hello World: formatted output with Printf, reading interactive input from stdin, writing and reading files through channels, and handling the exceptions (End_of_file, Sys_error) that I/O naturally raises. Because errors are values and exceptions in OCaml, you will see how option types and try ... with combine to make I/O safe.

Formatted Console Output

The Printf module gives you format specifiers with full type checking. The compiler reads the format string and requires arguments of matching types.

Create a file named output.ml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
let () =
  (* Printf with typed format specifiers *)
  Printf.printf "Integer:  %d\n" 42;
  Printf.printf "Float:    %.2f\n" 3.14159;
  Printf.printf "String:   %s\n" "OCaml";
  Printf.printf "Char:     %c\n" 'A';
  Printf.printf "Bool:     %b\n" true;
  Printf.printf "Hex:      %x\n" 255;

  (* Combine several values in one format string *)
  Printf.printf "%s is %d years old\n" "OCaml" 29;

  (* sprintf builds a string instead of printing it *)
  let summary = Printf.sprintf "%d + %d = %d" 2 3 5 in
  print_endline summary;

  (* Width and alignment: %-10s left-justifies in a 10-char field *)
  Printf.printf "|%-10s|%5d|\n" "left" 7

The format string is not just a string - it is a special format type. This is why Printf.printf "%d" "oops" fails to compile: the %d demands an int, and the type checker enforces it before your program ever runs.

Reading Console Input

Reading from stdin uses read_line (returns a string) and helpers like read_int (reads a line and parses it as an int). These functions perform side effects and return the value read.

Create a file named input.ml:

1
2
3
4
5
6
7
8
9
let () =
  print_string "Enter your name: ";
  let name = read_line () in

  print_string "Enter your age: ";
  let age = read_int () in

  Printf.printf "Hello, %s!\n" name;
  Printf.printf "Next year you will be %d\n" (age + 1)

read_line () returns the next line from stdin without its trailing newline. read_int () reads a line and converts it with int_of_string, raising Failure if the text is not a valid integer. Note the () argument: these are functions that take unit, forcing the read to happen when called rather than when defined.

Writing and Reading Files

File I/O opens a channel, works with it, then closes it. open_out creates (or truncates) a file for writing; open_in opens one for reading. Reading line by line pairs naturally with pattern matching, including OCaml’s exception patterns that let you match End_of_file right inside a match.

Create a file named file_io.ml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
(* Write a small CSV-style file *)
let write_file filename =
  let oc = open_out filename in
  Printf.fprintf oc "Apples,10\n";
  Printf.fprintf oc "Bananas,5\n";
  Printf.fprintf oc "Cherries,20\n";
  close_out oc

(* Read every line into a list, closing the channel at end of file *)
let read_file filename =
  let ic = open_in filename in
  let rec loop acc =
    match input_line ic with
    | line -> loop (line :: acc)
    | exception End_of_file ->
        close_in ic;
        List.rev acc
  in
  loop []

let () =
  let filename = "inventory.csv" in
  write_file filename;
  Printf.printf "Wrote %s\n" filename;

  let lines = read_file filename in
  Printf.printf "Read %d lines:\n" (List.length lines);
  List.iter (fun line -> Printf.printf "  %s\n" line) lines;

  (* Parse each line and total the quantities *)
  let total =
    List.fold_left
      (fun acc line ->
        match String.split_on_char ',' line with
        | [_; qty] -> acc + int_of_string qty
        | _ -> acc)
      0 lines
  in
  Printf.printf "Total items: %d\n" total

The | exception End_of_file -> branch is idiomatic OCaml: input_line raises End_of_file when there is nothing left to read, and the exception pattern handles it as cleanly as a normal case. Combining the read with List.fold_left shows how imperative I/O feeds directly into functional data processing.

Handling I/O Errors Safely

I/O can fail: a file may not exist, or a disk may be full. OCaml signals these with exceptions such as Sys_error. A common pattern is to wrap the risky operation in try ... with and return an option, turning a possible failure into a value the caller must handle.

Create a file named io_errors.ml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(* Read a whole file, returning None if it cannot be opened *)
let read_file_safe filename =
  try
    let ic = open_in filename in
    let content = In_channel.input_all ic in
    close_in ic;
    Some content
  with Sys_error msg ->
    Printf.eprintf "Error: %s\n" msg;
    None

let () =
  (* Attempt to read a file that does not exist *)
  (match read_file_safe "does_not_exist.txt" with
   | Some content -> Printf.printf "Content: %s\n" content
   | None -> print_endline "Could not read the file");

  (* Now write a real file and read it back *)
  let oc = open_out "greeting.txt" in
  output_string oc "Hello from a safe read!";
  close_out oc;

  match read_file_safe "greeting.txt" with
  | Some content -> Printf.printf "Content: %s\n" content
  | None -> print_endline "Could not read the file"

In_channel.input_all reads an entire channel into a single string. The Sys_error from the missing file is caught, reported on stderr with Printf.eprintf, and converted to None - so the program continues instead of crashing. Returning option forces the caller to explicitly handle the failure case via pattern matching.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the official OCaml image
docker pull ocaml/opam:alpine

# Formatted console output
docker run --rm -v $(pwd):/home/opam/app -w /home/opam/app ocaml/opam:alpine ocaml output.ml

# Console input (pipe input in with printf, and add -i for stdin)
printf 'Alice\n30\n' | docker run --rm -i -v $(pwd):/home/opam/app -w /home/opam/app ocaml/opam:alpine ocaml input.ml

# Writing and reading files
docker run --rm -v $(pwd):/home/opam/app -w /home/opam/app ocaml/opam:alpine ocaml file_io.ml

# Safe error handling
docker run --rm -v $(pwd):/home/opam/app -w /home/opam/app ocaml/opam:alpine ocaml io_errors.ml

The volume mount means files written inside the container (like inventory.csv and greeting.txt) appear in your current directory after the run.

Expected Output

Running output.ml:

Integer:  42
Float:    3.14
String:   OCaml
Char:     A
Bool:     true
Hex:      ff
OCaml is 29 years old
2 + 3 = 5
|left      |    7|

Running input.ml with the piped input Alice and 30:

Enter your name: Enter your age: Hello, Alice!
Next year you will be 31

(The prompts appear before the answers because buffered stdout is flushed together at exit; the piped input is consumed, not echoed.)

Running file_io.ml:

Wrote inventory.csv
Read 3 lines:
  Apples,10
  Bananas,5
  Cherries,20
Total items: 35

Running io_errors.ml:

Error: does_not_exist.txt: No such file or directory
Could not read the file
Content: Hello from a safe read!

Key Concepts

  • Channels are the foundation: out_channel and in_channel model output and input, with stdout, stderr, and stdin always available and open_out/open_in for files.
  • Printf is type-safe: format strings are checked at compile time, so %d requires an int and %s requires a string - a mismatch is a compile error, not a runtime surprise.
  • I/O returns unit: side-effecting output functions return (), and you sequence them with semicolons rather than a monad.
  • Reading takes unit: functions like read_line () and read_int () take a () argument so the read happens when called, and read_int can raise Failure on bad input.
  • Exceptions signal end and failure: input_line raises End_of_file, and file operations raise Sys_error; OCaml’s | exception ... -> patterns handle these directly inside a match.
  • Convert exceptions to option: wrapping risky I/O in try ... with and returning Some/None makes failure a value the caller must handle explicitly.
  • Always close channels: pair open_out with close_out and open_in with close_in to flush buffers and release file handles.
  • I/O feeds functional pipelines: imperative reads combine naturally with List.fold_left, List.iter, and String.split_on_char for processing the data you read.

Running Today

All examples can be run using Docker:

docker pull ocaml/opam:alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining