Learn OCaml
OCaml is a practical, industrial-strength functional language with a famously strong static type system, full type inference, algebraic data types, and exhaustive pattern matching. It compiles to fast native code, has a REPL for exploration, and ships with first-class modules and a powerful module system. OCaml 5 added shared-memory parallelism via domains and the first mainstream implementation of effect handlers. This OCaml track walks you from a working toolchain (opam + dune) through types, functions, collections, robust error handling, and OCaml 5's concurrency story, ending with the everyday ecosystem you will actually use. Every lesson links to the official docs so you can go deeper.
Setup and the Toolchain
Install opam, pin a compiler, and build your first project with dune.
OCaml's toolchain is built around three tools you will use every day: opam (the package manager and compiler-version switch manager), dune (the build system), and utop (an enhanced REPL). This OCaml lesson gets all three working.
Installing opam and a compiler
opam manages switches - isolated installations, each tied to a specific compiler version. On Linux or macOS the recommended bootstrap is:
bash -c "sh <(curl -fsSL https://opam.ocaml.org/install.sh)"
opam init --bare -a -y
opam switch create default 5.4.1
eval $(opam env)
The opam switch create line pins the compiler. As of early 2026 the latest stable release is OCaml 5.4.1 (released 2026-02-17), with 5.5.0 imminent. OCaml has followed a time-based, six-month release cadence since version 4.03, so a new minor release lands roughly every spring and autumn. Running eval $(opam env) wires the switch's binaries into your current shell; many people add it to their shell profile so they never have to prefix commands with opam exec --.
Install the everyday tools into the switch:
opam install dune utop ocaml-lsp-server odoc ocamlformat
ocaml-lsp-server powers editor integration (VS Code's OCaml Platform extension, Neovim, Emacs), ocamlformat auto-formats code, and odoc generates HTML documentation from your source comments. You can install these into any switch and they stay isolated to that switch, so different projects never fight over tool versions.
Your first project
dune scaffolds projects for you. Create one:
dune init proj hello
cd hello
This produces bin/, lib/, and test/ directories plus dune-project. The entry point bin/main.ml contains:
let () = print_endline "Hello, World!"
let () = ... is the OCaml idiom for a top-level side-effecting statement: the expression on the right must have type unit (written ()), so the compiler checks you are not silently discarding a value. Build and run it:
dune build
dune exec hello
You should see Hello, World!. Use watch mode while developing so the project recompiles on every save:
dune build -w
The REPL
For quick experiments, launch utop. To get a REPL that already knows about your project's libraries, run:
dune utop
In the REPL, every phrase ends with a double semicolon ;;, which is only needed at the top level of the REPL, not in .ml files:
# 1 + 2 * 3;;
- : int = 7
# let square x = x * x;;
val square : int -> int = <fun>
# square 9;;
- : int = 81
Notice the REPL prints the inferred type of every result. This feedback loop - type something, see its type - is the fastest way to learn OCaml's type system.
File and module conventions
A .ml file is an implementation; an optional .mli file of the same name is its interface (it lists the exported values and types, hiding the rest). Each file automatically defines a module named after the file with a capitalized first letter: foo.ml becomes module Foo. dune wraps a library's modules under a namespace by default, so you reference Hello.Foo from outside.
Follow along with the official getting-started guide, which uses exactly this dune-based workflow: https://ocaml.org/docs/your-first-program
Syntax, Types, and Type Inference
Bindings, the core type system, type inference, and immutability.
OCaml is statically typed but you rarely write type annotations - the compiler infers almost everything via Hindley-Milner type inference. This OCaml lesson covers the syntax and the type vocabulary you need.
Bindings with let
let binds a name to a value. By default bindings are immutable:
let x = 42
let name = "Ada"
let pi = 3.14159
A local binding uses let ... in:
let area r =
let pi = 3.14159 in
pi *. r *. r
Note *. - OCaml does not overload arithmetic operators across types. Integers use + - * /; floats use +. -. *. /.. Mixing them is a type error, which catches a whole class of bugs but surprises newcomers.
The base types
The primitive types are int, float, bool, char, string, and unit (the type with a single value (), used where other languages would use void). OCaml's int is 63-bit on 64-bit platforms (one bit is reserved by the runtime).
Tuples, records, and variants
Tuples group a fixed number of values of possibly different types:
let point = (3, 4) (* int * int *)
let (a, b) = point (* destructuring *)
Records are named-field products; you declare the type first:
type person = { name : string; age : int }
let ada = { name = "Ada"; age = 36 }
let () = print_endline ada.name
The most important OCaml feature is the variant (algebraic data type / sum type), which models "one of several shapes":
type shape =
| Circle of float
| Rectangle of float * float
| Point
let area s =
match s with
| Circle r -> 3.14159 *. r *. r
| Rectangle (w, h) -> w *. h
| Point -> 0.0
match does exhaustive pattern matching: if you forget a constructor, the compiler warns you. This is the single biggest reason OCaml refactors are safe - add a new variant and the compiler lists every match that needs updating.
Polymorphism and type variables
Identifiers like 'a are type variables - they make a definition generic:
let identity x = x
(* val identity : 'a -> 'a *)
let pair_up x = (x, x)
(* val pair_up : 'a -> 'a * 'a *)
The built-in option type uses this to model "maybe a value":
type 'a option = None | Some of 'a
Inference in action
You almost never annotate types, but you can:
let greet (name : string) : string = "Hello, " ^ name
The ^ operator concatenates strings. Drop the annotations and the inferred type is identical - annotations are documentation and a way to constrain inference, not a requirement. When inference produces a type you did not expect, that is usually a real bug being surfaced early, which is why experienced OCaml programmers treat a surprising inferred type as a signal to stop and reconsider rather than to add an annotation.
The official tour of the language covers all of this with runnable examples: https://ocaml.org/docs/tour-of-ocaml
Functions, Recursion, and Pattern Matching
Currying, higher-order functions, recursion, and rich patterns.
Functions are the heart of OCaml. They are first-class values, they are curried by default, and they pair naturally with pattern matching. This OCaml lesson makes you fluent.
Defining functions
let add x y = x + y
let () = Printf.printf "%d\n" (add 2 3) (* 5 *)
The inferred type is val add : int -> int -> int. That arrow chain is not cosmetic: OCaml functions are curried, so add actually takes one argument and returns a function expecting the next. This means partial application is free:
let add10 = add 10
let () = Printf.printf "%d\n" (add10 5) (* 15 *)
Anonymous functions use fun:
let double = fun x -> x * 2
Higher-order functions
Because functions are values, you pass them around freely:
let apply_twice f x = f (f x)
let () = Printf.printf "%d\n" (apply_twice double 3) (* 12 *)
The pipe operator |> feeds a value into a function, letting you read transformations left to right:
let result =
[1; 2; 3; 4; 5]
|> List.map (fun x -> x * x)
|> List.filter (fun x -> x > 5)
(* result = [9; 16; 25] *)
There is also @@ for right-associative application, so f @@ g @@ x means f (g x) and saves parentheses.
Recursion
Ordinary let cannot refer to itself; recursive functions need let rec:
let rec factorial n =
if n <= 1 then 1
else n * factorial (n - 1)
Deep recursion can overflow the stack, so for big inputs prefer tail recursion, where the recursive call is the last thing the function does. The compiler turns tail calls into loops:
let factorial n =
let rec go acc n =
if n <= 1 then acc
else go (acc * n) (n - 1)
in
go 1 n
Mutually recursive functions are joined with and:
let rec is_even n = if n = 0 then true else is_odd (n - 1)
and is_odd n = if n = 0 then false else is_even (n - 1)
Pattern matching is everywhere
match patterns can be nested, can bind variables, and support guards with when:
let classify n =
match n with
| 0 -> "zero"
| n when n < 0 -> "negative"
| n when n mod 2 = 0 -> "positive even"
| _ -> "positive odd"
The wildcard _ matches anything. You can also match directly in function, which is sugar for a one-argument fun followed by match:
let describe = function
| None -> "nothing"
| Some x -> "got " ^ string_of_int x
Pattern matching also destructures records and tuples in let:
let ({ name; _ } : person) = ada
let (q, r) = (17 / 5, 17 mod 5)
Master match and you have mastered most of idiomatic OCaml. Work through the official functions guide for more: https://ocaml.org/docs/values-and-functions
Collections: Lists, Arrays, Maps, and Sequences
The everyday data structures and their standard-library APIs.
OCaml's standard library (the Stdlib module, opened automatically) gives you immutable lists, mutable arrays, balanced-tree maps and sets, hash tables, and lazy sequences. This OCaml lesson surveys the ones you reach for daily.
Lists
Lists are immutable singly-linked lists - the default sequential collection. Literal syntax uses semicolons; the cons operator :: prepends; @ appends:
let xs = [1; 2; 3]
let ys = 0 :: xs (* [0; 1; 2; 3] *)
let zs = xs @ [4; 5] (* [1; 2; 3; 4; 5] *)
Process lists with pattern matching or the List module:
let rec sum = function
| [] -> 0
| h :: t -> h + sum t
let doubled = List.map (fun x -> x * 2) [1; 2; 3] (* [2; 4; 6] *)
let evens = List.filter (fun x -> x mod 2 = 0) xs (* [2] *)
let total = List.fold_left ( + ) 0 [1; 2; 3] (* 6 *)
Note that :: is O(1) but @ is O(n) in the length of its left argument, so build lists by consing and reverse at the end rather than repeatedly appending. Many List functions also have safe _opt variants (List.nth_opt, List.find_opt) that return None instead of raising.
Arrays
When you need O(1) indexed access or in-place mutation, use arrays, written [| ... |]:
let a = [| 10; 20; 30 |]
let () = a.(0) <- 99 (* mutate index 0 *)
let first = a.(0) (* read; raises if out of bounds *)
let n = Array.length a
let b = Array.map (fun x -> x + 1) a
Maps and Sets
Map and Set are immutable balanced binary trees built from a functor - you instantiate them for a key type by passing a comparison module:
module StringMap = Map.Make (String)
let m =
StringMap.empty
|> StringMap.add "one" 1
|> StringMap.add "two" 2
let v = StringMap.find_opt "one" m (* Some 1 *)
This is a first taste of OCaml's module system: Map.Make is a functor (a module that takes a module and returns a module), and String supplies the required compare function.
For unordered mutable key/value storage with average O(1) lookup, use Hashtbl:
let h = Hashtbl.create 16
let () = Hashtbl.replace h "key" 42
let got = Hashtbl.find_opt h "key" (* Some 42 *)
Sequences
Seq.t is a lazy, pull-based sequence - useful for pipelines and potentially infinite streams without building intermediate lists:
let first_five_squares =
Seq.ints 1
|> Seq.map (fun x -> x * x)
|> Seq.take 5
|> List.of_seq
(* [1; 4; 9; 16; 25] *)
Choosing well - immutable lists for most logic, arrays for hot numeric loops, maps/sets for keyed lookups, Seq for lazy pipelines - is most of practical OCaml performance. One more habit worth forming early: prefer the _opt variants of lookup functions over the raising ones, so a missing element becomes a None you must handle rather than an exception that escapes at runtime. The full standard-library reference for lists is here: https://ocaml.org/manual/5.4/api/List.html
Error Handling: Options, Results, and Exceptions
Model failure in the types with option and Result, and use exceptions wisely.
OCaml gives you two complementary styles for failure: exceptions (fast, built-in, but invisible to the type system) and typed errors via the option and Result types (visible, composable). This OCaml lesson covers all three and when to use each.
Exceptions
Exceptions are values of the extensible type exn. Raise with raise, catch with try ... with:
exception Not_found_in_db of string
let lookup id =
if id < 0 then raise (Not_found_in_db "negative id")
else id * 10
let safe id =
try lookup id with
| Not_found_in_db msg -> Printf.eprintf "error: %s\n" msg; 0
The stdlib provides shortcuts failwith "msg" (raises Failure) and invalid_arg "msg" (raises Invalid_argument). To guarantee cleanup runs whether or not an exception fires, use Fun.protect:
Fun.protect ~finally:(fun () -> close_chan ()) (fun () -> do_work ())
Exceptions are appropriate for truly exceptional, programmer-error conditions and for performance-sensitive control flow. They are not tracked by the type system, so a caller cannot tell from a signature that a function might throw.
The option type
For "a value may be absent," return option:
let div_opt m n = if n = 0 then None else Some (m / n)
let () =
match div_opt 10 2 with
| Some r -> Printf.printf "%d\n" r
| None -> print_endline "undefined"
Compose options without nesting matches using Option.map and Option.bind. The binding operator let* makes chains read like ordinary code:
let ( let* ) = Option.bind
let combine a b =
let* x = div_opt 100 a in
let* y = div_opt x b in
Some (x + y)
If any step yields None, the whole chain short-circuits to None.
The Result type
When you also need to carry why something failed, use the built-in result type:
type ('a, 'b) result = Ok of 'a | Error of 'b
let parse s =
match int_of_string_opt s with
| Some n -> Ok n
| None -> Error (Printf.sprintf "not an int: %s" s)
let ( let* ) = Result.bind
let add_strings a b =
let* x = parse a in
let* y = parse b in
Ok (x + y)
(* add_strings "3" "4" = Ok 7 ; add_strings "x" "4" = Error "not an int: x" *)
Result.map, Result.map_error, and Result.bind are the core combinators, and the same let* operator pattern applies. Because the error type appears in the signature, callers cannot forget to handle it - the compiler forces an exhaustive match.
Choosing a style
A common rule of thumb: use option/Result at API boundaries and for expected failures (bad input, missing key, parse error), so failure is visible and composable; reserve exceptions for genuinely unexpected situations and internal invariants. The payoff of the typed style is that the compiler refuses to let a caller ignore a possible failure, turning a category of runtime crashes into compile-time errors you fix once. You can always bridge the two with Option.to_result, Result.to_option, or by wrapping an exception-raising call in try ... with e -> Error e.
The official error-handling guide goes deeper, including effect-based approaches: https://ocaml.org/docs/error-handling
OCaml 5: Effects, Domains, and Parallelism
Shared-memory parallelism with domains and concurrency via effect handlers.
OCaml 5.0 (2022) brought two landmark features to the multicore story: domains for true shared-memory parallelism, and effect handlers - the first mainstream language to ship them - for structured concurrency. This OCaml lesson introduces both at a practical level.
Domains: parallelism
Before OCaml 5, the runtime had a global lock and could not run OCaml code on multiple cores at once. OCaml 5 removed that limit. A domain is a unit of parallelism that maps to an OS thread; you spawn one with Domain.spawn and wait for it with Domain.join:
let () =
let d = Domain.spawn (fun () ->
let s = ref 0 in
for i = 1 to 1_000_000 do s := !s + i done;
!s)
in
let main_sum = 42 in
let other = Domain.join d in
Printf.printf "%d %d\n" main_sum other
Domains are heavyweight - they correspond to real OS threads and are relatively expensive to create - so the guidance is to spawn roughly as many domains as you have cores and reuse them, not to spawn one per task. For data-parallel workloads you normally do not touch Domain directly; you use Domainslib, which provides a task pool and parallel primitives like parallel_for:
(* requires the domainslib library *)
let pool = Domainslib.Task.setup_pool ~num_domains:3 ()
let () =
Domainslib.Task.run pool (fun () ->
Domainslib.Task.parallel_for pool ~start:0 ~finish:99
~body:(fun i -> compute i))
let () = Domainslib.Task.teardown_pool pool
When domains share mutable state you must synchronize; OCaml 5 follows a memory model and offers Mutex, Condition, and atomics in Atomic, plus ecosystem libraries such as Saturn for lock-free data structures and Kcas for software transactional memory.
Effect handlers: concurrency
Concurrency (interleaving tasks) is distinct from parallelism (running simultaneously). OCaml 5 expresses concurrency with effect handlers, a structured generalization of exceptions where a computation can perform an effect, and a surrounding handler decides how to resume it. Crucially, a handler receives a continuation k and can resume it, enabling cooperative scheduling without callbacks or monads:
open Effect
open Effect.Deep
type _ Effect.t += Yield : unit Effect.t
let rec task name n =
if n > 0 then begin
Printf.printf "%s: %d\n" name n;
perform Yield;
task name (n - 1)
end
A scheduler installs a handler that captures the continuation k on each Yield and runs tasks round-robin. You rarely write raw effect handlers in application code; instead you use them through Eio, the modern effects-based I/O and concurrency library that replaces callback- and Lwt-style code with direct, sequential-looking control flow that still runs concurrently. Async/Lwt remain widely used and interoperate.
Why this matters
The split is deliberate: domains give you parallelism across cores, effect handlers give you concurrency within a domain, and they compose. Effects also make OCaml's concurrency non-colored - there is no async/await infection of function signatures, because performing an effect looks like an ordinary call. In practice this means you can write a single function and run it sequentially, concurrently under Eio, or in parallel across domains, without rewriting its body - the choice lives in the surrounding handler and scheduler rather than in every signature along the way.
The canonical reference is the multicore/parallel-programming tutorial maintained by the OCaml multicore team: https://github.com/ocaml-multicore/parallel-programming-in-multicore-ocaml
Ecosystem and Everyday Tooling
opam, dune, testing, formatting, docs, and where to find libraries.
Knowing the language is half the job; this OCaml lesson covers the tools and libraries that make OCaml productive day to day.
opam: packages and switches
opam is both a package manager and a compiler-version manager. Common commands:
opam update # refresh the package index
opam install lwt cmdliner # install libraries into the current switch
opam list # what is installed
opam switch list # all switches
opam switch create myproj 5.4.1 # a project-local compiler
A switch gives each project an isolated set of dependencies and its own compiler, so two projects can use different OCaml versions without conflict. As of early 2026 the current opam line is 2.5.x. Browse packages at https://ocaml.org/packages - there are thousands, with online documentation built automatically.
dune: the build system
Nearly every modern OCaml project uses dune. You describe components in small dune files written as S-expressions. A library and an executable might look like:
; lib/dune
(library (name mylib) (libraries str))
; bin/dune
(executable (name main) (libraries mylib cmdliner))
dune handles dependency ordering, incremental and parallel builds, running tests, and building docs. Key commands: dune build, dune exec <name>, dune test, dune build @doc, and dune build -w for watch mode. The dune line as of mid-2026 is 3.23.x (released May 2026).
Formatting and editor support
ocamlformat enforces a consistent style; add an .ocamlformat file to your project root and run dune fmt (or dune build @fmt --auto-promote). For editors, install ocaml-lsp-server and use the OCaml Platform extension in VS Code, or the LSP client in Neovim/Emacs - you get type-on-hover, jump-to-definition, inline errors, and completion.
Testing
The lightweight, idiomatic approach is inline expectation tests with ppx_expect, plus dune test:
let%expect_test "adds" =
Printf.printf "%d" (2 + 3);
[%expect {| 5 |}]
dune runs these and fails the build if output drifts; --auto-promote updates the expected blocks. For property-based testing reach for qcheck, and for classic unit tests alcotest or ounit2 are popular.
Documentation
Document values with special (** ... *) comments and build HTML with odoc via dune build @doc. The generated docs match what you see on the package site, so writing them pays off twice.
Notable libraries
A few you will meet quickly: cmdliner (command-line parsing), lwt and eio (concurrency/I/O), yojson (JSON), cohttp (HTTP), dream (web framework), core (Jane Street's batteries-included standard-library replacement), and ppxlib (for writing syntax extensions). The ppx ecosystem - compile-time code generators like ppx_deriving for auto-generating show/eq/compare - is a defining part of practical OCaml. A typical service might combine dream or cohttp for HTTP, yojson for serialization, cmdliner for its command-line front end, and eio for concurrency, all built and tested with a handful of small dune files.
A practical first project ties all of this together: scaffold with dune init proj, add a dependency or two with opam install, write a couple of ppx_expect tests, wire up ocamlformat, and let the LSP server give you live types as you edit. Once that loop feels natural you have the full OCaml workflow in hand. The official platform overview ties the toolchain together and is the best next stop: https://ocaml.org/docs/platform