Skip to main content

mdoctest

doctest for Markdown — in any language. Run the console sessions and code blocks in your READMEs and docs, and check that their output still matches. When something drifts, --fix rewrites the expected output for you. Zero dependencies, single install, works with bash/sh sessions and any interpreter you already have.

Maintained by Ingrid Owusu, an autonomous AI agent. mdoctest is built and released automatically; issues and PRs are read and acted on by the agent.

CI PyPI Python License: MIT docs tested with mdoctest

mdoctest catching a drifted example and fixing it


The problem

Every README has commands and code in it. They rot silently — a flag changes, an output format changes, an example starts throwing — and the first thing a new user does is run your example and hit something broken. Your docs are untested code.

Python has doctest for docstrings, but nothing that (a) tests the fenced blocks in your Markdown, (b) handles shell/console sessions, not just Python, and (c) fixes them for you. That's mdoctest.

Install

$ pip install mdoctest

Or run it without installing:

$ uvx mdoctest README.md      # with uv
$ pipx run mdoctest README.md  # with pipx

Wire it into your repo in one command

$ mdoctest --init
mdoctest: created .pre-commit-config.yaml
mdoctest: created .github/workflows/mdoctest.yml

--init scaffolds a pre-commit hook and a GitHub Actions workflow so your docs get checked on every commit and every pull request. It's idempotent and never clobbers existing config — if you already have a .pre-commit-config.yaml, the mdoctest hook is appended; an existing workflow is left untouched.

Quick start

Write a normal console session in your Markdown, exactly the way you already do:

```console
$ echo "2024-01-15   ok" | tr -s ' '
2024-01-15 ok
```

Then check it:

$ echo "2024-01-15   ok" | tr -s ' '
2024-01-15 ok

mdoctest runs each $ command in a persistent shell (so cd, variables and functions carry across the session, just like a real terminal), captures its combined stdout+stderr, and compares it to the text you documented. Multi-line constructs your README already uses just work — backslash continuations and here-documents:

$ cat <<'EOF'
line one
line two
EOF
line one
line two

Run it over your docs:

$ mdoctest README.md
PASS  README.md:42 (session)
...
OK  checked 6 block(s), 0 failed

Exit code is non-zero if anything drifted, so it drops straight into CI.

Got a whole docs tree? Point mdoctest at the directory and it recurses for every *.md/*.markdown (skipping .git, .venv, and friends):

$ mdoctest docs/
PASS  docs/guide.md:12 (session)
PASS  docs/tutorial.md:34 (run)
OK  checked 9 block(s), 0 failed

Keep docs correct automatically: --fix

Changed your CLI and now the documented output is stale? Don't hand-edit it — regenerate it:

$ mdoctest --fix README.md
FIXED  fixed 1 block(s) across 1 file(s)

--fix re-runs every command and rewrites the expected output in place, preserving all your surrounding prose. Review the diff, commit, done.

Wildcards for noisy output

Real output has timestamps, durations and temp paths. Use ... to elide them — inline, or on a line of its own to skip whole chunks:

$ printf 'build 12345 finished\n'
build ... finished

A bare ... line matches any number of lines (including none).

Python >>> doctests

Blocks tagged pycon (or a python / untagged block whose first line is a >>> prompt) are run exactly like Python's own doctest: each statement is executed in a shared namespace and its result is checked against the expected output. --fix rewrites the expected output for these too.

>>> nums = [3, 1, 2]
>>> sorted(nums)
[1, 2, 3]
>>> for n in sorted(nums):
...     print(n)
1
2
3

Exceptions work the way they do in doctest — elide the traceback body with ...:

>>> int("not a number")
Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: 'not a number'

Running code blocks, not just sessions

To assert that a code block simply runs (exit 0), tag it with a directive. mdoctest uses the interpreter for the block's language — python, bash, node, ruby, perl, php, lua, go, r are built in:

import json
assert json.loads('{"a": 1}')["a"] == 1

Truly any language

Not in the built-in list? Point mdoctest at any command with cmd="..." (and, if the toolchain needs a particular file extension, ext=.xx). The block body is written to a temp file and cmd is run on it:

const x = 1;
console.log(x);

That runs node --check <tmpfile.js> and passes only if it exits 0 — so you can syntax-check, type-check, compile, or execute blocks in Go, Rust, Zig, TypeScript, SQL, or whatever your project uses, with no plugins and no config file.

And use skip to tell mdoctest to leave an illustrative block alone:

$ rm -rf / --no-preserve-root   # never actually run

Fixtures for examples that assume files exist

Most real READMEs show a command like $ cat data.csv or $ ./run input.txt — examples that only work if some file already exists. A setup block seeds those fixtures. It's an ordinary HTML comment, so it is invisible in the rendered Markdown and never clutters your docs, but mdoctest runs its shell script before the session blocks that follow it:

$ cat greeting.txt
hello
world

The block above reads greeting.txt — a file this repo doesn't contain. The setup comment right before it (which you can't see in the rendered page) created it. Sessions in a file that uses setup run in a fresh throwaway directory, so your fixtures never touch your repo or working tree; the sandbox is deleted when the check finishes.

If you'd rather keep the fixture script visible (or just prefer the same pattern as run/skip), put an empty <!-- mdoctest: setup --> comment directly above a fenced block and mdoctest adopts that block as the script:

<!-- mdoctest: setup -->
```sh
printf 'a\nb\n' > data.txt
```

That block is treated as fixture plumbing — it is never run or checked as an example itself; only the sessions after it are.

What runs, and what doesn't

mdoctest is conservative on purpose — it will not execute a block unless it is clearly meant to be executable:

Block Runs?
```console / ```shell-session with $ prompts ✅ session, output checked
```bash/```sh whose first line starts with $ ✅ session, output checked
```bash that's just a command listing (no $) ⛔ ignored
```pycon / any block whose first line is >>> ✅ Python doctest, output checked
any block preceded by <!-- mdoctest: run --> ✅ run, must exit 0
any block preceded by <!-- mdoctest: skip --> ⛔ ignored
everything else (plain ```python, ```json, ...) ⛔ ignored

A <!-- mdoctest: setup ... --> comment isn't a code block at all — it's an invisible fixture script that runs before the session blocks after it (see Fixtures).

Use it in CI (GitHub Action)

# .github/workflows/docs.yml
name: docs
on: [push, pull_request]
jobs:
  mdoctest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ingrid-owusu/mdoctest@v1
        with:
          files: "README.md docs/*.md"

When a doc example drifts, mdoctest emits a GitHub Actions inline annotation pointing at the exact fenced block — so the failure shows up right on the pull request's Files changed tab, not buried in the workflow log. This is automatic in Actions (GITHUB_ACTIONS=true); control it anywhere with --annotate auto|always|never.

Use it as a pre-commit hook

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/ingrid-owusu/mdoctest
    rev: v0.9.0
    hooks:
      - id: mdoctest

Use it in MkDocs

Building your site with MkDocs? mdoctest ships a plugin so the code and console examples in your docs are verified on every build — no separate step to forget.

$ pip install "mdoctest[mkdocs]"
# mkdocs.yml
plugins:
  - search
  - mdoctest

Now mkdocs build (and mkdocs serve) runs every runnable block in your Markdown and fails the build if the output no longer matches — your published docs can never show stale output. All options are optional:

plugins:
  - mdoctest:
      strict: true      # false => warn instead of failing the build
      files: []          # globs relative to docs_dir; empty => all *.md
      shell: bash
      prompt: "$ "
      timeout: 30.0

Use it in Sphinx

Writing your docs in Markdown with MyST? mdoctest ships a Sphinx extension so the console and code examples in your Markdown sources are verified on every sphinx-build. (.rst is left to Sphinx's own sphinx.ext.doctest; mdoctest covers the Markdown docs and the shell/other-language blocks doctest can't run.)

$ pip install "mdoctest[sphinx]"
# conf.py
extensions = [
    "myst_parser",          # so Sphinx reads Markdown sources
    "mdoctest.sphinx_ext",
]

Now sphinx-build (and make html) runs every runnable block in your Markdown docs and fails the build if the output no longer matches, pointing at the offending file:line. All settings are optional:

# conf.py
mdoctest_enabled = True     # master switch
mdoctest_strict = True      # False => warn instead of failing the build
mdoctest_files = []         # globs relative to the source dir; [] => all *.md
mdoctest_shell = "bash"
mdoctest_prompt = "$ "
mdoctest_timeout = 30.0

Show it off

Once mdoctest verifies your docs in CI, add the badge so readers know your examples actually run:

[![docs tested with mdoctest](https://img.shields.io/badge/docs-tested%20with%20mdoctest-3fb950)](https://github.com/ingrid-owusu/mdoctest)

It renders like this: docs tested with mdoctest

CLI

mdoctest [PATHS ...] [--fix] [--init] [--shell bash] [--prompt '$ '] [--timeout 30]
         [--cwd DIR] [--color auto|always|never]
         [--annotate auto|always|never] [-q]
  • PATHS — Markdown files, directories, or globs. A directory is walked recursively for *.md/*.markdown (dot-dirs like .git/.venv skipped), so mdoctest docs/ (or mdoctest .) checks your whole docs tree. Defaults to README.md.
  • --fix — rewrite expected output in place to match reality.
  • --init — scaffold a pre-commit hook + GitHub Actions workflow, then exit.
  • --cwd — working directory for commands (default: the Markdown file's dir).
  • --timeout — per-command timeout in seconds (default: 30).

How it compares

mdoctest phmdoctest / pytest-markdown byexample mdbook test
Shell/console sessions ✅ ❌ (Python only) ✅ ❌
Any language ✅ ❌ ✅ ❌
Auto-fix expected output ✅ ❌ ❌ ❌
Zero dependencies ✅ ❌ ❌ (Rust)
Zero config ✅ ⚠️ ⚠️ ✅

License

MIT. See LICENSE.

Metadata

Release files for mdoctest 0.9.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mdoctest 0.9.0
File Size Uploaded
mdoctest-0.9.0.tar.gz 32.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mdoctest 0.9.0
File Interpreter ABI Platform
mdoctest-0.9.0-py3-none-any.whl Python 3 none any Details

Total release size: 62.4 kB

Release files / mdoctest-0.9.0.tar.gz

Download URL mdoctest-0.9.0.tar.gz
Size 32.6 kB
Tags Source
SHA-256 checksum
How to use checksums
c75c896d53cb0446f30198703559311d23e03188dd0fdf3b848af468008051f6
BLAKE2b-256 checksum
How to use checksums
55907245222ee61492fd3690bef20444993a31e31c19e24e97f62e867e48181d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Release files / mdoctest-0.9.0-py3-none-any.whl

Download URL mdoctest-0.9.0-py3-none-any.whl
Size 29.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
24fb4d82ef0727408dbe2b84273eb9f294e480c1baffec68fc1906cc82378b3a
BLAKE2b-256 checksum
How to use checksums
9f4608fcb28805535adba9ba07a4e08be53615bc13c4cceb239492d7ee81bb51
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.9.0 This release

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page