blog :: Post "intro-to-odoc"
Introduction to odoc
2026-08-23 · 7 min
odoc turns the comments in your interface files into a browsable HTML manual. One dune alias builds it. The rest of this is the markup, and the handful of commands for checking that what you wrote is what gets rendered.
A little history
ocamldoc shipped with the compiler and worked by parsing source files directly. That was fine per-project and awkward across packages: it could not reliably resolve a reference into a library it had not parsed, so ecosystem-wide documentation did not link up. odoc replaced it by working from the compiler's own artifacts - the .cmti and .cmt files - which already carry fully resolved types and module paths. That is what makes a reference to another package's type render as a link. It is the tool behind the package documentation on ocaml.org.
Install and build
Install odoc.
$ opam install odoc
Build the documentation for every package in the project. Dune drives odoc for you; there is no odoc invocation to write by hand.
$ dune build @doc
Where it lands.
_build/default/_doc/_html/index.html_build/default/_doc/_html/hello/Hello/index.html
Open it. On Linux, xdg-open.
$ open _build/default/_doc/_html/index.html
Include modules that are not part of the public interface. Useful while working, not for what you publish.
$ dune build @doc-private
Serve it instead of opening a file URL, which some browsers restrict.
$ python3 -m http.server -d _build/default/_doc/_html 8000
Doc comments
A doc comment is a comment opening with two asterisks. It attaches to the item immediately after it, or - with a caret - to the item before it. Write them in the .mli when there is one: that is the interface people read, and a comment in the .ml is ignored once an .mli exists.
Attached to the item that follows.
$ (** Parse a configuration file. *)$ val parse : string -> t
Attached to the item before it, with (**<. Handy for variant constructors and record fields.
$ type level =$ | Debug (**< Everything, including timings. *)$ | Info (**< The default. *)$ | Error (**< Failures only. *)
A floating comment, attached to nothing, used to break a long interface into sections.
$ (** {1 Reading} *)$$ val parse : string -> t$ val parse_string : string -> t$$ (** {1 Writing} *)$$ val to_string : t -> string
The comment at the very top of the file documents the module itself, and its first sentence becomes the module's one-line synopsis in listings. Keep that sentence short and self-contained.
$ (** Configuration file handling.$$ Reads the INI-like format described in {!page-format}, and$ reports errors with the line they occurred on. *)
Markup
Inline styling and code.
{b bold} {i italic} {e emphasis} [inline code]{^ superscript} {_ subscript}
A code block. The language annotation drives syntax highlighting.
$ {[$ let cfg = Config.parse "app.ini" in$ Config.get cfg "port"$ ]}
Verbatim text, for output and anything that is not OCaml.
$ {v$ port = 8080$ host = localhost$ v}
Lists. The nesting is braces, not indentation.
$ {ul$ {li A bullet.}$ {li Another.}}$$ {ol$ {li First.}$ {li Second.}}
Headings, one through five. {0} is the page title and belongs only in a .mld file.
{1 A section}{2 A subsection}
Cross-references
A reference in braces with a bang becomes a link, and odoc resolves it against the real module structure. An unresolvable reference is a warning, which is the main reason to turn warnings into errors in CI: otherwise dead links accumulate silently through every rename.
The plain form. odoc works out what kind of thing the name refers to.
{!Config.parse} -- a value in another module{!t} -- a type in this one{!Stdlib.List} -- a module, including one from another package
Disambiguate when a type and a value share a name, which in OCaml is common.
{!type:t}{!val:parse}{!module:Config}{!exception:Parse_error}
Link text of your own, rather than the raw path.
{{!Config.parse} the parser}{{:https://ocaml.org} an external URL}
Reference a standalone page by its page- prefix.
{!page-format}
Tags
The ones that carry semantics. They go at the end of the comment they belong to.
$ (** Read a key from the configuration.$$ @param cfg the parsed configuration$ @param key the key to look up, case-insensitive$ @return the value, or [None] if the key is absent$ @raise Invalid_argument if [key] is empty$ @since 2.1$ @see 'format.mld' for the file syntax *)$ val get : t -> string -> string option
Deprecation. odoc renders this as a banner, and it is the signal a reader actually notices.
$ (** @deprecated Use {!get} instead, which does not raise. *)$ val get_exn : t -> string -> string
@canonical tells odoc which path is the real home of an item that is reachable by several names, so links point at one place rather than an alias.
$ (** @canonical Config.Key *)
@inline on an included module type folds its contents into the including module, instead of rendering an opaque include.
$ include S (** @inline *)
Standalone pages
Not everything belongs in an interface. A tutorial, a file-format reference, or the landing page for the package goes in a .mld file: the same markup, with no OCaml around it.
doc/index.mld. {0} is the page title, and {!modules:} renders a list with each module's synopsis pulled from its top comment.
$ {0 hello}$$ A small configuration library.$$ {1 Getting started}$$ Parse a file, then read keys from it:$$ {[$ let cfg = Config.parse "app.ini"$ ]}$$ {1 API}$$ {!modules: Config Config.Key}
Register the pages in doc/dune. Name them without the .mld extension.
$ (documentation$ (package hello)$ (mld_files index format))
A file named index.mld becomes the package's landing page, replacing the generated one. Any other name becomes a page you link to with {!page-name}.
$ dune build @doc
Warnings, and keeping it honest
Read what odoc complained about. Broken references and malformed markup both land here, and both are easy to miss in a normal build's output.
$ dune build @doc 2>&1 | grep -i warningWarning: Failed to resolve reference unresolvedroot(Confg) Couldn't find "Confg"
Fail the build on them, so a rename cannot quietly break a link. Worth putting in CI.
$ dune build @doc --release$ dune build @doc -w @doc
OCaml warning 50 catches a doc comment attached to nothing - usually a blank line accidentally left between the comment and the item. Enable it in dune-project.
$ (env$ (dev$ (flags (:standard -w +50))))
ocamlformat leaves doc comments alone unless told otherwise. Turn this on in .ocamlformat so the markup is formatted and, more usefully, so malformed markup is reported.
$ parse-docstrings = true
Browse the documentation for everything installed in the current switch, not just your project.
$ opam install odig$ odig doc$ odig doc yojson
Publishing
The built tree is self-contained static HTML. Copying it is the whole deployment.
$ cp -r _build/default/_doc/_html/* ../site/
To GitHub Pages, via a worktree on the gh-pages branch.
$ git worktree add gh-pages gh-pages$ dune build @doc$ cp -r _build/default/_doc/_html/* gh-pages/$ cd gh-pages && git add -A && git commit -m "docs" && git push
dune-release publishes the docs alongside a release, if you are already using it to cut releases.
$ dune-release publish doc
That is the whole tool: comments in the .mli with the first sentence doing the work of a synopsis, braces for markup, {!references} that odoc resolves against real module paths, .mld files for the prose that has no interface to hang on, and dune build @doc in CI with warnings treated as the breakage they are.