Intermediate

I/O Operations in F#

Learn console output, reading input, and file I/O in F# with formatted printing, pipelines, and functional error handling using Docker-ready examples

Input and output are where a program meets the outside world. In Hello World you saw a single printfn call; here we go further — formatting values for the console, reading input from the user, and reading and writing files.

F# is functional-first, so its I/O has a distinctive flavour. Under the hood, file I/O comes straight from the .NET System.IO namespace, which means you get the entire Base Class Library. On top of that, F# adds its own type-safe formatting functions (printfn, sprintf, eprintfn) whose format strings are checked at compile time — a mismatched %d or %s is a compiler error, not a runtime surprise.

Because I/O is inherently effectful (it reads from and writes to the world outside your program), F# treats it as ordinary code rather than hiding it behind a monad the way Haskell does. What stays functional is how you shape the data around the I/O: pipelines (|>), pattern matching on results, and try/with for errors. This tutorial shows console output, console input, file reading and writing, and error handling — all runnable in Docker.

Formatted Console Output

F#’s printf family gives you type-checked, printf-style formatting. Create a file named io_output.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Formatted console output in F#

// printfn writes a line; format specifiers are checked at compile time
printfn "Name: %s, Age: %d" "Ada" 36
printfn "Pi is approximately %.3f" 3.14159
printfn "Enabled: %b" true

// printf omits the trailing newline
printf "No newline here... "
printfn "continued on the same line"

// sprintf returns a formatted string instead of printing it
let summary = sprintf "%s scored %d%%" "Grace" 95
printfn "%s" summary

// String interpolation (F# 5+) reads naturally for simple cases
let language = "F#"
let year = 2005
printfn $"{language} first appeared in {year}"

// %A pretty-prints any value, including lists and tuples
printfn "Numbers: %A" [ 1; 2; 3 ]

Key format specifiers: %s (string), %d (integer), %f/%.3f (float with precision), %b (boolean), %A (any value, structurally), and %% for a literal percent sign. Because the compiler reads the format string, passing the wrong argument type is caught before the program ever runs.

Reading Console Input

To read from standard input, F# uses .NET’s System.Console. Create a file named io_input.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
open System

printf "What is your name? "
let name = Console.ReadLine()

printf "What year is it? "
let yearText = Console.ReadLine()

// Int32.TryParse returns a (bool, int) tuple you can pattern match on —
// idiomatic F# for parsing without exceptions
match Int32.TryParse(yearText) with
| true, year -> printfn "Hello, %s! Next year will be %d." name (year + 1)
| false, _   -> printfn "Hello, %s! I couldn't parse the year." name

Console.ReadLine() returns the next line from stdin as a string. Rather than throwing when the year isn’t a number, Int32.TryParse hands back a tuple that pattern matching destructures cleanly — a common F# alternative to try/catch for parsing.

Writing and Reading Files

The System.IO.File type provides simple one-call helpers for whole-file access. Create a file named io_files.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
open System.IO

// Write a string to a file (creates it, or overwrites if it exists)
let path = "notes.txt"
File.WriteAllText(path, "First line\nSecond line\n")

// Append without overwriting what's already there
File.AppendAllText(path, "Appended line\n")

// Write a collection of lines at once
let langs = [ "F#"; "OCaml"; "Haskell" ]
File.WriteAllLines("languages.txt", langs)

// Read the whole file back as a single string
let contents = File.ReadAllText(path)
printfn "--- notes.txt ---"
printf "%s" contents

// Read a file as an array of lines, then process it with a pipeline
let lines = File.ReadAllLines("languages.txt")
printfn "--- languages.txt (%d lines) ---" lines.Length
lines |> Array.iteri (fun i line -> printfn "%d: %s" i line)

WriteAllText and WriteAllLines handle opening, writing, and closing the file for you — there is no file handle to manage or forget to close. Once the data is in memory, F# pipelines (|>) and functions like Array.iteri let you process it in a functional style.

Handling I/O Errors

File operations can fail — a file may be missing, locked, or unreadable. F# handles these with try/with and pattern matching on exception types. Create a file named io_errors.fsx:

 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
open System.IO

// Wrap a read in try/with and match on the specific failure
let safeRead path =
    try
        let text = File.ReadAllText(path)
        printfn "Read %d characters from %s" text.Length path
    with
    | :? FileNotFoundException ->
        printfn "File not found: %s" path
    | :? IOException as ex ->
        printfn "I/O error reading %s: %s" path ex.Message

// Create a file that exists, then read both it and a missing one
File.WriteAllText("present.txt", "hello")
safeRead "present.txt"
safeRead "missing.txt"

// Or guard with File.Exists and stay in a pure pipeline
let describe path =
    if File.Exists(path) then sprintf "%s exists" path
    else sprintf "%s is missing" path

[ "present.txt"; "missing.txt" ]
|> List.map describe
|> List.iter (printfn "%s")

The :? FileNotFoundException syntax is a type-test pattern: it matches when the caught exception is of that type. FileNotFoundException is a subtype of IOException, so listing the more specific case first ensures it takes priority. When you’d rather avoid exceptions entirely, File.Exists lets you branch before touching the disk.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the official .NET SDK image
docker pull mcr.microsoft.com/dotnet/sdk:9.0

# Run the formatted output example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi io_output.fsx

# Run the file read/write example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi io_files.fsx

# Run the error-handling example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi io_errors.fsx

# The input example reads from stdin, so add -i and pipe the answers in
printf 'Ada\n2026\n' | docker run --rm -i -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi io_input.fsx

Expected Output

Running io_output.fsx:

Name: Ada, Age: 36
Pi is approximately 3.142
Enabled: true
No newline here... continued on the same line
Grace scored 95%
F# first appeared in 2005
Numbers: [1; 2; 3]

Running io_input.fsx with the piped answers Ada and 2026 (the prompts print without a newline, and piped input is not echoed):

What is your name? What year is it? Hello, Ada! Next year will be 2027.

Running io_files.fsx:

--- notes.txt ---
First line
Second line
Appended line
--- languages.txt (3 lines) ---
0: F#
1: OCaml
2: Haskell

Running io_errors.fsx:

Read 5 characters from present.txt
File not found: missing.txt
present.txt exists
missing.txt is missing

Key Concepts

  • Type-checked formattingprintfn, printf, and sprintf validate their format strings at compile time, so a %d/%s mismatch fails to build rather than misbehaving at runtime.
  • printf vs printfn vs sprintfprintf omits the trailing newline, printfn adds one, and sprintf returns the formatted string instead of printing it.
  • String interpolation$"{value}" (F# 5+) is a readable alternative to format specifiers for straightforward cases.
  • File I/O is .NET I/OSystem.IO.File helpers like ReadAllText, WriteAllText, WriteAllLines, and AppendAllText handle opening and closing for you, so there are no handles to leak.
  • Parse without exceptionsInt32.TryParse returns a (bool, int) tuple that pattern matching destructures, a common F# alternative to try/catch.
  • Errors via try/with — the :? ExceptionType type-test pattern lets you match specific failures like FileNotFoundException; list more specific exception types before their base types.
  • Pipelines around effects — I/O itself is effectful, but F# keeps the surrounding data transformations functional with |>, List.map, and Array.iteri.

Running Today

All examples can be run using Docker:

docker pull mcr.microsoft.com/dotnet/sdk:9.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining