Skip to content
datahacks.dev
YAML

Reading YAML Front Matter From Markdown Files in Python

Split a Markdown file into YAML metadata and body with one regex, then handle dates, CRLF, a BOM, a missing closing line and body text with dashes.

6 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the YAML logo.

Static site generators, documentation tools and note-taking apps keep a bit of YAML at the top of each Markdown file: a title, a date, tags, a draft flag. This is called front matter. If you write a script that builds an index, finds drafts or renames files from their metadata, you need to split the file and parse the top block. The regular expression takes one line and the edge cases take the rest of this post, with each tested.

The format

Markdown
---
title: Hello
date: 2026-10-27
tags: [a, b]
draft: no
---
# Body
Some text

The first line must be exactly three dashes. The block ends at the next line of three dashes (some tools also accept three dots). Everything after that line is the body. Hugo, Jekyll, Eleventy and many others use this convention, and the loader in your script needs to follow the same rules.

The splitter

fm.pyPython
import re
import yaml

FM = re.compile(r"\A?---[ \t]*\r?\n(.*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\Z)(.*)\Z", re.S)

def split(text):
    m = FM.match(text)
    if not m:
        return None, text
    return yaml.safe_load(m.group(1)), m.group(2)

The pattern is anchored at the start (\A), allows an optional byte order mark, requires --- on the first line, captures lazily (.*?) up to the first closing line and captures the rest as the body. re.S lets the dot match newlines. The lazy match is what protects the body: it stops at the first closing marker. Results on a normal file:

Text
normal   meta={'title': 'Hello', 'date': datetime.date(2026, 10, 27), 'tags': ['a', 'b'], 'draft': False}  body='# Body\nSome text\n-...'
{'title': 'str', 'date': 'date', 'tags': 'list', 'draft': 'bool'}

The body starts at # Body and keeps the later --- line, which in Markdown is a horizontal rule, untouched. The metadata types show two YAML conversions to be aware of. date: 2026-10-27 is a datetime.date, because YAML resolves the timestamp. And draft: no is the boolean False, because PyYAML implements YAML 1.1 (the Norway problem and YAML boolean surprises). That is convenient for a draft flag and a trap for any other field that happens to hold no, on or yes as text.

Dates and JSON

If you pass the metadata to json.dumps to build an index, the date breaks it:

Text
json.dumps: Object of type date is not JSON serializable

Three fixes. Convert in the dump: json.dumps(meta, default=str) writes "2026-10-27". Or quote the date in the front matter so it is a string from the start. Or convert deliberately after loading: meta["date"] = meta["date"].isoformat() when it is a date. The last is the most explicit and lets you handle both date and datetime. The first is the least code and also converts other objects you did not expect to str, so check what you get.

A related trap: a date written with a time zone offset, a time of day without a date, or a malformed date such as 2026-13-45 is a string or an error depending on the form. If your site depends on dates, validate them after loading (validating YAML configuration with pydantic works on front matter dictionaries too).

Edge cases, tested

Text
crlf                 meta={'title': 'Win'}                       body='Body\r\n'
bom                  meta={'title': 'Bom'}                       body='Body\n'
no front matter      meta=None                                   body='# Just a heading\nt...'
empty front matter   meta=None                                   body='---\n---\nBody\n'
not closed           meta=None                                   body='---\ntitle: Open\nBo...'
bad yaml             YAMLError mapping values are not allowed here (line 1)

Windows line endings work because the pattern accepts \r?\n. The body keeps its \r\n endings, so normalize them if your output tool cares.

A byte order mark at the start (some Windows editors add one) is allowed by ?. Without it the first line is not --- and the split returns no metadata, a bug that appears only for files from one editor. Open files with encoding="utf-8-sig" to strip the mark at the source.

No front matter returns (None, text). Callers must handle None, for instance with meta or {}.

An empty block (two dash lines in a row) did not match the pattern in this version, because the pattern requires a line break before the closing marker, and the result was no metadata with the body unsplit. If empty blocks occur in your files, adjust the pattern to make the content group optional, and decide whether an empty block means an empty dictionary.

An unclosed block returns no metadata, and the whole file, including the stray ---, is the body. A script that silently treats such a file as having no front matter will hide a typo. Consider raising when the first line is --- but no closing line exists.

Invalid YAML in the block raises a normal YAMLError. Its line number counts from the start of the block, not the file, so line 1 of the error is line 2 of the file. Add the offset (one line for the opening marker, or two with a BOM) before showing it to a person.

Tools that already do this

You do not have to write the splitter. The python-frontmatter package parses front matter in YAML, TOML or JSON and returns a post object with metadata and content, and it handles several of the cases above. yq can read front matter on the command line: yq --front-matter=extract '.title' post.md printed the metadata of a Markdown file in a shell script. Static site generators expose it to templates already. Write your own when you need to control the exact rules, as for a linter that enforces required fields.

A small linter

A checker for a content repository is a useful application: required keys present, dates valid, tags a list, no drafts in the publish directory. Walk the files with glob, split each, validate the dictionary and print one line per problem with the file name. Run it in CI. Because front matter is user-written YAML, everything in this cluster applies: quote titles that contain a colon, quote anything that could be misread, watch for duplicate keys and keep the block short (YAML strings, quoting and when you need quotes). A title such as Fix: the parser is the commonest failure, and it produces "mapping values are not allowed here" on line 1 of the block.

The cluster hub is reading and writing YAML in Python safely. The loading call is explained in loading YAML in Python with PyYAML safe_load. The convention is described in the Jekyll front matter documentation and the library at python-frontmatter. Check a block's types in the YAML to JSON tool.

When a regex splitter is not enough for front matter

The files use several metadata formats. TOML between plus signs and JSON between braces are also used. A library handles all three.

You rewrite the front matter. Editing and writing it back needs a round-trip YAML library to keep comments and order (preserving comments and order with ruamel yaml).

The body contains lines that look like the closing marker. In a fenced code block, a line of three dashes could be mistaken only if it comes before the real closing line, which it cannot. The lazy match is safe for well-formed files.

You need exact positions. For error reporting inside editors, track the offset of the block, not just its content.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.