blog :: Post "effect-handlers-in-ocaml"
Effect Handlers in OCaml
2026-07-12 · 6 min
Effects let a function suspend, hand control to a caller-supplied handler, and be resumed - all without exceptions unwinding the stack or a monad threading state through every signature. This is the mechanism, command by command.
A little history
Effect handlers shipped in OCaml 5.0, released December 2022, alongside multicore support - the two features came out of the same research thread at OCaml Labs (Cambridge) led by KC Sivaramakrishnan. The design descends from earlier academic work on algebraic effects (Plotkin and Pretnar), but OCaml's version is untyped: an effect can be performed anywhere, and the compiler will not tell you if nothing handles it, much like an uncaught exception.
Get an OCaml 5 switch
Effects need OCaml 5.x. Check what you're on.
$ ocaml -version
If you're still on 4.x, create a 5.x switch and use it in the current directory.
$ opam switch create 5.2.0$ opam switch 5.2.0
Declaring and performing an effect
An effect is declared as an extensible variant of Effect.t. Performing one with nothing to handle it behaves like an uncaught exception.
$ open Effect$$ type _ Effect.t += Xchg : int -> int Effect.t$$ let () = Printf.printf "%d\n" (perform (Xchg 0))
Run it unhandled.
$ dune exec ./main.exeFatal error: exception Stdlib.Effect.Unhandled(Xchg 0)
Handling an effect
Effect.Deep.match_with runs a function and intercepts every effect it performs. effc decides which effects it handles; continue resumes the suspended computation with a value.
$ open Effect.Deep$$ let comp () =$ Printf.printf "%d\n" (perform (Xchg 0));$ Printf.printf "%d\n" (perform (Xchg 1))$$ let () =$ match_with comp ()$ { retc = (fun () -> ());$ exnc = (fun e -> raise e);$ effc = fun (type a) (eff : a Effect.t) ->$ match eff with$ | Xchg n -> Some (fun (k : (a, _) continuation) ->$ continue k (n + 1))$ | _ -> None }
Each perform suspends comp, hands (n, k) to the handler, and continue k resumes comp exactly where it left off - the printed values are 1 then 2.
12
Effects as generators
The same shape - perform to suspend, a handler to intercept - gives you a generator. produce doesn't know or care who is consuming its values.
$ type _ Effect.t += Yield : int -> unit Effect.t$$ let produce n =$ for i = 1 to n do$ Effect.perform (Yield i)$ done
A consumer that prints each value as it arrives, then resumes production.
$ let consume f =$ Effect.Deep.match_with f ()$ { retc = (fun () -> ());$ exnc = (fun e -> raise e);$ effc = fun (type a) (eff : a Effect.t) ->$ match eff with$ | Yield i -> Some (fun (k : (a, _) Effect.Deep.continuation) ->$ Printf.printf "got %d\n" i;$ Effect.Deep.continue k ())$ | _ -> None }$$ let () = consume (fun () -> produce 3)
Continuations are one-shot
A captured continuation (the k above) can be resumed exactly once. Calling continue or discontinue on it a second time raises Effect.Continuation_already_resumed - OCaml's effects do not give you the multi-shot continuations some other effect systems support, which keeps the runtime implementation cheap.
Effects are not parallelism
Effect handlers give you structured control flow on a single domain - they do not by themselves run anything concurrently. Actual parallelism uses the separate Domain module (also new in 5.0), typically through the higher-level domainslib library.
Pull in domainslib and spawn work across domains explicitly.
$ opam install domainslib
A task pool doing two computations in parallel, then joining the results.
$ let pool = Domainslib.Task.setup_pool ~num_domains:2 ()$ let a, b =$ Domainslib.Task.run pool (fun () ->$ let fut_a = Domainslib.Task.async pool (fun () -> heavy_a ()) in$ let fut_b = Domainslib.Task.async pool (fun () -> heavy_b ()) in$ (Domainslib.Task.await pool fut_a, Domainslib.Task.await pool fut_b))
Direct-style IO with eio
eio is the effects-based IO library that replaced the need for Lwt/Async's monadic style for a lot of new code - fibers, cancellation, and IO are all built on the same perform/handle mechanism above, so concurrent code reads like ordinary direct-style OCaml instead of a chain of >>=.
Install it and run a program under its main event loop.
$ opam install eio_main$$ let () =$ Eio_main.run @@ fun env ->$ Eio.Switch.run @@ fun sw ->$ Eio.Fiber.both$ (fun () -> traverse_a env sw)$ (fun () -> traverse_b env sw)
That's the shape of it: perform to suspend, a handler to decide what happens next, continue to resume - generators and eio's fibers are both just that pattern wearing different clothes.