Index Ai Academy
Phase 7 · Software engineering · Building software

Config files: YAML and TOML

Write YAML and TOML that parse as you meant, dodge the type traps, and validate what you load.

About 25 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A working engineer writes more configuration than they expect: CI workflows, Docker Compose files and Kubernetes manifests are YAML, and Python projects describe themselves in TOML. These files are read by machines that take you literally, so a value that looks right can parse into something else entirely, and the mistake only shows up when a deployment fails. This lesson teaches you to write both formats deliberately, and to check them the reliable way: by parsing them.

What you'll be able to do

  • Write YAML that parses to exactly the structure you meant
  • Spot YAML's traps: surprise booleans, numbers that drop digits, tabs and colons
  • Write a pyproject.toml by hand and read it with tomllib
  • Load configuration safely and validate it before your program uses it

What it covers

  1. Three formats, one job
  2. YAML in five rules
  3. The traps
  4. A workflow that parses as meant
  5. safeload, never load
  6. TOML: types you can see
  7. Write a pyproject.toml
  8. Validate what you load
Quick check
Before any explaining, a guess. A YAML file says country: NO. What does Python's PyYAML library read as the value?
  1. It refuses to parse
  2. None
  3. False
  4. The text "NO"
Show the answer

False

PyYAML follows YAML 1.1, where yes, no, on and off are booleans. Norway's country code becomes False. Quoting it, "NO", keeps it as text.

Three formats, one job

Configuration files hold settings as text, so people can read them, review them in pull requests and keep them in Git. You've met JSON already: strict, unambiguous and noisy to write by hand. YAML drops the braces and quotes and uses indentation instead, which makes it pleasant to read and easy to get subtly wrong. TOML sits in between: explicit about types, with sections in square brackets, and it's the format of pyproject.toml.

In Python, PyYAML's yaml.safe_load and the standard library's tomllib.loads both turn a file's text into the dictionaries and lists you already know, so checking a config means loading it and looking at what you got.

Which format you write is rarely your choice: the tool that reads the file decides. GitHub, Docker Compose and Kubernetes chose YAML for its readability; Python's packaging tools chose TOML for its strictness. What you can choose is how carefully you write, and whether you check the result before it reaches a server. The rest of this lesson gives you the rules for both, the traps that catch experienced engineers, and a loader that refuses bad settings.

Quick check
You're changing how a Python project's tests run, in its pyproject.toml. Which format are you writing?
  1. YAML
  2. TOML
  3. Whichever you prefer
  4. JSON
Show the answer

TOML

The file's name says it: pyproject.toml is TOML, because the packaging tools that read it chose TOML.

YAML in five rules

  1. key: value makes a dictionary entry; a space after the colon is required.
  2. Indentation, with spaces only, shows what belongs inside what.
  3. A line starting with - is an item in a list.
  4. Text usually needs no quotes, but quote it whenever it could be mistaken for something else.
  5. # starts a comment.
import yaml

text = '''
name: tests
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: pip install -r requirements.txt
      - run: pytest -q
'''
config = yaml.safe_load(text)
print(config["jobs"]["test"]["steps"][1])
It runs right here in your browser, with nothing to install.

The printed line shows the loaded shape directly: the second step is a small dictionary with one key, run. Two spaces per level is the usual indentation, and every item at the same level must line up exactly, because the indentation is the structure. A single misplaced space can move a key out of the block you meant, and the file still loads, just into a different shape. That's why the habit of loading and printing a config is worth more than staring at it.

Quick check
A reminder from the dictionaries lesson: a loaded YAML file is ordinary dictionaries and lists. What does this print?
import yaml

config = yaml.safe_load('''
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: ruff check .
      - run: pytest -q
''')
print(len(config["jobs"]["test"]["steps"]))
  1. 1
  2. 3
  3. 4
  4. 2
Show the answer

2

steps is a list with two items, one per dash, so len gives 2.

In your own words
What are YAML's five rules, and why did Norway's code become False?

Write a sentence or two, then compare it with a good answer in the lesson.

The traps

YAML guesses types from how a value looks, and its guesses can surprise you:

  • yes, no, on and off become booleans in PyYAML. GitHub Actions files start with on:, which PyYAML reads as the key True. (GitHub's own parser knows better; your Python script may not.)
  • python: 3.10 is the number 3.1, because a number drops its trailing zero. Versions should be quoted strings.
  • port: 08080 and other values with leading zeros may not be the numbers you expect.
  • A colon followed by a space inside text, as in title: Note: read me, breaks parsing unless the text is quoted.
  • A tab used for indentation is an error.
import yaml
print(yaml.safe_load("python: 3.10\nversion: '3.10'\non: push\ncountry: NO"))

The fix is the same each time: quote the value, then load the file and look.

These traps come from YAML 1.1, the version PyYAML implements. The newer YAML 1.2 dropped the yes/no booleans, but plenty of tools still read files the old way, so write as if they all do. When a value is meant to be text, quoting it costs nothing and removes the guesswork for every reader, human or program.

Quick check
What does this print?
import yaml
print(yaml.safe_load("python: 3.10"))
  1. {'python': 310}
  2. {'python': 3.10}
  3. {'python': '3.10'}
  4. {'python': 3.1}
Show the answer

{'python': 3.1}

Unquoted, 3.10 is a number, and as a float it's 3.1. Write python: "3.10" to keep the version.

Drill

A workflow that parses as meant

Fix the YAML in WORKFLOW so that yaml.safe_load gives a dictionary whose keys are the strings "name", "on" and "jobs", whose Python version is the string "3.10", and whose name is the text CI: tests.

import yaml

WORKFLOW = '''
name: CI: tests
on: push
jobs:
  test:
    python: 3.10
'''

try:
    print(yaml.safe_load(WORKFLOW))
except yaml.YAMLError as e:
    print("Doesn't parse:", type(e).__name__)
In the lesson, your code is checked as you write it.

safe_load, never load

PyYAML can also build arbitrary Python objects from special tags, if you use the full loader. Load an untrusted file that way and it can run code on your machine. yaml.safe_load only ever builds plain dictionaries, lists, strings, numbers, booleans and None, which is all a configuration needs. Use it every time, including for files you wrote yourself, so the habit never fails you on one you didn't. Security reviewers search code for yaml.load( without a safe loader for exactly this reason.

Quick check
Why use yaml.safe_load rather than yaml.load with the full loader?
  1. It keeps the file's comments in the result
  2. It fixes the yes/no boolean trap for you
  3. It parses very large files several times faster
  4. It only builds plain data, so a malicious file can't run code
Show the answer

It only builds plain data, so a malicious file can't run code

Special tags let a file construct Python objects, which can execute code. safe_load refuses them and returns only plain data.

In your own words
Name YAML's traps and say why safe_load.

Write a sentence or two, then compare it with a good answer in the lesson.

TOML: types you can see

TOML spells its types out. Strings are always quoted, so a version can't turn into a number. Square brackets start a table, which becomes a dictionary, and key = value lines fill it. Lists use square brackets with commas.

import tomllib

text = '''
[project]
name = "trail-notes"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["fastapi>=0.110", "httpx"]

[tool.pytest.ini_options]
addopts = "-q"
'''
data = tomllib.loads(text)
print(data["project"]["dependencies"], data["tool"]["pytest"]["ini_options"])

The dotted header [tool.pytest.ini_options] builds nested dictionaries. tomllib only reads TOML; writing it needs a separate package, which is why most people edit pyproject.toml by hand.

Two details trip people up. A key belongs to the most recent table header above it, so the order of sections matters. And a table can only be defined once: writing [project] twice is an error rather than a merge.

Quick check
What does this print?
import tomllib
print(tomllib.loads('[tool.black]\nline-length = 88'))
  1. {'line-length': 88}
  2. {'tool': {'black': {'line-length': 88}}}
  3. {'tool.black': {'line-length': 88}}
  4. {'tool': {'black': 'line-length = 88'}}
Show the answer

{'tool': {'black': {'line-length': 88}}}

A dotted table name nests: tool, then black inside it, holding the key line-length with the number 88.

Drill

Write a pyproject.toml

Write PYPROJECT so that tomllib.loads gives a project table with name "trip-planner", version "1.2.0" (a string), requires-python ">=3.12", and dependencies listing "fastapi" and "sqlalchemy>=2", plus a tool.ruff table whose line-length is the number 100.

import tomllib

PYPROJECT = '''
[project]
name = trip-planner
version = 1.2.0
'''

try:
    print(tomllib.loads(PYPROJECT))
except tomllib.TOMLDecodeError as e:
    print("Doesn't parse:", e)
In the lesson, your code is checked as you write it.
Try it now

You'll write all three. The format is usually chosen for you by the tool that reads the file.

Validate what you load

Parsing proves the file is well formed, not that it says what your program needs. Check the result straight after loading: the keys that must be there, their types, and their ranges. Collect every problem and report them together, with the key's name, so whoever edits the file can fix it in one go instead of finding one mistake per deployment. A program that fails at start-up with "max_tokens must be a whole number from 1 to 8192" saves someone an hour of guessing.

Defaults belong here too. If a setting is optional, fill in its default in one place, the loader, so the rest of the program can rely on every key being present. And keep secrets out of these files entirely: configuration is committed and reviewed, while keys belong in environment variables or a secret manager.

Quick check
A config says max_tokens: 1e3. What does yaml.safe_load give for it, and is that a problem?
import yaml
print(repr(yaml.safe_load("max_tokens: 1e3")["max_tokens"]))
  1. 1000
  2. None
  3. '1e3'
  4. 1000.0
Show the answer

'1e3'

PyYAML's float rule needs a dot, so 1e3 stays the string '1e3'. That is a problem, and a type check catches it at start-up instead of deep inside the first request.

In your own words
Why validate configuration after loading it?

Write a sentence or two, then compare it with a good answer in the lesson.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 6-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

How should a Python version be written in YAML?

  1. Quoted, like "3.10"
  2. Unquoted, like 3.10
  3. As 310
  4. With a trailing dot, like 3.10.

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson