Parse YAML front matter in Python
Bobby Huang ·
Call frontmatter.load(path) from the python-frontmatter package, or split the file on its dash lines and pass the header to yaml.safe_load. Both returned the same fields for a normal file, a datetime.date for an unquoted date, and a yaml.YAMLError for a malformed value. They differed on a file with a BOM, where python-frontmatter found no fields until it got encoding="utf-8-sig". Writing back with python-frontmatter sorted the keys by default and dropped comments.
Key takeaways
- frontmatter.load and a short yaml.safe_load split returned the same four fields for a normal file.
- An unquoted date came back as datetime.date from both, and json.dumps raised a TypeError on it.
- A malformed value raised yaml.scanner.ScannerError from both, so one except yaml.YAMLError catches them.
- On a file with a BOM, python-frontmatter returned no fields and no error until it was given encoding="utf-8-sig".
- frontmatter.dump sorted keys by default. sort_keys=False kept their order, but comments were gone either way.
To read a Markdown file's YAML front matter in Python, call frontmatter.load(path) from the python-frontmatter package, or write a short split of your own and hand the header to yaml.safe_load. This post runs both on the same files. On a normal file they agree. They part ways on a byte order mark, an unclosed header and a dash line at the top of the body.
Everything quoted here ran on a Mac with Python 3.14.7, PyYAML 6.0.3 with libyaml, and python-frontmatter 1.3.0. Nothing reaches past those versions or past YAML headers: the package's TOML and JSON support wasn't tried. For the rules of front matter itself, see the YAML front matter guide. For a fix to each kind of YAML mistake, see YAML front matter errors. To print a header as a table, see read YAML front matter as a table.
The sample file
Most runs start from this file, saved as plan.md:
---
title: Settings page dark theme
status: in-progress
owner: Jane Doe
date: 2026-10-19
---
# Settings page dark theme
Body text.
Way 1: the python-frontmatter package
The package name is python-frontmatter, the import is frontmatter, and its metadata lists PyYAML as a requirement. This script reads plan.md:
import frontmatter
post = frontmatter.load("plan.md")
print(post["title"])
print(post.get("reviewer", "none set"))
print(post.metadata)
print(repr(post.content))
It printed:
Settings page dark theme
none set
{'title': 'Settings page dark theme', 'status': 'in-progress', 'owner': 'Jane Doe', 'date': datetime.date(2026, 10, 19)}
'# Settings page dark theme\n\nBody text.'
post.metadata is a plain dict, and post.content is the body. That body has been stripped at both ends: the final newline is gone. The start gets the same treatment, so a test file whose body opened with a four-space code line came back as print('hi') with no indent.
Way 2: a short PyYAML split
If you'd rather not add a package, this is the whole job:
import yaml
def split_front_matter(path):
with open(path, encoding="utf-8-sig") as f:
text = f.read()
lines = text.split("\n")
if lines[0].rstrip() != "---":
return {}, text
for i, line in enumerate(lines[1:], start=1):
if line.rstrip() == "---":
meta = yaml.safe_load("\n".join(lines[1:i]))
if meta is None or isinstance(meta, dict):
return meta or {}, "\n".join(lines[i + 1:]).lstrip("\n")
return {}, text # the opening --- was a rule, not a header
raise ValueError(f"{path}: no closing --- line")
On plan.md it returned the same four fields, with the body '# Settings page dark theme\n\nBody text.\n'. Unlike the package, it keeps the final newline.
Use yaml.safe_load, because it builds plain values only: a test header with the tag !!python/object/apply:os.getcwd [] raised could not determine a constructor for the tag instead of calling anything. The package's source also sets a safe loader by default, and it raised the same error on that file.
What each one did
| Test file | python-frontmatter | The PyYAML split |
|---|---|---|
| No header | {}, whole text as body |
{}, whole text as body |
| Header never closed | {}, whole text as body, no error |
ValueError |
| Unquoted date | datetime.date |
datetime.date |
--- line later in the body |
Body kept whole | Body kept whole |
--- line at the top, no header |
{}, text above the next rule lost |
{}, whole text as body |
| Malformed value | ScannerError |
ScannerError |
| BOM | {}, no error, header left in body |
Four fields |
| CRLF | Four fields | Four fields |
The sections below quote the runs behind each row.
No front matter
A file with no header, no-header.md, gave this from frontmatter.load:
{}
'# Settings page dark theme\n\nBody text.'
The split returned {} and the same text with its final newline. So far, no difference.
The difference comes with a header that opens and never closes. Given unclosed.md, which has an opening --- and no second one, the package returned {} and this body, with no error:
'---\ntitle: Notes\nstatus: draft\n\nBody.'
The split raised ValueError: unclosed.md: no closing --- line. Also, frontmatter.check("unclosed.md") returned True, so that check doesn't tell you a header was parsed. Check post.metadata when you need to know.
A date value
The test file date.md holds a date, a date with a time, and a quoted date:
date: 2026-10-19
when: 2026-10-19 14:30:00
quoted: "2026-10-19"
Both ways gave the same metadata:
{'date': datetime.date(2026, 10, 19), 'when': datetime.datetime(2026, 10, 19, 14, 30), 'quoted': '2026-10-19'}
The date object isn't a string, and that shows up in two places. post["date"] == "2026-10-19" returned False, while post["date"].isoformat() == "2026-10-19" returned True. And json.dumps(post.metadata) stopped with this:
TypeError: Object of type date is not JSON serializable
json.dumps(post.metadata, default=str) ran and printed the dates as text. To keep a value as text from the start, quote it in the header: YAML front matter quoting covers each case.
A --- inside the body
The file rule.md has a real header, then two --- rules in the body. Both ways returned {'title': 'Notes'} and kept the whole body, rules included. From python-frontmatter:
'First part.\n\n---\n\nSecond part.\n\n---\n\nThird part.'
From the split, with its final newline:
'First part.\n\n---\n\nSecond part.\n\n---\n\nThird part.\n'
A title of before---after was also fine in both, because each looks for a --- line and not for three dashes anywhere. A plain text.split("---", 2) on that same file gave {'title': 'before'}. The errors post has a one-liner of that kind and states the limit.
The trouble is a file with no header that opens on a rule. Here is rule-first.md:
---
First part.
---
Second part.
The package returned {} and the body 'Second part.'. "First part." wasn't in post.metadata or post.content, so writing this post back would lose it. The split returned {} and the whole text, because of its isinstance check.
That check only goes so far. Change the first part to Note: read this first. and it parses as a mapping. Both ways then returned {'Note': 'read this first.'} and the body Second part., with a final newline only from the split, because neither can tell that line from a real header.
A malformed value
The file bad.md has an unquoted colon, title: Settings: dark theme, on its line 2. Both ways raised yaml.scanner.ScannerError. From python-frontmatter:
mapping values are not allowed in this context
in "<unicode string>", line 2, column 16
From the split:
mapping values are not allowed here
in "<unicode string>", line 1, column 16:
title: Settings: dark theme
^
The wording changes with the loader. Run on that one line with no front matter code, yaml.CSafeLoader gave the first wording and yaml.SafeLoader the second, and yaml.safe_load in the split uses the second. A check that matches on the message would be fragile, so catch the class. except yaml.YAMLError caught both in this test.
The line number differs too. The package's line 2 matched the file. The split hands YAML only the lines after the opening ---, so add 1 to its number.
A BOM or CRLF line endings
The fixture bytes were checked first: bom.md starts efbbbf, against 2d2d2d for plan.md, and crlf.md has 10 carriage returns against 0. To check your own files by eye, see invisible characters in Markdown.
CRLF made no difference to reading. Both ways returned the same four fields, and both bodies came back with plain \n endings. In the split, that's Python's text mode at work.
A BOM did. frontmatter.load("bom.md") returned no error and these results:
{}
'---\ntitle: Settings page dark theme\nstatus: in-progress\nowner: Jane Doe\ndate: 2026-10-19\n---\n\n# Settings page dark theme\n\nBody text.'
The mark sits before the first dash, so the package didn't see a header. Passing the encoding fixed it, and on bom-crlf.md as well:
post = frontmatter.load("bom.md", encoding="utf-8-sig")
That returned all four fields and a clean body. The same call on plan.md and crlf.md gave the same results as without it. The split opens files with utf-8-sig for this reason. With plain utf-8, the first line read as --- and failed the dash check.
Writing back with python-frontmatter
The test file commented.md has two comments, keys in no particular order and a one-line list:
---
# who owns this file
title: Settings page dark theme
status: in-progress # change when merged
owner: Jane Doe
date: 2026-10-19
tags: [ui, settings]
---
Body text.
This changed one field and wrote the file back:
post = frontmatter.load("commented.md")
post["status"] = "done"
frontmatter.dump(post, "commented-out.md")
What came back:
---
date: 2026-10-19
owner: Jane Doe
status: done
tags:
- ui
- settings
title: Settings page dark theme
---
Body text.
Key order did not survive. The keys were sorted. Comments did not survive: both are gone. The one-line list became a block list, and the file's last newline was removed.
Adding sort_keys=False to the frontmatter.dump call brought the order back, and nothing else:
---
title: Settings page dark theme
status: done
owner: Jane Doe
date: 2026-10-19
tags:
- ui
- settings
---
Body text.
Three more results from writing back:
- The date stayed a date.
date: 2026-10-19stayed unquoted, andquoted: "2026-10-19"came back asquoted: '2026-10-19', still quoted. - CRLF was lost. Writing
crlf.mdback gave 0 carriage returns. - A BOM file gets a second header. Loading
bom.mdwith no encoding, settingstatus, and writing it back gave the bytes below. Reloading that file returned only{'status': 'done'}, with the original fields stuck in the body.
b'---\nstatus: done\n---\n\n\xef\xbb\xbf---\ntitle: Settings page dark theme\nstatus: in-progress\nowner: Jane Doe\ndate: 2026-10-19\n---\n\n# Settings page dark theme\n\nBody text.'
With encoding="utf-8-sig" on the load, the same write gave one normal header, with no BOM.
Steps to check your own files
Every command below ran on the test files, except the install in step 1: both packages were already on the test machine.
Install the packages if you need them:
python3 -m pip install python-frontmatter PyYAML. Then check what you have.python3 -c "import importlib.metadata as m; print(m.version('python-frontmatter'), m.version('PyYAML'))"printed1.3.0 6.0.3.Check for a BOM with
python3 -c "print(open('bom.md', 'rb').read(3).hex())". It printedefbbbf. Onplan.mdit printed2d2d2d, the three dashes.See the types you'll get with
python3 -c "import frontmatter; p = frontmatter.load('date.md', encoding='utf-8-sig'); print({k: type(v).__name__ for k, v in p.metadata.items()})". It printed{'date': 'date', 'when': 'datetime', 'quoted': 'str'}.Save this as
check_fm.pyto sort a folder's files by what parsed:import sys import frontmatter import yaml for path in sys.argv[1:]: try: post = frontmatter.load(path, encoding="utf-8-sig") except yaml.YAMLError as e: print(path, "BAD:", str(e).splitlines()[0]) continue print(path, "fields:", len(post.metadata))Run it with
python3 check_fm.py plan.md bom.md bad.md no-header.md unclosed.md. A count of 0 means no header was read, which covers both a missing header and an unclosed one:plan.md fields: 4 bom.md fields: 4 bad.md BAD: mapping values are not allowed in this context no-header.md fields: 0 unclosed.md fields: 0Before you trust a write-back, write to a new file and compare. Run
python3 -c "import frontmatter; frontmatter.dump(frontmatter.load('commented.md'), 'roundtrip.md', sort_keys=False)", thendiff commented.md roundtrip.md. Here the diff listed the removed comment, the shortened status line, the block list and\ No newline at end of file, and exited 1.
Where Sashmark fits
Sashmark is a Markdown reader and editor for people whose AI agents write Markdown. It is in development; nothing has been released. There are no AI features in the app.
Questions
- Does python-frontmatter keep comments and key order when it writes a file?
- It dropped comments in every run here. Keys came back sorted by default, and passing sort_keys=False to frontmatter.dump kept the original order. It also turned a one-line list into a block list and removed the file's last newline.
- Why does python-frontmatter return no fields for a file that has a header?
- In this test the cause was a byte order mark. The file's first bytes were the mark, not the dashes, so the package returned empty metadata and left the header in the body. frontmatter.load(path, encoding="utf-8-sig") read all four fields.
- What type does a front matter date have in Python?
- An unquoted 2026-10-19 came back as datetime.date, and a value with a time came back as datetime.datetime. A quoted date stayed a str. Comparing that date object to the string 2026-10-19 gave False.