OCaml Module 12 – Effects, concurrency and parallelism (OCaml 5)
Effect handlers
Declare OCaml 5 effects, perform them and answer them with match … with effect, then meet Effect.Unhandled, one-shot continuations and Effect.Deep.match_with.
What you will learn
- Declare an effect by extending Effect.t, and perform it from code that does not know who answers
- Handle an effect with the match … with effect syntax and resume the suspended code with continue
- Predict where Effect.Unhandled is raised when no handler covers a performed effect
- Explain why a continuation can be resumed only once, and end one with discontinue so its cleanup runs
- Compare the OCaml 5.3 syntax with the Effect.Deep.match_with record API, and write a handler in each
Before you start
What you’ll learn · Before you start
What you will learn
- Declare an effect by extending Effect.t, and perform it from code that does not know who answers
- Handle an effect with the match … with effect syntax and resume the suspended code with continue
- Predict where Effect.Unhandled is raised when no handler covers a performed effect
- Explain why a continuation can be resumed only once, and end one with discontinue so its cleanup runs
- Compare the OCaml 5.3 syntax with the Effect.Deep.match_with record API, and write a handler in each
Before you start
On this page
A report that draws a 32-column rule needs two numbers, a page width and a margin, yet only the function four calls down reads them. Passing them as parameters changes the three functions in between, and a global variable gives the whole program one width at a time. An effect: An operation declared by adding a constructor to OCaml's extensible type 'a Effect.t and started with Effect.perform: the code that performs it stops until a handler supplies an answer of type 'a. removes both costs: the deep function performs a question, OCaml suspends it, and an effect handler: Code that runs a computation and receives each effect it performs together with the continuation, then decides what to do: resume it with continue, end it with discontinue, or neither. OCaml 5.3 writes one as effect cases in a match. written around the call supplies the answer and lets the function carry on from where it stopped.
You will declare an effect, perform it and handle it with the match … with effect syntax of OCaml 5.3, then see
what happens when nobody answers, when an answer is given twice and when the waiting code is never resumed, and write
the same handler with the older Effect.Deep.match_with record. Each program was compiled and run with OCaml 5.5.1,
and the page shows what it printed, errors included.
An effect lets deep code ask, and its caller answer
report.ml builds the header of a sales report in four layers: report calls header, header calls rule, and
rule calls usable_width, the only function that needs the page settings. It asks for each one with
perform (Ask "width") and perform (Ask "margin") and never says where the answers come from. The last function of
the file, with_settings, runs a computation and answers every Ask from a table, and the program runs the same
report under two tables:
from session import run, write
# One report, four functions deep, needs two page settings. Only the innermost function asks for them, with the
# effect Ask; the handler around the call answers. The same report then runs under two handlers, with no parameter
# passed down and no global variable.
write("report.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
(* The only function that needs the settings. *)
let usable_width () =
let width = perform (Ask "width") in
let margin = perform (Ask "margin") in
width - 2 * margin
(* Three callers in between, none of them mentions the settings. *)
let rule () = String.make (usable_width ()) '-'
let header title =
let line = rule () in
Printf.sprintf "%s (%d columns)\\n%s" title (String.length line) line
let report () = print_endline (header "Monthly sales")
(* Runs f and answers every Ask it performs from a table. *)
let with_settings (settings : (string * int) list) f =
match f () with
| v -> v
| effect (Ask name), k -> continue k (List.assoc name settings)
let () =
with_settings [ ("width", 40); ("margin", 4) ] report;
with_settings [ ("width", 24); ("margin", 2) ] report
""")
run("""
ocamlopt report.ml -o report
./report
""") Output
$ ocamlopt report.ml -o report $ ./report Monthly sales (32 columns) -------------------------------- Monthly sales (20 columns) --------------------
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 inject_session.py
The first run answered 40 and 4 and drew a rule of 40 − 2 × 4 = 32 columns; the second answered 24 and 2 and drew
20. Neither rule, header nor report mentions a width, and no variable outside the handlers changed. That is
the job an effect does: the code that needs a value asks for it, and the code that starts the work decides the
answer, much as a parameter would, without passing through every function in between.
The mechanism will look familiar from exceptions. raise stops the
current function and the nearest enclosing handler with a matching case takes over. perform stops the current
function too, with one difference that changes what a handler can do. An
exception handler: A try-with or match-with-exception clause that intercepts an exception and decides what to do with it instead of letting it propagate. More in “Exceptions and resource cleanup” can only take over from code that has been abandoned, while an
effect handler also receives the rest of the stopped computation and can send it back to work with an answer. The
manual calls effect handlers a generalisation of exception handlers, and the table sets the two side by side:
| An exception | An effect | |
|---|---|---|
| Declared as | exception Missing of string |
type _ Effect.t += Ask : string -> int Effect.t |
| Started with | raise (Missing "width") |
perform (Ask "width") |
| Caught by | try … with Missing n -> … |
match … with effect (Ask n), k -> … |
| The handler receives | the exception’s value | the effect’s value and the continuation k |
| Can the stopped code go on? | No, it has been left for good | Yes: continue k 40 resumes it with 40 |
The last row is the whole difference, and the rest of the lesson is about it: what k holds, what continue gives
back, and what happens when k is used twice or never.
Key idea
An effect is a question that deep code asks and its caller answers: the function that needs a value performs it, and the handler around the call supplies the value and lets the function continue.
Checkpoint
Check: Effects let a caller answer deep code
An effect is declared as a constructor, then performed
An effect is a value of the type 'a Effect.t, where 'a is the type of its answer. Effect.t is an
A variant type declared with = .. whose constructors can be added later, anywhere in a program, with +=. The type of exceptions, exn, is one, and so is Effect.t.,
like the type of exceptions, exn, and it is a GADT (generalised algebraic data type): A variant whose constructors can each give a different return type on the right of a colon, which pattern matching then refines; the index tells OCaml which arms are reachable for which value. More in “Generalised algebraic data types (GADTs)”: each constructor names its own answer
type after its last arrow. Load the declarations and functions of a small file into the toplevel and read the types
it prints:
from session import toplevel, write
# Declaring an effect adds a constructor to the extensible GADT Effect.t; the type after the last arrow is the answer
# the performer gets back. A function that performs an effect has an ordinary type: nothing in it names the effect.
# The last two phrases are mistakes the compiler catches: an effect used as the wrong type, and a declaration without
# the type parameter of Effect.t.
write("widths.ml", """type _ Effect.t += Ask : string -> int Effect.t
type _ Effect.t += Log : string -> unit Effect.t
let usable_width () =
let width = Effect.perform (Ask "width") in
let margin = Effect.perform (Ask "margin") in
width - 2 * margin
let logged_width () =
Effect.perform (Log "usable_width asked");
usable_width ()
""")
toplevel("""
#use "widths.ml";;
Ask "width";;
Log "starting";;
Effect.perform;;
let label () = Effect.perform (Ask "width") ^ " columns";;
type Effect.t += Ping : unit Effect.t;;
""") Output
$ ocaml
OCaml version 5.5.1
Enter #help;; for help.
# #use "widths.ml";;
type _ Stdlib.Effect.t += Ask : string -> int Effect.t
type _ Stdlib.Effect.t += Log : string -> unit Effect.t
val usable_width : unit -> int = <fun>
val logged_width : unit -> int = <fun>
# Ask "width";;
- : int Effect.t = Ask "width"
# Log "starting";;
- : unit Effect.t = Log "starting"
# Effect.perform;;
- : 'a Effect.t -> 'a = <fun>
# let label () = Effect.perform (Ask "width") ^ " columns";;
Line 1, characters 15-43:
1 | let label () = Effect.perform (Ask "width") ^ " columns";;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This expression has type int but an expression was expected of type
string
# type Effect.t += Ping : unit Effect.t;;
Line 1, characters 0-37:
1 | type Effect.t += Ping : unit Effect.t;;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This extension does not match the definition of type Stdlib.Effect.t
They have different arities.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 declare_session.py
Ask : string -> int Effect.t reads as “Ask carries a string, and performing it gives back an int”, and
Log : string -> unit Effect.t gives back (). Ask "width" on its own is only a value of type int Effect.t:
nothing happens until it is handed to Effect.perform, whose type, 'a Effect.t -> 'a, turns an int Effect.t
into an int. That is why usable_width type-checks as unit -> int, like any arithmetic, and why label, which
joins the answer to a string with ^, was refused: the answer is an int, not a string.
Two facts in those types matter for the rest of the lesson. The type of logged_width, unit -> int, says nothing
about Log or Ask: OCaml’s types do not list the effects a function may perform, so the compiler never checks
that a handler is waiting. And the declaration must keep its _ parameter. Without it OCaml refused the line with
“They have different arities”, because Effect.t takes the answer type as its parameter. That index is what lets
one Effect.perform return an int for Ask and () for Log, as the GADTs lesson showed
for typed expressions.
Checkpoint
Check: Declaring and performing an effect
A handler answers with continue, and the code carries on
with_settings is the handler in full:
match f () with | v -> v | effect (Ask name), k -> continue k (List.assoc name settings). The first case is the
value case, which runs when f () returns normally. The second is an effect case, marked by the keyword effect. It
matches when the computation performs Ask name, and it binds k to the
continuation: In OCaml's effect handlers, the suspended rest of a computation, from the perform up to the handler, of type ('a, 'b) continuation: resuming it with an 'a runs that rest and gives back its 'b result.: the suspended rest of f (), from the perform up to this match.
continue k 40 resumes that rest, and 40 becomes the value the perform returns. continue and discontinue are
functions of Effect.Deep rather than keywords, so a file that writes effect cases still opens that module, as every
program on this page does. Step through one call, each arrow a jump of control between the handler and the code it
runs:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The handler starts the work. with_settings runs f () inside its match, so every effect that usable_width performs goes to this handler first. usable_width prints "asks for width".
- perform suspends the code. usable_width performs Ask "width" and stops in the middle of its let. The rest of its work becomes the continuation k, and control jumps to the effect case with name bound to "width".
- continue k 40. The handler looks up the width, prints "width is 40, continue" and calls continue k 40. usable_width carries on from the perform, which returns 40, and prints "asks for margin".
- The same handler again. Ask "margin" comes to the same handler, because a deep handler goes back around the rest of the computation each time it is resumed. The first continue call has not returned yet.
- continue k 4. The handler prints "margin is 4, continue" and resumes the new k with 4. usable_width prints "returns" and gives back 40 - 2 * 4 = 32.
- The value case runs. usable_width has returned normally, so the value case of the match runs and prints "value case: 32". Its result, 32, is what the innermost continue call returns.
- The continue calls return. continue k 4 returns 32, and the handler prints "continue for margin gave 32". That ends the margin case, so continue k 40 returns 32 too, and with_settings gives 32 to its caller.
The same handler, with a line printed at each step, gives the run that the figure follows:
from session import run, write
# The handler of report.ml with a print at every step: who runs when, what continue returns, and that the same
# handler answers the second Ask (a deep handler). The second file is the same handler without the annotation on
# settings, which OCaml refuses.
write("trace.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
let usable_width () =
print_endline "usable_width: asks for width";
let width = perform (Ask "width") in
print_endline "usable_width: asks for margin";
let margin = perform (Ask "margin") in
print_endline "usable_width: returns";
width - 2 * margin
let with_settings (settings : (string * int) list) f =
match f () with
| v ->
Printf.printf "value case: %d\\n" v;
v
| effect (Ask name), k ->
let answer = List.assoc name settings in
Printf.printf "handler: %s is %d, continue\\n" name answer;
let rest = continue k answer in
Printf.printf "handler: continue for %s gave %d\\n" name rest;
rest
let () = Printf.printf "result: %d\\n" (with_settings [ ("width", 40); ("margin", 4) ] usable_width)
""")
write("no_annotation.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
let with_settings settings f =
match f () with
| v -> v
| effect (Ask name), k -> continue k (List.assoc name settings)
""")
write("with_try.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
let usable_width () =
let width = perform (Ask "width") in
let margin = perform (Ask "margin") in
width - 2 * margin
(* The same handler in a try: effect cases, no value case. *)
let with_settings (settings : (string * int) list) f =
try f () with effect (Ask name), k -> continue k (List.assoc name settings)
let () = Printf.printf "try: %d\\n" (with_settings [ ("width", 40); ("margin", 4) ] usable_width)
""")
run("""
ocamlopt trace.ml -o trace
./trace
!ocamlopt -c no_annotation.ml
ocamlopt with_try.ml -o with_try
./with_try
""") Output
$ ocamlopt trace.ml -o trace
$ ./trace
usable_width: asks for width
handler: width is 40, continue
usable_width: asks for margin
handler: margin is 4, continue
usable_width: returns
value case: 32
handler: continue for margin gave 32
handler: continue for width gave 32
result: 32
$ ocamlopt -c no_annotation.ml
File "no_annotation.ml", line 9, characters 39-65:
9 | | effect (Ask name), k -> continue k (List.assoc name settings)
^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This expression has type 'a but an expression was expected of type int
This instance of int is ambiguous:
it would escape the scope of its equation
[exit status 2]
$ ocamlopt with_try.ml -o with_try
$ ./with_try
try: 32
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 handle_session.py
Three things in the trace deserve a slow read. usable_width printed “asks for margin” only after the handler had
printed “width is 40”: the performer really waits. One handler answered both questions, although they were performed
one after the other, because it is a deep handler: An effect handler that is installed again around the computation each time it is resumed, so it receives every effect the computation performs until it returns; the match … with effect syntax writes deep handlers.: it goes back around the computation
each time continue resumes it, until the computation returns. And continue itself returns. It gives back what the
rest of the computation produced, here 32 from the value case, so “continue for margin gave 32” came out before
“continue for width gave 32”: the inner call finished first. The last file, with_try.ml, answered the same
questions from a try … with effect, which has no value case, and printed 32 as well.
A handler whose answer has no known type
no_annotation.ml is with_settings without the type on settings, and OCaml refused it with “This instance of
int is ambiguous: it would escape the scope of its equation”. Inside the effect case the answer is an int only
because the Ask constructor says so, and the type of List.assoc name settings comes from outside the case.
Write the type where the value comes from, (settings : (string * int) list), as report.ml does, and the case
compiles.
Whatever supplies the answer, a lookup in a table or a constant such as the 0 of the nested handlers below, the value
given to continue must have exactly the type the effect declares: an int for Ask.
Key idea
Inside an effect case, k is the rest of the computation: continue k v makes the perform return v, runs the
rest under the same handler, and returns what that rest produced.
Checkpoint
Check: Handling an effect and resuming with continue
An effect with no handler stops the program at the perform
Since no function’s type lists its effects, nothing stops a program from performing one that no handler covers.
widths.ml adds a Log line at the top of usable_width and runs it under with_settings, which handles only
Ask. It compiled without a word, and then stopped:
from session import run, write
# The compiler does not check that some handler covers every effect a function performs. widths.ml performs Log as
# well as Ask under a handler that knows only Ask: it compiles without a word and stops when it runs. nested.ml adds
# a handler for Log inside the one for Ask, and runs a handler that answers only "margin" on its own.
write("widths.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
type _ Effect.t += Log : string -> unit Effect.t
let usable_width () =
perform (Log "computing the usable width");
let width = perform (Ask "width") in
let margin = perform (Ask "margin") in
width - 2 * margin
let with_settings (settings : (string * int) list) f =
match f () with
| v -> v
| effect (Ask name), k -> continue k (List.assoc name settings)
let () = Printf.printf "%d\\n" (with_settings [ ("width", 40); ("margin", 4) ] usable_width)
""")
write("nested.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
type _ Effect.t += Log : string -> unit Effect.t
let usable_width () =
perform (Log "computing the usable width");
let width = perform (Ask "width") in
let margin = perform (Ask "margin") in
width - 2 * margin
let with_settings (settings : (string * int) list) f =
match f () with
| v -> v
| effect (Ask name), k -> continue k (List.assoc name settings)
(* Prints every Log line; any other effect goes on to the next handler out. *)
let printing_log f =
match f () with
| v -> v
| effect (Log line), k ->
print_endline ("log: " ^ line);
continue k ()
(* Answers Ask "margin" only. *)
let no_margin f =
match f () with
| v -> v
| effect (Ask "margin"), k -> continue k 0
let settings = [ ("width", 40); ("margin", 4) ]
let () =
Printf.printf "nested: %d\\n" (with_settings settings (fun () -> printing_log usable_width));
Printf.printf "inner answers margin: %d\\n"
(with_settings settings (fun () -> printing_log (fun () -> no_margin usable_width)));
let alone =
try printing_log (fun () -> no_margin usable_width)
with Effect.Unhandled (Ask name) -> Printf.printf "nobody answered %S\\n" name; -1
in
Printf.printf "alone: %d\\n" alone
""")
run("""
ocamlopt widths.ml -o widths
!./widths
ocamlopt nested.ml -o nested
./nested
""") Output
$ ocamlopt widths.ml -o widths
$ ./widths
Fatal error: exception Stdlib.Effect.Unhandled(Widths.Log("computing the usable width"))
[exit status 2]
$ ocamlopt nested.ml -o nested
$ ./nested
log: computing the usable width
nested: 32
log: computing the usable width
inner answers margin: 40
log: computing the usable width
nobody answered "width"
alone: -1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unhandled_session.py
OCaml raised Effect.Unhandled with the effect inside it, Widths.Log("computing the usable width"), and the
program ended with exit status 2, as with any uncaught exception. The manual says where the exception starts: at the
point of the perform. An effect that no enclosing handler covers is an
unhandled effect: An effect performed where no enclosing handler has a case for it. OCaml does not detect this at compile time; it raises Effect.Unhandled, with the effect inside, at the point of the perform., and because OCaml’s types do not track effects, the first sign of
one is this exception at run time.
nested.ml shows how handlers share the work. A handler that has no case for an effect hands it on to the next
handler out, as a try that does not match an exception lets it through. Follow its second and third runs:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Three handlers, one call. In the second run, with_settings runs printing_log, which runs no_margin, which runs usable_width. Every effect goes first to the innermost handler, no_margin, and moves outward from there.
- Log moves past no_margin. usable_width performs Log. no_margin has a case only for Ask "margin", so the effect moves on to printing_log, which prints the log line and resumes the performer.
- The width goes outward. Ask "width" matches neither the margin case of no_margin nor anything in printing_log, so it reaches with_settings, which answers 40 from its table.
- The margin stays inside. Ask "margin" is caught by the innermost handler, no_margin, which answers 0, and with_settings never sees it. The run printed inner answers margin: 40, from 40 - 2 * 0.
- No outer handler. The third run calls printing_log and no_margin with no with_settings around them. Ask "width" passes both, and no handler is left to answer it.
- Raised at the perform. OCaml raises Effect.Unhandled (Ask "width") where usable_width performed the effect. It travels up through both handlers like an exception, to the try around the call, which printed nobody answered "width".
In the second run printing_log printed the log line, no_margin answered 0 for the margin and with_settings
answered the width, so the innermost answer won: 40 − 2 × 0 = 40. In the third run nothing answered Ask "width".
Effect.Unhandled came out of the perform, passed through both handlers like an exception, and reached the try
around the call, which matched Effect.Unhandled (Ask name) and printed nobody answered "width".
Key idea
OCaml never checks effects at compile time: an effect that no enclosing handler covers raises Effect.Unhandled
at its perform, and a handler without a case for an effect hands it to the next handler out.
Checkpoint
Check: Effect.Unhandled and handlers that hand effects on
A continuation can be resumed only once
The handlers so far called continue once for each effect. A handler may also call it twice, keep k for later, or
never call it, and the runtime treats each of these differently. twice.ml answers one Ask with 40 and then again
with 24. cleanup.ml suspends a job that keeps its closing step in Fun.protect, where a job with a real file would
close it, and ends it in two ways:
from session import run, write
# A continuation can be resumed once. twice.ml resumes one two times. cleanup.ml ends a suspended job that holds an
# open resource in two ways: the first handler never resumes it, the second resumes it with an exception through
# discontinue, so the job's Fun.protect cleanup runs.
write("twice.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
let () =
let total =
match perform (Ask "width") with
| v -> v
| effect (Ask _), k ->
let first = continue k 40 in
Printf.printf "first continue gave %d\\n" first;
first + continue k 24
in
Printf.printf "total %d\\n" total
""")
write("cleanup.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
exception Cancelled
let job () =
print_endline "job: opens the output file";
Fun.protect
~finally:(fun () -> print_endline "job: closes the output file")
(fun () ->
let width = perform (Ask "width") in
Printf.printf "job: writes %d columns\\n" width;
width)
(* Never resumes k: the rest of job, its cleanup included, never runs. *)
let dropping () =
match job () with
| v -> v
| effect (Ask _), _k ->
print_endline "handler: no answer, returns -1";
-1
(* Resumes k by raising Cancelled where job performed Ask. *)
let cancelling () =
match job () with
| v -> v
| exception Cancelled ->
print_endline "handler: Cancelled came out of job";
-1
| effect (Ask _), k ->
print_endline "handler: discontinue k Cancelled";
discontinue k Cancelled
let () =
Printf.printf "dropping gave %d\\n\\n" (dropping ());
Printf.printf "cancelling gave %d\\n" (cancelling ())
""")
run("""
ocamlopt twice.ml -o twice
!./twice
ocamlopt cleanup.ml -o cleanup
./cleanup
""") Output
$ ocamlopt twice.ml -o twice $ ./twice first continue gave 40 Fatal error: exception Stdlib.Effect.Continuation_already_resumed [exit status 2] $ ocamlopt cleanup.ml -o cleanup $ ./cleanup job: opens the output file handler: no answer, returns -1 dropping gave -1 job: opens the output file handler: discontinue k Cancelled job: closes the output file handler: Cancelled came out of job cancelling gave -1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 oneshot_session.py
The first continue ran the rest of the computation and gave 40, and the second raised
Continuation_already_resumed. An OCaml continuation is a
one-shot continuation: A continuation that can be resumed only once: OCaml never copies the suspended stack, and continue or discontinue on a continuation already resumed raises Continuation_already_resumed.: it points at the suspended part of the stack itself,
which is never copied, so once that part has run there is nothing left to run again. The manual’s reasons for the
design are cost, since capturing and resuming copy no stack frames, and safety for resources such as files and
sockets, which a second run would use twice.
The two job runs show the other half of the rule. dropping returned −1 without touching k, and the job never
printed its closing line: a continuation that is never resumed leaves its code suspended with whatever it opened, and
the manual adds that the memory of the suspended stack leaks too. cancelling ended the job with
discontinue k Cancelled, which raises Cancelled at the perform. The job’s Fun.protect ran its finally, the
exception left the job, and the exception Cancelled case of the same match caught it, so one match can hold a
value case, exception cases and effect cases together. The manual’s discipline is that every continuation is
eventually continued or discontinued, exactly once.
Once does not have to mean at once. A handler may store k and resume it after its match has returned:
from session import run, write
# A handler may keep the continuation and resume it after the match has returned. Kept in a reference whose type
# the compiler must infer, it is refused; with the type written on the reference, it compiles and runs.
write("keep_bad.ml", """open Effect
open Effect.Deep
type _ Effect.t += Pause : unit Effect.t
let saved = ref None
let () =
match (print_endline "step 1"; perform Pause; print_endline "step 2") with
| () -> print_endline "finished"
| effect Pause, k -> saved := Some k
""")
write("keep.ml", """open Effect
open Effect.Deep
type _ Effect.t += Pause : unit Effect.t
let saved : (unit, unit) continuation option ref = ref None
let () =
(match (print_endline "step 1"; perform Pause; print_endline "step 2") with
| () -> print_endline "finished"
| effect Pause, k ->
print_endline "paused, k kept";
saved := Some k);
print_endline "the match has returned";
match !saved with
| Some k ->
saved := None;
continue k ()
| None -> ()
""")
run("""
!ocamlopt -c keep_bad.ml
ocamlopt keep.ml -o keep
./keep
""") Output
$ ocamlopt -c keep_bad.ml
File "keep_bad.ml", line 11, characters 37-38:
11 | | effect Pause, k -> saved := Some k
^
Error: The value k has type (unit, unit) continuation/2
but an expression was expected of type 'a
This instance of unit is ambiguous:
it would escape the scope of its equation
File "_none_", line 1:
Definition of type continuation/2
[exit status 2]
$ ocamlopt keep.ml -o keep
$ ./keep
step 1
paused, k kept
the match has returned
step 2
finished
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 keep_session.py
keep.ml printed “the match has returned” before “step 2”: the computation waited in the reference until the
program chose to resume it, which is how a scheduler built on effects keeps a task waiting until its turn comes.
The first attempt, keep_bad.ml, failed with “This instance of unit is ambiguous: it would escape the scope of its
equation”, because the reference had no type of its own and the continuation’s types are fixed only inside the
effect case. Writing the type on the reference, (unit, unit) continuation option ref, was enough.
Key idea
Resume every continuation exactly once: a second resume raises Continuation_already_resumed, no resume leaves the
code and its resources suspended, and discontinue ends it with an exception so that its cleanup runs.
Checkpoint
Check: One-shot continuations and discontinue
Before OCaml 5.3, a handler was a record
Effects arrived in OCaml 5.0 without any syntax of their own: a handler was a record of three functions given to
Effect.Deep.match_with. The match … with effect cases came in 5.3, and the record API is still in the standard
library, so you will meet it in code written for 5.0 to 5.2 and in libraries that still build there. Here is
with_settings written twice in the record API:
from session import run, write
# The handler of report.ml written as before OCaml 5.3: a record given to Effect.Deep.match_with (value, exception
# and effect cases) or to Effect.Deep.try_with (the effect case only). Both still work on 5.5.1. The effc field must
# accept every effect type, so it needs a locally abstract type; record_bad.ml leaves it out.
write("record.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
let usable_width () =
let width = perform (Ask "width") in
let margin = perform (Ask "margin") in
width - 2 * margin
let with_settings (settings : (string * int) list) f =
match_with f ()
{ retc = (fun v -> v);
exnc = raise;
effc =
(fun (type c) (eff : c Effect.t) ->
match eff with
| Ask name ->
Some (fun (k : (c, _) continuation) -> continue k (List.assoc name settings))
| _ -> None) }
let with_settings_try (settings : (string * int) list) f =
try_with f ()
{ effc =
(fun (type c) (eff : c Effect.t) ->
match eff with
| Ask name ->
Some (fun (k : (c, _) continuation) -> continue k (List.assoc name settings))
| _ -> None) }
let settings = [ ("width", 40); ("margin", 4) ]
let () =
Printf.printf "match_with: %d\\n" (with_settings settings usable_width);
Printf.printf "try_with: %d\\n" (with_settings_try settings usable_width)
""")
write("record_bad.ml", """open Effect
open Effect.Deep
type _ Effect.t += Ask : string -> int Effect.t
let with_settings (settings : (string * int) list) f =
match_with f ()
{ retc = (fun v -> v);
exnc = raise;
effc =
(fun eff ->
match eff with
| Ask name -> Some (fun k -> continue k (List.assoc name settings))
| _ -> None) }
""")
run("""
ocamlopt record.ml -o record
./record
!ocamlopt -c record_bad.ml
""") Output
$ ocamlopt record.ml -o record
$ ./record
match_with: 32
try_with: 32
$ ocamlopt -c record_bad.ml
File "record_bad.ml", lines 11-14, characters 8-22:
11 | ........(fun eff ->
12 | match eff with
13 | | Ask name -> Some (fun k -> continue k (List.assoc name settings))
14 | | _ -> None)..
Error: This field value has type
int Effect.t -> ((int, 'a) Effect.Deep.continuation -> 'a) option
which is less general than
'c. 'c Effect.t -> (('c, 'b) Effect.Deep.continuation -> 'b) option
The type int is not a type variable.
[exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 record_session.py
The record’s fields are the cases of the match: retc handles the value, exnc an exception (here raise, so
exceptions go on unchanged) and effc the effects. effc receives the effect alone and returns an option: Some of
a function that takes the continuation, for an effect it handles, or None to hand the effect on. try_with takes
only effc, keeps the value as it is and raises every exception again. Both records printed 32, as the syntax did.
The difference is in the types. effc must accept an effect of any answer type, so it starts with
fun (type c) (eff : c Effect.t) ->, a locally abstract type: A type a function introduces with 'type a.', kept abstract inside the body and refined per arm, which lets pattern matching on a GADT type-check each arm against its own case's type. More in “Generalised algebraic data types (GADTs)” like the
type a. of the GADTs lesson, written here for one anonymous function, and each case annotates its continuation as
(k : (c, _) continuation). Without them OCaml inferred int Effect.t from the Ask case and refused the field as
“less general than” the record’s 'c. 'c Effect.t -> … type. The syntax needs none of this, and it needs no last
| _ -> None case either: an effect without a case simply goes on outward. Which form to write depends on the
compilers your code must build on and on what the handler does with values and exceptions:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Handler form | Compilers it builds on | Annotations it needs | Value and exception cases | Effects it does not handle |
|---|---|---|---|---|
| match … with effect Written as: effect cases beside the value case | Mixed 5.3 and later only: older compilers have no effect patterns | Good only the type of the answer, such as the settings table | Good v -> … and exception e -> … sit in the same match | Good an effect with no case goes on to the next handler by itself |
| Effect.Deep.match_with Written as: a record of retc, exnc and effc | Good every release from 5.0; the run printed 32 on 5.5.1 | Poor fun (type c) (eff : c Effect.t), and k annotated in each case | Good retc for the value, exnc for exceptions | Mixed needs a last case, | _ -> None, to hand them on |
| Effect.Deep.try_with Written as: a record with effc only | Good every release from 5.0; the run printed 32 on 5.5.1 | Poor the same locally abstract type and annotations as match_with | Poor keeps the value as it is and raises every exception again | Mixed needs | _ -> None, as match_with does |
How the answers decide
- “Which OCaml releases must the code build on?” Also 5.0, 5.1 or 5.2: rules out match … with effect. Effect patterns arrived in 5.3, so a handler for 5.0 to 5.2 is a record given to match_with or try_with.
- “Which OCaml releases must the code build on?” 5.3 or later only: adds a caveat to Effect.Deep.match_with and Effect.Deep.try_with. Both records still work on 5.5.1, where each printed 32, but they need a locally abstract type and annotations that the syntax does without.
- “Must the handler change the result, or catch exceptions from the computation?” Yes, it changes the value or catches: rules out Effect.Deep.try_with. try_with returns the value unchanged and raises every exception again; the syntax has value and exception cases, and match_with has retc and exnc.
- “Must the handler change the result, or catch exceptions from the computation?” No, it only answers effects: Any of the three can answer the effects; the two records also need a last | _ -> None case for the effects they hand on.
In short: on 5.3 and later, write effect cases in a match; for code that must also build on 5.0 to 5.2, use
match_with, or try_with when the handler only answers effects. Beside deep handlers, Effect.Shallow provides
shallow ones, which handle a single effect and must be given a handler again for the next one, with no syntax of
their own; the next lesson runs one program under both
kinds. The releases since 5.0 changed how handlers are written far more than what they do:
- 5.0
Effects arrive, without syntax new module
The release that made OCaml multicore added the
Effectmodule, withperform,continueanddiscontinue, and bothEffect.DeepandEffect.Shallow. A handler was a record given tomatch_withortry_with, and the manual gained a tutorial on effect handlers. - 5.1
More back ends, and no marshalled continuations
Multicore support, fibers and effects included, came back to the RISC-V and s390x back ends, and marshalling a continuation with the
Marshalmodule was made to raiseInvalid_argument. - 5.2
Where effects cannot go, documented
The manual now says that an effect performed from a signal handler, a finaliser or a memprof callback cannot be caught by the main code, and that no effect crosses a call from C into OCaml through
caml_callback: each case ends inEffect.Unhandled. - 5.3
The match … with effect syntax new syntax
Deep handlers gained syntax: effect cases in a
matchor atry, with the continuation bound beside the effect, and the manual tutorial was rewritten with it.effectbecame a keyword, and the new-keywordsoption, as in-keywords 5.2, builds older code that useseffectas a name. - 5.4
effect as an attribute name
effect, a keyword since 5.3, may be used as the name of an attribute, as in[@effect]. - 5.5
An effect declared for one expression new in 5.5
let type _ Effect.t += Ping : unit Effect.t in …declares an effect for the expression that follows, aslet exceptiondoes for an exception; the lesson runs one. A bug with unhandled effects in bytecode programs was fixed.
The timeline has 6 events, from 5.0 to 5.5.
One last session shows the 5.3 keyword, the module’s alert and the 5.5 form of a declaration:
from session import run, write
# Three version facts, run on OCaml 5.5.1. Since 5.3 effect is a keyword, so old code that names a value effect
# stops compiling unless it is built with -keywords 5.2. The Effect module carries an unstable alert, which the
# compiler shows only when asked. Since 5.5 an effect can be declared for one expression with let type ... in, and
# outside.ml shows that the constructor does not exist after that expression.
write("old_names.ml", """let effect = "side effect"
let () = print_endline effect
""")
write("uses_effect.ml", """open Effect
type _ t += Ask : string -> int t
let width () = perform (Ask "width")
""")
write("local.ml", """let () =
let type _ Effect.t += Ping : unit Effect.t in
match Effect.perform Ping; "pong" with
| answer -> print_endline answer
| effect Ping, k ->
print_endline "ping";
Effect.Deep.continue k ()
""")
write("outside.ml", """let pinged =
let type _ Effect.t += Ping : unit Effect.t in
match Effect.perform Ping with
| () -> true
| effect Ping, k -> Effect.Deep.continue k ()
let again = Effect.perform Ping
""")
run("""
!ocamlopt -c old_names.ml
ocamlopt -keywords 5.2 old_names.ml -o old_names
./old_names
ocamlopt -c uses_effect.ml
ocamlopt -c -alert +unstable uses_effect.ml
ocamlopt local.ml -o local
./local
!ocamlopt -c outside.ml
""") Output
$ ocamlopt -c old_names.ml
File "old_names.ml", line 1, characters 4-10:
1 | let effect = "side effect"
^^^^^^
Error: Syntax error
[exit status 2]
$ ocamlopt -keywords 5.2 old_names.ml -o old_names
$ ./old_names
side effect
$ ocamlopt -c uses_effect.ml
$ ocamlopt -c -alert +unstable uses_effect.ml
File "uses_effect.ml", line 1, characters 5-11:
1 | open Effect
^^^^^^
Alert unstable: module Stdlib.Effect
The Effect interface may change in incompatible ways in the future.
$ ocamlopt local.ml -o local
$ ./local
ping
pong
$ ocamlopt -c outside.ml
File "outside.ml", line 7, characters 27-31:
7 | let again = Effect.perform Ping
^^^^
Error: This variant expression is expected to have type 'a Effect.t
There is no constructor Ping within type Effect.t
[exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 versions_session.py
local.ml declared Ping for a single expression with let type _ Effect.t += Ping : unit Effect.t in, and its
handler printed “ping” before the expression’s own “pong”. Outside that expression Ping does not exist, as with a
let exception: outside.ml performed it after the let and was refused with “There is no constructor Ping within
type Effect.t”.
effect is a keyword from OCaml 5.3
Code that names a value effect no longer parses: old_names.ml failed with “Syntax error” on 5.5.1 and built
unchanged with -keywords 5.2. The Effect module also carries an unstable alert, which says its interface may
change in incompatible ways; the compiler showed it only when the build asked with -alert +unstable.
Checkpoint
Check: The match_with and try_with record API
Practise: effects, handlers and continuations
These questions reuse the lesson’s own programs: the report and its two tables, the nested handlers, the job that holds a file open and the record handler. Work each answer out from what the sessions printed before you check it:
Practice that adapts to you
Practise this lesson
16 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
A warm-up for freshers and 2–5 years. Both kinds of handler catch something thrown from deep inside a call: what can an effect handler do that an exception handler cannot?
Both are found the same way: the nearest enclosing handler with a matching case runs, and one without a case lets
the exception or the effect go on outward. The difference is what the handler receives. An exception handler gets
the exception’s value, and the code that raised it has already been abandoned, so the handler can only take over. An
effect handler also gets the continuation, the suspended rest of the computation from the perform onwards, and can
resume it with continue k v, which makes the perform return v. That makes an effect a question the caller
answers, such as a setting or a log line. It also brings duties an exception lacks: a continuation can be resumed
only once, or Continuation_already_resumed is raised, and one that will not be continued should be ended with
discontinue so its cleanup runs.
Three questions for experienced engineers follow: a settings handler that worked until request work moved into domains, a test handler for code that logs and reads settings, and an importer whose callers each want bad rows treated differently. Sketch how you would approach each one; its page has a first step to measure your sketch against.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A service installs one settings handler around its main function, and its code reads limits with perform (Ask "max_rows"). After request work moves into Domain.spawn, every request fails with Effect.Unhandled (Ask "max_rows"), although the handler is still in place. Why, and how do you fix it?
-
Mid to senior
A pricing function reads settings through an Ask effect and writes progress lines through a Log effect. You want unit tests that run it with no files, no globals and no printing, and that check both its result and what it logged. How do you write the test handler, and what should each test assert?
-
Senior to staff
A CSV importer meets rows with no price. One caller wants such rows skipped and counted, another wants a default price, a third wants the import stopped at the first bad row, and the importer must not know which. Design this with an effect, and say how stopping should work.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is an effect in OCaml 5?
A question that deep code asks with perform and the code around the call answers in a handler, so a setting reaches a function four calls down without a parameter or a global.
-
How is an effect declared, and why does perform (Ask "width") have type int?
As a constructor of the extensible GADT Effect.t, type _ Effect.t += Ask : string -> int Effect.t, whose index is the answer's type.
-
In match … with effect (Ask name), k -> continue k v, what are k and continue?
k is the suspended rest of the computation; continue k v makes the perform return v, runs the rest under the same deep handler and returns what the rest produced.
-
What happens when no handler covers a performed effect?
Effect.Unhandled is raised at the perform, at run time only, because OCaml does not check effects at compile time. A handler with no case for an effect passes it to the next handler out.
-
How many times may a continuation be resumed, and what does discontinue do?
Exactly once: a second continue raises Continuation_already_resumed, and a dropped one never runs its cleanup. discontinue ends it with an exception so that Fun.protect can tidy up.
-
Why was a handler refused with "This instance of int is ambiguous"?
The answer's type inside the effect case came from outside it. Annotate where the value comes from, as in (settings : (string * int) list).
-
What changed for handlers in OCaml 5.3, and how did code look before?
effect became a keyword, so match … with effect works; before, a handler was a record of retc, exnc and effc passed to Effect.Deep.match_with, which still works. Code that names a value effect needs -keywords 5.2.
-
How does an effect handler differ from an exception handler?
Both are the nearest enclosing handler with a matching case, but an effect handler also gets the continuation and can resume the code from the perform, while the code that raised an exception is already abandoned.
Key takeaways
- An effect lets the function that needs a value ask for it with
performwhile the code that starts the work answers in a handler, so the report’s settings reachedusable_widthwithout a parameter or a global. type _ Effect.t += Ask : string -> int Effect.tadds a constructor to an extensible GADT whose index is the answer’s type, soperform (Ask "width")has typeint, and no function’s type records the effects it performs.- In
match … with effect (Ask name), k -> continue k v,kis the suspended rest of the computation, andcontinuemakes theperformreturnv, keeps the deep handler around the rest and returns what the rest produced. - An effect that no handler covers raises
Effect.Unhandledat itsperform, at run time only, and a handler with no case for an effect hands it to the next handler out. - A continuation is resumed exactly once: a second
continueraisesContinuation_already_resumed, a dropped one never runs its cleanup, anddiscontinueends it with an exception so thatFun.protectcan tidy up. - Before 5.3 a handler was a record of
retc,exncandeffcgiven toEffect.Deep.match_withortry_with, which still works, at the cost of a locally abstract type and annotations that the syntax does not need.
Terms in this lesson
- continuation
- In OCaml's effect handlers, the suspended rest of a computation, from the perform up to the handler, of type ('a, 'b) continuation: resuming it with an 'a runs that rest and gives back its 'b result.
- deep handler
- An effect handler that is installed again around the computation each time it is resumed, so it receives every effect the computation performs until it returns; the match … with effect syntax writes deep handlers.
- effect
- An operation declared by adding a constructor to OCaml's extensible type 'a Effect.t and started with Effect.perform: the code that performs it stops until a handler supplies an answer of type 'a.
- effect handler
- Code that runs a computation and receives each effect it performs together with the continuation, then decides what to do: resume it with continue, end it with discontinue, or neither. OCaml 5.3 writes one as effect cases in a match.
- one-shot continuation
- A continuation that can be resumed only once: OCaml never copies the suspended stack, and continue or discontinue on a continuation already resumed raises Continuation_already_resumed.
- unhandled effect
- An effect performed where no enclosing handler has a case for it. OCaml does not detect this at compile time; it raises Effect.Unhandled, with the effect inside, at the point of the perform.
References
- The OCaml Manual: Effect handlers (Inria and the OCaml developers)
- The OCaml Manual: module Effect (Inria and the OCaml developers)
- The OCaml Manual: module Effect.Deep (Inria and the OCaml developers)
- The OCaml Manual: module Effect.Shallow (Inria and the OCaml developers)
- The OCaml Manual: Extensible variant types (Inria and the OCaml developers)
- The OCaml Manual: Locally abstract types (Inria and the OCaml developers)
- The OCaml Manual: module Fun (protect) (Inria and the OCaml developers)
- The OCaml Manual: Batch compilation (the -keywords option) (Inria and the OCaml developers)
- OCaml Changes (effects in 5.0, the handler syntax in 5.3, local effect declarations in 5.5) (The OCaml developers)
Report a problem with this lesson
Kept only in this browser. Your Learn progress