Embercode
✕
Data Formats and TextLesson 5 of 7

45 min read

YAML and TOML config files

Why this matters

A config file holds the settings a program reads when it starts: which port to listen on, which database to connect to, which steps a build job runs. Keeping these settings in a file means you can change them without editing the program's code. Docker Compose, GitHub Actions, Kubernetes, Python projects and Rust projects all use config files like this.

Two formats cover most of them. YAML is common for tools that describe servers, pipelines and deployments. TOML is common for project settings, such as Python's pyproject.toml and Rust's Cargo.toml. Both are plain text, so you can open them in any editor, and both are easy to break with one wrong space or quote mark.

This lesson covers both formats. The JSON lesson earlier in this module covers the braces and quotes format that many APIs use. YAML and TOML are written for people to edit by hand, which is why they have rules about indentation, quotes and typing that JSON does not.

What a config file does

Suppose you run a small online shop. The program needs to know four things: the port it listens on, the database address, the number of database connections it keeps open, and the web addresses it accepts requests from.

You could write those values into the code:

PORT = 8080
DB_HOST = "localhost"

That works until you need a different port on a second server. Then you edit the code, test it again and deploy it again, for a change that is only a number.

A config file moves those values out of the code:

the program starts
  -> reads shop.yaml
  -> uses port 8080

The program stays the same. Only the file changes. The rest of this lesson is about writing that file correctly and reading it from code.

YAML in five minutes

YAML is a text format where indentation shows structure. There are three building blocks.

A mapping is a set of key: value pairs. It is the same idea as a dictionary or an object in other languages. The key is a name, the colon and a space separate it from the value.

A sequence is an ordered list. Each item starts with a dash and a space: - .

A scalar is a single value: a number, a true or false value, or a piece of text.

Here is a small YAML file for the shop:

service:
  name: checkout
  port: 8080
  debug: false
database:
  host: localhost
  port: 5432
  pool:
    min: 2
    max: 10
allowed_origins:
  - https://example.com
  - https://shop.example.com

Save it as shop.yaml. The top level has three keys: service, database and allowed_origins. The lines under service are indented two spaces, so they belong to it. The lines under pool are indented four spaces, so they belong to database. The two dashes under allowed_origins form a list.

A short Python program reads the file. The yaml package is not part of the standard library (the set of modules that ships with Python), so install it first with python3 -m pip install pyyaml. The package name is pyyaml and the import name is yaml.

import yaml
 
with open("shop.yaml", encoding="utf-8") as f:
    config = yaml.safe_load(f)
 
print(config["service"]["port"])
print(config["database"]["pool"]["max"])
print(config["allowed_origins"])

Running it with Python 3.14.4 and pyyaml 6.0.3 prints:

8080
10
['https://example.com', 'https://shop.example.com']

Three things happened. yaml.safe_load turned the text into ordinary Python dictionaries and lists. Each config["..."] step went one level deeper into the nesting. The number 8080 came back as a number, not as text, because YAML guesses types from how a value is written. Use yaml.safe_load, not the older unsafe loaders. The safe version only builds plain data, while the unsafe ones can build Python objects from tags written in the file.

You can see the guessed types directly:

import yaml
 
config = yaml.safe_load("port: 8080\ndebug: false\nname: checkout\n")
for key, value in config.items():
    print(key, type(value).__name__)
port int
debug bool
name str

A value with no quotes is read as a number if it looks like one, as true or false if it says so, and as text otherwise.

Indentation rules

In YAML, indentation carries the structure. These are the rules that matter in practice.

Use spaces, never tabs. YAML does not allow the tab character to indent. Most editors can be set to insert spaces when you press Tab. Check your editor's setting for YAML files before you start.

Children are indented more than their parent. The lines under service: are indented further than service: itself. Sibling keys, which are keys that share the same parent, must start at the same column.

Two spaces per level is the usual choice. YAML accepts any number of spaces, as long as each child is consistent with its siblings. Most files use two spaces, so stay with that unless a project says otherwise.

A dash counts as part of the indentation. Inside a list, the text after - lines up with the next key. Look at this example:

steps:
  - name: install
    run: pip install -r requirements.txt
  - name: test
    run: pytest

The dash sits at column three. The word name sits at column five, after the dash and its space. The line run: sits at column five too, so it belongs to the same list item as name. If run: were at column three, it would start a new item and would not be read as part of the first one.

Read the example back in code to confirm:

import yaml
 
steps_src = """\
steps:
  - name: install
    run: pip install -r requirements.txt
  - name: test
    run: pytest
"""
print(yaml.safe_load(steps_src))
{'steps': [{'name': 'install', 'run': 'pip install -r requirements.txt'}, {'name': 'test', 'run': 'pytest'}]}

Each dash starts one dictionary. Both keys in each dictionary are read as belonging to that dash.

Quoting and text values

Most text does not need quotes in YAML. checkout, localhost and https://example.com all work without them.

Quotes are needed in four cases:

  • The value starts with a character that YAML uses for its own purposes, such as *, &, !, %, @ or a backtick.
  • The value contains a colon followed by a space, or a space followed by #. A # after a space starts a comment, so everything after it is ignored.
  • The value looks like a number, a true or false word, or a null value, but you want it kept as text.
  • The value is empty. Write "" for an empty string.

Here is the last case in practice. The word NO is a common country code. In YAML 1.1 rules, which the PyYAML library follows, the unquoted word NO means false. The next section shows what that does.

Single quotes and double quotes both work. Double quotes allow escape sequences such as \n for a new line. Single quotes take the text exactly as written, apart from a doubled single quote that stands for one quote mark.

Common config files

You will meet these files early. Each one is a YAML or TOML file with a fixed set of keys that its tool expects.

Docker Compose describes containers that run together. The file is usually named docker-compose.yml or compose.yaml. Its top level has a services key.

services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    environment:
      APP_ENV: production
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example

Notice that "8080:80" is quoted. Without the quotes the value still has a colon followed by digits, not a space, so YAML would accept it. The quotes make the intent clear to the reader and avoid surprises in other tools that read the same file.

GitHub Actions runs jobs when code is pushed. Its workflow files live in .github/workflows/ and end in .yml.

name: tests
on:
  push:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python3 -m pytest

The key on names the events that start the workflow. The value in square brackets is a list written on one line, which is a second way to write a sequence.

Kubernetes manifests describe what should run in a cluster of servers. They are YAML files, often ending in .yaml, with keys such as apiVersion, kind and metadata.

pyproject.toml is the project file for Python packages. It holds the project name, its version, its dependencies and the settings of tools such as formatters and test runners. It is written in TOML.

Cargo.toml is the project file for Rust. It lists the package name, the version and the dependencies. It is also TOML.

Look at a file's name and its first lines to tell which tool it belongs to. The tool's documentation lists the keys it reads. A key the tool does not know is usually ignored without any error, which makes typos in key names hard to find. The mistakes section below covers this.

TOML in five minutes

TOML is a format with a simpler shape than YAML. Its rules are about lines and headers, not about indentation. A TOML file is mostly key = value lines, grouped under headers in square brackets.

title = "checkout"
 
[server]
host = "127.0.0.1"
port = 8080
debug = false
 
[database]
url = "postgres://localhost:5432/shop"
pool_size = 10
replicas = ["db-2", "db-3"]
 
[database.timeouts]
connect_seconds = 5
query_seconds = 30

The line title = "checkout" at the top belongs to the whole file, because no header comes before it. The [server] line starts a table, which is TOML's word for a group of keys. Every line after that header belongs to server until the next header.

The header [database.timeouts] creates a table inside database. The dot in the header is a path. The keys under it are read as database then timeouts.

TOML values have a few types. Strings are in double quotes. Integers and floats are plain numbers, and underscores may be used as digit separators, so 1_000_000 is one million. Booleans are true and false, in lower case. Arrays use square brackets, as in ["db-2", "db-3"]. Dates and times are written in a standard form, such as 2026-11-05T09:30:00+05:30.

TOML has no null value. A key is either set or absent.

Read the file with Python's built-in tomllib module. It is part of the standard library from Python 3.11 onward, so nothing needs to be installed. A TOML file must be opened in binary mode:

import tomllib
 
with open("shop.toml", "rb") as f:
    config = tomllib.load(f)
 
print(config["server"]["port"], type(config["server"]["port"]).__name__)
print(config["database"]["timeouts"]["query_seconds"])

Save the TOML example above as shop.toml and run the code. It prints:

8080 int
30

The "rb" mode matters. If you open the file in text mode, the call fails with a clear message:

TypeError: File must be opened in binary mode, e.g. use `open('foo.toml', 'rb')`

When you already have the text in a string, call tomllib.loads instead. It takes a string and skips the file step.

Arrays of tables

Some settings repeat, such as a list of database migrations, each with its own keys. TOML uses a double-bracket header for this:

[[migrations]]
name = "001_users"
 
[[migrations]]
name = "002_orders"

Each [[migrations]] header adds one item to a list called migrations. The code below counts them:

import tomllib
 
text = '[[migrations]]\nname = "001_users"\n\n[[migrations]]\nname = "002_orders"\n'
data = tomllib.loads(text)
print(len(data["migrations"]), data["migrations"][1]["name"])
2 002_orders

Dotted keys inside a table

A key may contain dots. Its meaning depends on the table it sits in. This is a subtle point that often surprises readers, so here is an example that shows it:

[server]
port = 1
 
[server.tls]
cert = "a.pem"
server.port = 2

The last line does not set server.port at the top. It sets port inside the tls table, because the table header [server.tls] is still active. Parsing it gives:

{'server': {'port': 1, 'tls': {'cert': 'a.pem', 'server': {'port': 2}}}}

The rule is that a dotted key is always relative to the current table. If you want to set the value at the top of the file, put the key under a header for that table.

Common mistakes

These mistakes are the ones that show up most often. Each entry gives the message the parser (the code that reads and checks the text) prints. Line and column numbers refer to the small samples used here. Your file will have different numbers, but the message text will match.

YAML mistakes

A tab used for indentation. The most common error with an editor set to tabs.

service:
	name: checkout
while scanning for the next token
found character '\t' that cannot start any token
in "<unicode string>", line 2, column 1

The fix is to replace the tab with spaces. Most editors have a command to convert tabs to spaces for the whole file.

Children that do not line up. The second line is indented four spaces, but it sits under a key that has a value on the same line.

service:
  name: checkout
    port: 8080
mapping values are not allowed here
in "<unicode string>", line 3, column 9

The parser reads port as part of the value of name, and then finds a second colon where it did not expect one. Align name and port at the same column.

A colon inside unquoted text. A colon followed by a space ends a key. Here the title contains one.

title: Note: read this first
mapping values are not allowed here
in "<unicode string>", line 1, column 12

Put the value in quotes: title: "Note: read this first".

A quote that is never closed. The parser reads to the end of the file looking for the closing quote.

message: "hello
while scanning a quoted scalar
found unexpected end of stream

Check that each opening quote has a matching closing quote on the same line.

A missing space after the colon. This one does not produce an error. The line becomes a single text value.

port:8080

Loading this gives:

'port:8080'

The whole line is a plain string, not a key with a value. If a setting seems to be ignored, check for a missing space after its colon.

A value that YAML reads as a boolean. In the older YAML 1.1 rules, which PyYAML follows, the words yes, no, on, off and similar words mean true or false. The code below shows three cases:

import yaml
 
print(yaml.safe_load("country: NO\n"))
print(yaml.safe_load("enabled: yes\n"))
print(yaml.safe_load("enabled: off\n"))
print(yaml.safe_load("on:\n  push:\n    branches: [main]\n"))
{'country': False}
{'enabled': True}
{'enabled': False}
{True: {'push': {'branches': ['main']}}}

The country code NO became False, and the key on became True. The last line is why a GitHub Actions file, whose trigger key is on, can be read as a dictionary with a key that is True. Quote any value or key that might be read this way, for example country: "NO".

A number with a leading digit or a trailing zero. A version number such as 3.10 is read as a float, so the trailing zero is lost:

import yaml
 
print(yaml.safe_load("python: 3.10\n"))
print(yaml.safe_load("mode: 0644\n"))
{'python': 3.1}
{'mode': 420}

The value 3.10 became 3.1. The value 0644, a file permission written in the usual octal style, became the decimal number 420. Quote values that must stay as written: python: "3.10".

The same key twice. This is silent in the PyYAML parser used here. The second value replaces the first.

import yaml
 
print(yaml.safe_load("port: 8080\nport: 9090\n"))
{'port': 9090}

No warning is shown. If a setting does not take effect, search the file for a second line with the same key.

TOML mistakes

TOML's errors are usually easier to read because the parser reports the line and column directly. Here is a short list of the common ones, with the messages the Python parser prints:

import tomllib
 
cases = {
    "colon instead of equals": 'name: "checkout"\n',
    "unquoted string": "name = checkout\n",
    "duplicate key": 'name = "a"\nname = "b"\n',
    "table defined twice": "[server]\nport = 1\n[server]\nhost = 'x'\n",
    "leading zero": "mode = 0644\n",
}
 
for label, src in cases.items():
    try:
        tomllib.loads(src)
    except tomllib.TOMLDecodeError as err:
        print(label, "->", err)
colon instead of equals -> Expected '=' after a key in a key/value pair (at line 1, column 5)
unquoted string -> Invalid value (at line 1, column 8)
duplicate key -> Cannot overwrite a value (at line 2, column 11)
table defined twice -> Cannot declare ('server',) twice (at line 3, column 8)
leading zero -> Expected newline or end of document after a statement (at line 1, column 9)

Here is what each one means in plain terms:

  • Colon instead of equals. TOML uses = between a key and its value. Copying the YAML habit of a colon breaks the line.
  • Unquoted string. TOML strings must be in quotes. checkout on its own is not a valid value. The parser looks for a value, finds a word that is not a number, boolean, date or array, and stops.
  • Duplicate key. A key may appear once in a table. The second name is rejected, not used to replace the first.
  • Table defined twice. A header such as [server] may appear once. Put all the keys for a table under one header.
  • Leading zero. TOML does not allow an integer to start with a zero. Write mode = 644 if the number is decimal, or use a string if the value is a file permission such as "0644".

A tricky mistake is the dotted key from the earlier section, which does not raise an error at all. It nests the value under a table you did not intend. When a setting seems to be ignored, print the parsed dictionary and check its shape.

Practice

Exercise 1

The file below has a tab and a line with the wrong indentation. Fix it so that yaml.safe_load returns the mapping for server.

server:
	port: 8080
	host: localhost

Show the answer

Replace the tab with two spaces on both lines. Both keys then sit at the same column under server:

server:
  port: 8080
  host: localhost

Loading the fixed version gives {'server': {'port': 8080, 'host': 'localhost'}}.

Exercise 2

Without running the code, predict what yaml.safe_load returns for these two lines:

enabled: off
version: 1.10

Show the answer

The result is {'enabled': False, 'version': 1.1}. The word off is read as false under YAML 1.1 rules. The number 1.10 is read as the float 1.1, so the trailing zero disappears. The fix is version: "1.10", which keeps the value as text.

Exercise 3

Convert this TOML file to YAML. Then check that both versions give the same Python data.

[database]
host = "db"
port = 5432

Show the answer

database:
  host: db
  port: 5432

Running tomllib.loads on the TOML and yaml.safe_load on the YAML gives the same dictionary, {'database': {'host': 'db', 'port': 5432}}, so the comparison prints True.

Exercise 4

This TOML file fails. Name the error and say which line causes it. Then fix it so that the port is stored.

[database]
host = "db"
[database]
port = 5432

Show the answer

The error is Cannot declare ('database',) twice, and it is reported at line 3, which is the second [database] header. Remove that second header and move port = 5432 up under the first one:

[database]
host = "db"
port = 5432

Exercise 5

Write a TOML file with two [[servers]] entries, the first named "a" and the second named "b". Load it and print the number of servers and the name of the second one.

Show the answer

import tomllib
 
text = '[[servers]]\nname = "a"\n\n[[servers]]\nname = "b"\n'
data = tomllib.loads(text)
print(len(data["servers"]), data["servers"][1]["name"])
2 b

The double-bracket header adds one item to the list each time it appears, so the list has two entries, and index 1 is the second one.

Summary

  • YAML uses indentation to show structure, and the indentation must use spaces, never tabs.
  • A YAML sequence item starts with - , and the keys in that item line up after the dash.
  • YAML guesses types from how a value is written, so NO becomes false, 3.10 becomes 3.1 and 0644 becomes 420 unless you quote it.
  • TOML uses key = value lines grouped under [table] headers, and its strings must be in double quotes.
  • Read TOML with tomllib, opening the file in binary mode, and read YAML with yaml.safe_load.
  • Most config errors are either a line the parser rejects, which the message points to, or a value that loads without an error but has the wrong type or shape, which you find by printing the parsed data.
Check yourself: Dates and config (8 questions) →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Dates, times and time zones