Turning a YAML config file into Python types
A YAML file is easy to write and easy to get wrong. Nothing in the file tells you a port has to be an integer, nothing stops a teammate from renaming max-retries to maxRetries, and nothing warns you that the string on reaches your code as True. Typing the file in Python is how those problems surface at load time instead of at three in the morning. The five styles above are five answers to the same question, and they differ in what they cost you at runtime.
Five ways to land the same file in Python
Pick by what has to happen after the file is read. A script that prints a report wants nothing more than a dict. A service that refuses to boot on a bad config wants real validation.
| Style | What you get | Runtime cost | Reach for it when |
|---|---|---|---|
| Dict literal | The parsed file as Python source | None | You want the values pasted into a test fixture or a notebook, without reading a file at all |
| Dataclass | Typed attributes, __repr__, __eq__ | Standard library only | The config is internal, already trusted, and you want dot access with type checking in the editor |
| TypedDict | Types over the dict PyYAML returns | Nothing at runtime | Existing code already passes the dict around and you do not want to change a single call site |
| Pydantic v2 | Parsing, coercion and errors that name the field | A third party dependency | The file comes from a person, a deploy pipeline, or anywhere a wrong type should stop the process |
| Plain class | An __init__ with no decorators | None | You are on an old Python, or the class needs methods and logic a dataclass would fight you over |
The split worth internalising is between the first three and Pydantic. A dataclass annotation is a note for your editor. Write port: int, hand the class a string, and Python builds the object without a word of complaint. Pydantic is the only option here that reads the annotation at runtime, rejects port: "eight thousand", and tells you which key in which block was wrong.
What PyYAML hands back for each scalar
Conversion starts with the reader, not with the class. These are the values yaml.safe_load produces, and the type of each field follows from them.
| Written in YAML | Python object | Worth knowing |
|---|---|---|
8443 | int | Bare digits, no quotes needed |
12.5 | float | One decimal in a list of whole numbers widens the whole field |
true, on, yes | bool | All three are True in YAML 1.1, which is what PyYAML implements |
~, null, empty | None | An empty value is not an empty string |
2026-08-14 | datetime.date | Unquoted dates are parsed, not left as text |
2026-08-14T09:21:05Z | datetime.datetime | Timezone aware when the file carries an offset |
"8443" | str | Quotes are the only way to keep a numeric string |
| block | str with newlines | Line breaks survive, trailing newline included |
> block | str, folded | Line breaks become spaces, blank lines stay |
[] | list | Empty in the sample means no item type to read |
The one that bites hardest is the country code for Norway. A list of regions holding NO comes back as the boolean False, because YAML 1.1 treats no, off and n as false. Version numbers do the same thing in reverse, since version: 1.20 is the float 1.2 and the trailing zero is gone. Quote both, and the generator types them str.
Keys YAML allows and Python refuses
A YAML key is any string. A Python attribute is not, and config files break that rule constantly, mostly because kebab-case reads well in a file nobody writes code in.
- Dashes and dots.
max-retriesandlog.levelare legal keys and illegal attributes. They becomemax_retriesandlog_level. - Python keywords. A key named
class,import,fromorlambdabecomesclass_. The trailing underscore is what PEP 8 asks for, and it is the same spelling the standard library uses. - Leading digits.
2fa-requiredgets ak_prefix, since no identifier in Python opens with a number. - Names that collide after renaming. A file holding both
log-levelandlog_levelgives one field name twice. The second gets a numeric suffix, and the decision log flags it, because at that point the file itself needs a look.
deploy:host: api.toolexe.com
max-retries: 5
class: standard
2fa-required: true@dataclass
class Deploy:host: str
max_retries: int
class_: str
k_2fa_required: boolRenaming has a cost, and it lands in different places depending on the style. A dataclass built with Deploy(**block) raises TypeError on the original key, so something has to translate. Pydantic solves it properly with Field(alias='max-retries') plus populate_by_name=True, which makes both spellings work. A TypedDict cannot rename at all, since its keys are the dict keys, so the output switches to the functional form Deploy = TypedDict('Deploy', {'max-retries': int}) the moment a key stops being a valid identifier.
Anchors and merge keys are resolved, not repeated
A config that defines a block once and reuses it three times is a good config and a confusing one to type. The reader here does what PyYAML does before your code ever sees the file, which is expand every alias into the value it points at.
defaults: &defaults
timeout-seconds: 12.5
retries: 3
worker:<<: *defaults
queue: exports@dataclass
class Worker:timeout_seconds: float
retries: int
queue: strThe merged keys are written into Worker in full, and a separate Defaults class is written too, since the anchored block is a key in its own right. Two blocks that end up with matching field names and matching types share one class rather than producing near duplicates. Delete the one you do not want after generating, or point both attributes at the same type.
What a list of blocks tells the generator
This is the reason to paste a whole file rather than one trimmed entry. Every item in a list is folded into a single shape before a class is written. A key present in all of them stays required. A key present in some of them comes through as Optional, and the decision log says how many entries were missing it.
feature-flags:- name: new-search
enabled: true
rollout: 0.25
- name: bulk-export
enabled: false@dataclass
class FeatureFlag:name: str
enabled: bool
rollout: Optional[float] = NoneTwo more decisions are visible there. The plural key feature-flags was made singular for the class name, so the attribute is List[FeatureFlag] rather than List[FeatureFlags]. And a field that is whole in one entry and decimal in another widens to float, since int would reject half the file.
Defaults, and the trap underneath them
Turning on Turn the values into defaults writes the values from your file into the class. It also walks straight into the oldest bug in Python configuration code, which is why the output handles lists and nested blocks differently from everything else.
@dataclass
class Config:origins: List[str] = ['https://toolexe.com']
# ValueError: mutable default
# <class 'list'> for field origins@dataclass
class Config:origins: List[str] = field(default_factory=lambda: ['https://toolexe.com'])Dataclasses raise at import time, which is the friendly outcome. A plain class does not. Write def __init__(self, origins=[]) and Python builds that list once, at definition time, then shares it between every instance you ever create. Append to one config and the next one starts with your leftovers. In the plain class style the output passes None and rebuilds the value inside __init__, which is the standard fix.
Loading the file once you have the types
Tick Append a safe_load snippet and the output ends with the code that reads the file. Two details there matter more than the rest.
raw = yaml.safe_load(text)config = Config(**raw)config.database
# {'dsn': '...', 'pool': {...}}
# a plain dict, not Databaseraw = yaml.safe_load(text)config = Config.model_validate(raw)config.database.pool.max
# 20, typed, checkedUnpacking a dict into a dataclass fills the top level and stops. Nested keys stay dicts, so config.database.pool.max raises AttributeError on a value that looks correct in the editor. Either build the nested objects yourself, add dacite to do it for you, or switch to the Pydantic style, where model_validate walks the whole tree.
The second detail is safe_load rather than load. Plain yaml.load without a loader argument can construct arbitrary Python objects from tags in the file, which turns a config file into code execution. Use safe_load for anything you did not write yourself, and for most things you did.
Reading the decision log
The panel under the editors lists what the generator worked out and what it guessed. It is the part to read before pasting the file into a repository. Square markers in green are choices made from solid evidence, such as a renamed key or a resolved merge. Amber markers are places where your sample did not carry enough information, such as an empty list, a field missing from some entries, or a boolean that started life as the word on. Every amber line is a spot where a second sample file would produce better types.
Where this converter stops
- The reader covers block YAML, not every corner of the spec. Mappings, lists, flow collections, block scalars, comments, anchors, aliases and merge keys are handled. Explicit tags such as
!!python/object, complex keys written with?, and multi-line flow collections are not. - Nothing here validates ranges or formats. A port of 99999 and a URL of
bananaare typedintandstrwithout comment. Pydantic gives you somewhere to addField(ge=1, le=65535)afterwards. - Enum-shaped strings stay strings. A driver key holding
redisin your file might accept four values. Two samples cannot show that, and guessing would be worse than leaving it. - Blocks keyed by identifier are typed as records. A file shaped like
{"eu-west": {...}, "us-east": {...}}produces a class with two oddly named fields. What you want isDict[str, Region], and nothing in the YAML says which one it is. - Environment variable placeholders are text. A value of
${DATABASE_URL}is a string here, since expansion happens in whatever tool reads the file later. - Large files slow the tab down. Reading and generating both run on the main thread. Files in the low megabytes are fine. A hundred megabyte export is not, and a trimmed slice of it produces the same types anyway.
Nothing you paste leaves the page. The reader and the generator both run in your browser, so a config carrying a database password or an API token stays on your machine. Load the page once, drop your connection, and everything above still works.
