Introduction to Irmin

2026-09-05 · 7 min

§ 01

Install and the minimal store

The in-memory backend, for getting the shape of the API without a filesystem or Git involved.

$ opam install irmin irmin-mem

Irmin.Mem.KV builds a key/value store functor over a content type - string here, the simplest instantiation.

module Store = Irmin_mem.KV.Make (Irmin.Contents.String)
let config = Irmin_mem.config ()
let info () = Irmin.Info.Default.v ~author:"me" 0L

Opening the repo and the main branch. Irmin is Lwt-based throughout - every store operation returns a promise.

let main () =
let open Lwt.Syntax in
let* repo = Store.Repo.v config in
let* t = Store.main repo in
let* () = Store.set_exn t ~info [ "a"; "b"; "c" ] "hello" in
let* v = Store.get t [ "a"; "b"; "c" ] in
Lwt_io.printl v
let () = Lwt_main.run (main ())

Run it.

$ dune exec ./main.exe
hello
Every Irmin operation is Lwt-shaped because a real backend (Git, a remote) does I/O on every read and write - the in-memory backend still returns promises so code is portable to a slower backend without any call site changing.
§ 02

Paths, keys, and the tree shape

A key is a string list - a path through a tree, the same mental model as a filesystem or a Git repo's working directory.

let path_to_readme = [ "docs"; "README.md" ]
let path_to_config = [ "config"; "app.json" ]

set_exn raises on failure; set returns a result - reach for the result-returning form at any boundary where the caller should decide how to handle a write conflict rather than crash.

let* r = Store.set t ~info [ "config"; "app.json" ] {|{"port":8080}|} in
match r with
| Ok () -> Lwt_io.printl "written"
| Error (`Conflict msg) -> Lwt_io.printl ("conflict: " ^ msg)
| Error (`Too_many_retries n) -> Lwt_io.printl (Printf.sprintf "gave up after %d retries" n)

mem checks existence without fetching the value; list enumerates one tree level, non-recursively.

let* exists = Store.mem t [ "config"; "app.json" ] in
let* entries = Store.list t [ "config" ] in
List.iter (fun (step, _) -> print_endline step) entries

remove_exn deletes a key; a whole subtree disappears once every key under it is gone - there is no separate directory-delete operation, because a directory is just a prefix, not a first-class node with its own existence.

let* () = Store.remove_exn t ~info [ "config"; "app.json" ] in
§ 03

History: every write is a commit

Store.history walks the commit graph reachable from a point in the store, the same shape as git log.

let* history = Store.history t in
Store.History.iter_vertex
(fun commit ->
let info = Store.Commit.info commit in
Printf.printf "%s: %s\n"
(Irmin.Info.Default.date info |> Int64.to_string)
(Irmin.Info.Default.message info))
history

last_modified finds the most recent commit that touched a given key - the equivalent of git log -1 -- path.

let* commits = Store.last_modified t [ "config"; "app.json" ] in
match commits with
| commit :: _ -> Lwt_io.printl (Store.Commit.hash commit |> Store.Hash.to_string)
| [] -> Lwt_io.printl "never touched"

Reading the store as it existed at an older commit - a store handle bound to a specific commit rather than a moving branch head.

let* head = Store.Head.get t in
let* old_tree = Store.Commit.tree head in
let* old_value = Store.Tree.find old_tree [ "config"; "app.json" ] in
§ 04

Branches

Branches are named pointers into the commit graph, same as Git - of_branch opens a store handle bound to one, creating it on first write if it does not exist yet.

let* feature = Store.of_branch repo "feature-x" in
let* () = Store.set_exn feature ~info [ "flag" ] "on" in

Listing every branch in the repo.

let* branches = Store.Branch.list repo in
List.iter print_endline branches

merge_with_branch merges another branch into the current one - three-way, using the common ancestor, exactly like git merge.

let* result = Store.merge_with_branch t "feature-x" ~info in
match result with
| Ok () -> Lwt_io.printl "merged cleanly"
| Error (`Conflict msg) -> Lwt_io.printl ("merge conflict: " ^ msg)

Deleting a branch once merged - the commits themselves stay reachable from history unless garbage collected separately.

let* () = Store.Branch.remove repo "feature-x" in
§ 05

Merge semantics for content types

The default Irmin.Contents.String merges by last-write-wins on conflict - there is no sensible way to merge two arbitrary strings. A real content type defines its own merge function, which is what makes concurrent, structured writes to the same key actually reconcile instead of one side clobbering the other.

A custom content type: a counter, whose merge adds up concurrent increments from both branches instead of picking one arbitrarily.

module Counter = struct
type t = int
let t = Irmin.Type.int
let merge ~old a b =
let open Irmin.Merge.Infix in
old () >>=* fun old ->
let old = match old with Some o -> o | None -> 0 in
let delta_a = a - old in
let delta_b = b - old in
Irmin.Merge.ok (old + delta_a + delta_b)
let merge = Irmin.Merge.(option (v t merge))
end
old () yields the value at the merge base commit, wrapped so it can also fail or be absent - a key created independently on both branches has no common ancestor value at all, which is exactly the None case.

Wiring the custom content type into a store, in place of Irmin.Contents.String.

module CounterStore = Irmin_mem.KV.Make (Counter)

Two branches each increment the same counter from 5 to 8 (+3) and from 5 to 9 (+4) independently - merging combines the deltas to 12, rather than last-write-wins silently dropping one branch's work.

let* main = CounterStore.main repo in
let* () = CounterStore.set_exn main ~info [ "visits" ] 5 in
let* a = CounterStore.of_branch repo "a" in
let* b = CounterStore.of_branch repo "b" in
let* () = CounterStore.set_exn a ~info [ "visits" ] 8 in
let* () = CounterStore.set_exn b ~info [ "visits" ] 9 in
let* () = CounterStore.merge_with_branch a "b" ~info |> Lwt.map (fun _ -> ()) in
let* v = CounterStore.get a [ "visits" ] in
(* v = 12 *)

A JSON content type via irmin.Contents.Json_value, with merge falling back to Irmin.Merge.default (which is last-write-wins) unless overridden - reasonable for config blobs where field-level merging is not worth the complexity.

module JsonStore = Irmin_mem.KV.Make (Irmin.Contents.Json_value)
let* t = JsonStore.main repo in
let* () = JsonStore.set_exn t ~info
[ "config" ] (`O [ ("port", `Float 8080.) ]) in
§ 06

Diffing

Store.status/Store.Tree.diff give the changed keys between two trees - the programmatic equivalent of git diff.

let* diff = Store.Tree.diff old_tree new_tree in
List.iter
(fun (key, change) ->
match change with
| `Added v -> Printf.printf "+ %s\n" (String.concat "/" key)
| `Removed v -> Printf.printf "- %s\n" (String.concat "/" key)
| `Updated (old, new_) -> Printf.printf "~ %s\n" (String.concat "/" key))
diff
§ 07

Watches

watch registers a callback fired on every commit to the store - the mechanism a service uses to react to changes made by another process sharing the same repo, rather than polling.

let* watch = Store.watch t (fun diff ->
match diff with
| `Updated (_, (commit, _)) ->
Lwt_io.printl ("new commit: " ^ Store.Commit.hash commit |> Store.Hash.to_string)
| `Added _ | `Removed _ -> Lwt.return_unit)
in

watch_key narrows the callback to changes under one specific path, avoiding a wake-up on every unrelated commit.

let* watch = Store.watch_key t [ "config"; "app.json" ] (fun _diff ->
Lwt_io.printl "config changed")
in

Unregistering it when done.

let* () = Store.unwatch watch in
§ 08

The FS and Git backends

Persisting to disk without a real Git repository underneath - a simpler on-disk format, faster than Git for pure storage.

$ opam install irmin-fs

Same Make functor, different config module - the store's own API is unchanged, only the backend.

module FSStore = Irmin_fs_unix.KV.Make (Irmin.Contents.String)
let config = Irmin_fs.config "/tmp/irmin-store"
let* repo = FSStore.Repo.v config in

The actual Git backend - every commit is a real Git commit, readable by git log/git show on the same directory.

$ opam install irmin-git

Irmin_git.FS.KV wires a store directly onto an on-disk Git repository - external tooling and Irmin can both read and write it.

module GitStore = Irmin_git.FS.KV (Irmin_git.Mem_info) (Irmin.Contents.String)
let config = Irmin_git.config ~bare:true "/tmp/my-repo"
let* repo = GitStore.Repo.v config in
let* t = GitStore.main repo in
let* () = GitStore.set_exn t ~info [ "hello.txt" ] "world" in

Confirming it from the shell - an ordinary Git repository, because it is one.

$ cd /tmp/my-repo && git log --oneline
a3f1c2e hello.txt
The Git backend is the one most people reach for in production - it gets replication, existing Git hosting, and every Git tool for free, at the cost of Git's own object-store performance characteristics (many small objects, packing) rather than a format tuned purely for Irmin's access patterns.

Pushing to and pulling from a real remote - the same clone/fetch/push vocabulary as the git CLI, exposed as Store functions.

let remote = Irmin_git.remote "https://github.com/me/my-repo.git" in
let* result = GitStore.Sync.pull t remote `Set in
let* _ = GitStore.Sync.push t remote in
§ 09

Transactions: with_tree

Store.with_tree applies a function over the tree and atomically commits the result, retrying on a concurrent conflicting write - the right way to do a read-modify-write instead of a bare get followed by a bare set.

let* () =
Store.with_tree_exn t [ "counters" ] ~info ~strategy:`Merge (fun tree ->
let tree = Option.value tree ~default:(Store.Tree.empty ()) in
let* current = Store.Tree.find tree [ "hits" ] in
let next = 1 + Option.value current ~default:0 in
let* tree = Store.Tree.add tree [ "hits" ] next in
Lwt.return_some tree)
in

Building a batch of changes on an in-memory tree, then committing them all in one write - cheaper than one Store.set per key when updating many paths at once.

let* tree = Store.Tree.empty () |> Lwt.return in
let* tree = Store.Tree.add tree [ "a" ] "1" in
let* tree = Store.Tree.add tree [ "b" ] "2" in
let* tree = Store.Tree.add tree [ "c" ] "3" in
let* () = Store.set_tree_exn t [ "batch" ] tree ~info in
§ 10

A small worked example: a config service

A tiny module wrapping a GitStore for versioned config, with get/set/history all in terms of the Store API above.

module Config = struct
let info msg () = Irmin.Info.Default.v ~author:"config-service" ~message:msg 0L
let get t key =
Store.find t [ "config"; key ]
let set t ~key ~value =
Store.set_exn t ~info:(info ("set " ^ key)) [ "config"; key ] value
let history t key =
Store.last_modified t [ "config"; key ]
end

Two independent writers on separate branches, merged, with the counter content type resolving any concurrent conflict rather than one writer silently losing.

let* writer_a = CounterStore.of_branch repo "writer-a" in
let* writer_b = CounterStore.of_branch repo "writer-b" in
let* () = CounterStore.set_exn writer_a ~info [ "config"; "replicas" ] 3 in
let* () = CounterStore.set_exn writer_b ~info [ "config"; "replicas" ] 5 in
let* main = CounterStore.main repo in
let* _ = CounterStore.merge_with_branch main "writer-a" ~info in
let* _ = CounterStore.merge_with_branch main "writer-b" ~info in

The merge function is the whole trick: Irmin never resolves conflicting writes for you, it just runs the content type's own merge function and stores whatever that returns. A plain reference cell has no sensible merge; a counter, a CRDT, or a last-write-wins record does. Getting the library itself onto a project is the same opam install step as any other dependency, covered in Introduction to opam.