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.

§ 01

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.

§ 02

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
§ 03

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. *)
§ 04

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}
§ 05

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}
§ 06

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 *)
§ 07

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
§ 08

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 warning
Warning: 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
§ 09

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.