---
title: "Parse YAML front matter in Python"
slug: yaml-front-matter-python
excerpt: "Parse YAML front matter in Python with python-frontmatter or a short PyYAML split. Tested on dates, broken values, a BOM, CRLF and writing back."
quickAnswer: "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."
keyTakeaways:
  - "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."
publishedDate: 2026-11-01
dateModified: 2026-11-01
author: Bobby Huang
cluster: B
role: post
status: published
faq:
  - q: Does python-frontmatter keep comments and key order when it writes a file?
    a: "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."
  - q: Why does python-frontmatter return no fields for a file that has a header?
    a: "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."
  - q: What type does a front matter date have in Python?
    a: "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."
related:
  - read-yaml-front-matter-as-a-table
  - yaml-front-matter-errors
  - yaml-front-matter-quoting
---

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](yaml-front-matter-in-markdown.md). For a fix to each kind of YAML mistake, see [YAML front matter errors](yaml-front-matter-errors.md). To print a header as a table, see [read YAML front matter as a table](read-yaml-front-matter-as-a-table.md).

## The sample file

Most runs start from this file, saved as `plan.md`:

```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`:

```python
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:

```text
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:

```python
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`:

```text
{}
'# 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:

```text
'---\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:

```yaml
date: 2026-10-19
when: 2026-10-19 14:30:00
quoted: "2026-10-19"
```

Both ways gave the same metadata:

```text
{'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:

```text
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](yaml-front-matter-quoting.md) 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:

```text
'First part.\n\n---\n\nSecond part.\n\n---\n\nThird part.'
```

From the split, with its final newline:

```text
'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](yaml-front-matter-errors.md) 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`:

```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:

```text
mapping values are not allowed in this context
  in "<unicode string>", line 2, column 16
```

From the split:

```text
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](markdown-invisible-characters.md).

**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:

```text
{}
'﻿---\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:

```python
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:

```yaml
---
# 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:

```python
post = frontmatter.load("commented.md")
post["status"] = "done"
frontmatter.dump(post, "commented-out.md")
```

What came back:

```text
---
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:

```text
---
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-19` stayed unquoted, and `quoted: "2026-10-19"` came back as `quoted: '2026-10-19'`, still quoted.
- **CRLF was lost.** Writing `crlf.md` back gave 0 carriage returns.
- **A BOM file gets a second header.** Loading `bom.md` with no encoding, setting `status`, and writing it back gave the bytes below. Reloading that file returned only `{'status': 'done'}`, with the original fields stuck in the body.

```text
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.

1. 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'))"` printed `1.3.0 6.0.3`.
2. Check for a BOM with `python3 -c "print(open('bom.md', 'rb').read(3).hex())"`. It printed `efbbbf`. On `plan.md` it printed `2d2d2d`, the three dashes.
3. 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'}`.
4. Save this as `check_fm.py` to sort a folder's files by what parsed:

   ```python
   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))
   ```

5. 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:

   ```text
   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: 0
   ```

6. Before 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)"`, then `diff 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.
