Todd Mercer

PyYAML safe_load does not reject repeated mapping keys in the tested 6.0.3 environment. But adding a duplicate check after merge expansion can reject a configuration that intentionally overrides a default.
The practical decision is not simply “strict or permissive YAML.” Decide separately whether explicit mapping keys must be unique and whether your consumer permits << merges. Check the original mappings before constructing dictionaries, then assert the merged values.
Aident tested 15 small synthetic configurations against four policies using Python 3.13.14 and PyYAML 6.0.3. The complete fixture below passed all 15 acceptance-vector assertions and eight additional result assertions. These are configuration-policy results, not a security audit or a benchmark of every YAML parser.
The merge override you should not mistake for a duplicate
Consider a service overriding its default port:
Under the tested allow-merge policy, service becomes {"host": "local", "port": 9090}. The service mapping has one explicit port key. The inherited port is not an accidental repeated explicit key.
A flattened-key uniqueness check sees both ports and rejects this fixture. That is a policy mismatch if your consumer allows defaults to be overridden.
By contrast, this source contains two explicit keys and is rejected by both checked policies:
The upstream duplicate-key discussion identifies this interaction between duplicate checking and merges. The YAML 1.1 merge-key draft describes inherited entries yielding to explicit keys, and earlier mappings winning within a merge sequence. It is a merge-extension contract, not a promise that every YAML 1.2 consumer supports it.
What the four policies accepted
“Accepted” means the loader returned without raising for that synthetic input. It does not mean the application schema, required fields, value types, or business rules passed.
Configuration case | Default safe_load | Unique after flattening | Check original keys, allow merge | Check original keys, refuse merge |
|---|---|---|---|---|
Ordinary string-key mapping | Accept | Accept | Accept | Accept |
Duplicate root key | Accept | Reject | Reject | Reject |
Duplicate nested key | Accept | Reject | Reject | Reject |
Same decoded name through a Unicode escape | Accept | Reject | Reject | Reject |
Merge default plus explicit override | Accept | Reject | Accept | Reject |
Two merge sources sharing a key | Accept | Reject | Accept | Reject |
Merge sources plus explicit override | Accept | Reject | Accept | Reject |
Duplicate inside a merge source | Accept | Reject | Reject | Reject |
Two separate merge directives | Accept | Accept | Reject | Reject |
Quoted literal | Accept | Accept | Accept | Accept |
Boolean/integer mapping-key collision | Accept | Reject | Reject | Reject |
Sequence used as a mapping key | Reject | Reject | Reject | Reject |
Recursive alias | Accept | Accept | Reject | Reject |
Unsupported Python object tag | Reject | Reject | Reject | Reject |
Scalar used as a merge source | Reject | Reject | Reject | Reject |
The “unique after flattening” loader is deliberately a diagnostic counterexample, not a recommended validator. It both rejects intended overrides and accepts the repeated merge-directive fixture because its two sources have disjoint resulting keys.
The two checked policies deliberately require string mapping keys, at most one merge directive per mapping, and no recursive aliases. Those are application choices, not universal YAML validity rules.
Assert precedence, not just successful parsing
For the tested version:
The expected service host is first. Adding an explicit host: explicit to the service changes the expected result to explicit. The fixture asserts both results and uses a multi-character key.
Issue 610 explains why merge-sequence precedence differs from repeated-key last-wins behavior. A later upstream main-branch report and proposed fix are reasons to rerun this test when changing versions. This article does not reproduce that branch or claim the report affects the installed 6.0.3 release.
Version numbers, loader classes, input, and recovered values belong in the acceptance record. “It parsed” is too weak to detect a changed default.
Run the complete acceptance fixture
Use a disposable Python environment with PyYAML==6.0.3. The tested run used the pure-Python yaml.SafeLoader, not CSafeLoader. For managed execution, paste the fixture into Code Interpreter Tools, inspect the current Action inputs and spend approval, and wait for its terminal output.
The fixture operates on in-memory synthetic strings. It does not read your workspace, credentials, or customer configuration. The first pass examines the original node graph; the second uses safe_load to construct the result. Duplicate evidence has not already been erased into a dictionary.
Expected output: status is PASS, pyyaml is 6.0.3, all 15 rows match the table, and assertions is 23. The final eight assertions independently check overrides, first-source precedence, a literal merge-looking key, typed-key collapse, the retained Boolean value resolver, and a simple merge.
If the version assertion fails, do not delete it to make the run green. Record the new version, rerun the same cases, and review any changed vector or recovered value.
What this check does not guarantee
The helper accepts a deliberately narrow configuration contract. It leaves PyYAML's value resolution intact: flag: on still becomes a Boolean in the tested environment. Requiring string keys does not require string values.
The true and 1 key fixture collapses to one Python dictionary entry under default loading. The checked policies avoid that class of collision by refusing non-string keys. They do not implement a general resolver for all possible YAML key equivalences.
The input-size, graph-depth, node-count, and cycle checks are fixture boundaries, not a denial-of-service defense. Composition happens before the graph checks. The test does not exercise parser resource exhaustion, huge alias graphs, every tag, all Unicode normalization, multiple-document streams, custom constructors, or alternate loaders. Do not deploy this demonstration as a hardened public upload validator.
PyYAML's documentation distinguishes safe loading from constructors that can create arbitrary Python objects. Staying with SafeLoader avoids those constructors; it does not establish duplicate-key, schema, resource, or consumer correctness by itself.
For real configuration ingestion, define a schema after construction: required keys, allowed value types, ranges, unknown-field policy, and the exact service values you expect. Keep the original input and policy decision available for review, with secrets removed.
Put the test at the consumer boundary
Choose an execution boundary before supplying a real configuration. Code interpreter versus AI-agent sandbox explains why a successful remote fixture does not imply access to your local application or its configuration.
If the resulting configuration becomes a JSON tool payload, apply the downstream decoder's own contract. The Python JSON acceptance test covers duplicate names and number policies that YAML merge rules do not solve. These are separate checks, not interchangeable parser settings.
Before allowing an agent to act on a configuration, keep three artifacts together: the redacted source, the chosen duplicate/merge policy, and the asserted resolved values. When the loader version or consumer policy changes, rerun the entire fixture rather than only the happy-path input.
Run the YAML configuration acceptance fixture with synthetic inputs first. A passing result establishes the tested policy behavior; connecting an integration, executing against real systems, and approving external writes are separate decisions.
About the author

Todd Mercer
Todd Mercer is a senior engineer with more than 10 years of experience working on social media platforms and large-scale ordering systems. He graduated from the University of Illinois Urbana-Champaign in computer science. At Aident, he covers technical issues, developer tools, and technology topics.



The one tool
for every tool
your agent needs.
Give any AI agent real capabilities in seconds. Connect 27,000+ tools once, skip the setup headache, and let your agents execute.


