Skip to content
AgileLogics
6 min read

Working with Files

Prerequisites: Sequence expressions and laziness

File access goes through .NET's System.IO. The File class covers most needs in one call:

open System.IO

File.WriteAllText("notes.txt", "first line\nsecond line\n")
File.AppendAllText("notes.txt", "third line\n")

let content = File.ReadAllText "notes.txt"
printf "%s" content

let lines = File.ReadAllLines "notes.txt"
printfn "%d lines, last: %s" lines.Length lines[lines.Length - 1]

printfn "%b %b" (File.Exists "notes.txt") (File.Exists "missing.txt")
first line
second line
third line
3 lines, last: third line
true false

Relative paths like "notes.txt" are relative to the current directory. In a script, __SOURCE_DIRECTORY__ gives the folder the script lives in — use Path.Combine(__SOURCE_DIRECTORY__, "data.csv") to find files next to it.

Processing a CSV file

open System.IO

File.WriteAllLines("sales.csv",
    [ "region,product,amount"
      "North,Tea,120"
      "South,Coffee,340"
      "North,Coffee,90"
      "South,Tea,210" ])

type Sale = { Region: string; Product: string; Amount: int }

let parseLine (line: string) =
    match line.Split ',' with
    | [| region; product; amount |] -> Some { Region = region; Product = product; Amount = int amount }
    | _ -> None

let sales =
    File.ReadLines "sales.csv"
    |> Seq.skip 1
    |> Seq.choose parseLine
    |> List.ofSeq

sales
|> List.groupBy _.Region
|> List.map (fun (region, rows) -> region, rows |> List.sumBy _.Amount)
|> List.iter (fun (region, total) -> printfn "%s: %d" region total)

let report =
    sales
    |> List.sortByDescending _.Amount
    |> List.map (fun s -> $"{s.Product},{s.Amount}")

File.WriteAllLines("report.csv", "product,amount" :: report)
printfn "%s" (File.ReadAllText "report.csv")
North: 210
South: 550
product,amount
Coffee,340
Tea,210
Tea,120
Coffee,90

File.ReadLines is lazy — it returns a sequence that reads one line at a time, so it works on files far bigger than memory. File.ReadAllLines reads everything into an array at once.

Streams with use

For fine control, open a reader or writer. use closes it automatically, even if an exception occurs:

open System.IO

do
    use writer = new StreamWriter("log.txt")
    writer.WriteLine "started"
    writer.WriteLine "finished"

let printLines path =
    use reader = new StreamReader(path: string)
    let mutable line = reader.ReadLine()
    while not (isNull line) do
        printfn "> %s" line
        line <- reader.ReadLine()

printLines "log.txt"
> started
> finished

The do block limits the writer's scope, so the file is flushed and closed before it's read; printLines closes its reader when it returns. (At the top level of a script, use behaves like let and F# warns, so put use inside a function or a do block.) new is recommended when creating disposable objects — the compiler reminds you to use them.

Directories and paths

open System.IO

Directory.CreateDirectory "data/archive" |> ignore
File.WriteAllText("data/a.txt", "A")
File.WriteAllText("data/b.log", "B")

for f in Directory.GetFiles("data", "*.txt") do
    printfn "%s" (Path.GetFileName f)

printfn "%s" (Path.GetExtension "report.final.csv")
printfn "%s" (Path.GetFileNameWithoutExtension "report.final.csv")
printfn "%s" (Path.ChangeExtension("photo.jpeg", ".jpg"))
printfn "%d" (Directory.GetDirectories "data").Length
a.txt
.csv
report.final
photo.jpg
1

Path.Combine joins folder and file names with the right separator for the operating system — never build paths by hand with "\\" or "/".

Common mistakes

  • Reading a missing file throws FileNotFoundException. Check File.Exists, or catch the exception and return a Result.
  • Forgetting use on a stream: the file stays locked and buffered writes may never reach the disk.