Config files: YAML and TOML
Write YAML and TOML that parse as you meant, dodge the type traps, and validate what you load.
Create a free account Try part of it here
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
- Three formats, one job
- YAML in five rules
- The traps
- A workflow that parses as meant
- safeload, never load
- TOML: types you can see
- Write a pyproject.toml
- Validate what you load
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.
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
key: valuemakes a dictionary entry; a space after the colon is required.- Indentation, with spaces only, shows what belongs inside what.
- A line starting with
-is an item in a list. - Text usually needs no quotes, but quote it whenever it could be mistaken for something else.
#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])
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.
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"]))
Show the answer
2
steps is a list with two items, one per dash, so len gives 2.
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,onandoffbecome booleans in PyYAML. GitHub Actions files start withon:, which PyYAML reads as the keyTrue. (GitHub's own parser knows better; your Python script may not.)python: 3.10is the number 3.1, because a number drops its trailing zero. Versions should be quoted strings.port: 08080and 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.
import yaml print(yaml.safe_load("python: 3.10"))
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.
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__)
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.
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.
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.
import tomllib print(tomllib.loads('[tool.black]\nline-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.
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)
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.
import yaml print(repr(yaml.safe_load("max_tokens: 1e3")["max_tokens"]))
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.
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
Try a quiz question
How should a Python version be written in YAML?