Skip to content
datahacks.dev
YAML

Parsing YAML in Languages Other Than Python Compared

One YAML document parsed in Node, Go, Java, Ruby, PHP, Rust, C# and yq: types, dates, duplicate keys, strict modes, safety and parse speed, all measured.

17 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the YAML logo.

If you have written YAML code in Python only, the question when you move to another language is whether the same file means the same thing. It does not, reliably. YAML has two live versions with different rules for booleans and numbers, libraries differ on dates and duplicate keys, and every ecosystem has its own idea of what a safe default is. This guide runs one small document through eight tools, records every result, and turns the differences into rules you can apply. The single-language posts in this cluster go deeper; this is the map. The Python side is in reading and writing YAML in Python safely.

The test document

doc.yamlYAML
name: orders
version: 3.10
debug: no
port: 8080
zip: 02134
tags: [web, public]
released: 2026-10-27
nothing: ~

Four keys are chosen to disagree. 3.10 looks like a version, no is a boolean in YAML 1.1 and a string in 1.2, 02134 has a leading zero (octal in 1.1, decimal in 1.2) and 2026-10-27 is a timestamp in 1.1 and a plain string in the 1.2 core schema. The other keys are controls.

What each library made of it

Untyped parsing, meaning into the language's generic structure, with the value for each tricky key:

Librarydebug: nozip: 02134version: 3.10released: 2026-10-27Duplicate keyVersion rules
Node yaml 2.9.1string nonumber 2134number 3.1stringError DUPLICATE_KEY1.2 (1.1 by option)
Node js-yaml 5.5.0string nonumber 2134number 3.1stringNot tested1.2 style
Go yaml.v3string no (untyped)int 1116float64 3.1time.TimeError with lineMixed
Java SnakeYAML 2.4Boolean falseInteger 1116Double 3.1DateLast wins, warning1.1
Ruby Psych 5.2.2falseInteger 1116Float 3.1Date (only if permitted)Last wins, silent1.1
PHP symfony/yamlstring nostring 02134float 3.1int timestampParseException1.2 style
Rust serde_yamlstring nostring 02134float 3.1stringError1.2
C# YamlDotNetstring no (everything is a string)string 02134string 3.10stringLast wins, silentUntyped path keeps text
yq 4.54.1string noint 2134float, prints 3.10stringPrints both keys1.2

Read the table by column. There is no single answer to what no is: three libraries say false or accept it as one, five say it is a string. 02134 has four different readings (1116, 2134, the string, and in C# the string by default). The date is a string, a date object, a timestamp integer or a platform time type. The columns on duplicate keys split the libraries in half. And every one of them agrees on one thing: 3.10 is not preserved as a number, since it is the number 3.1. Only the untyped C# path and the text-keeping behavior of yq hold on to the zero.

The same file therefore describes at least three programs, depending on which library reads it. If the file is read by more than one, quote the values that matter, write booleans as true and false, and test the file with every reader. The version rules are explained in YAML 1.1 versus 1.2, and the boolean case in the Norway problem.

Typed targets remove most of the ambiguity

The second set of results is more encouraging. When the destination is a typed field, most libraries stop guessing and use the type you declared.

LibraryVersion (string field)Zip (string field)Debug (bool field)Unknown keyType mismatch
Go yaml.v33.1002134falseIgnored unless KnownFields(true)cannot unmarshal !!str into int with line
Java SnakeYAML3.1002134falseError ConstructorExceptionConstructorException, property named
Rust serde_yaml3.1002134Error: expected a booleanError with deny_unknown_fieldsinvalid type with line and column
C# YamlDotNet3.1002134falseError unless IgnoreUnmatchedPropertiesTyped conversion

In Go, Java and C#, a string field received the original text, 3.10 and 02134, even though the file had no quotes. In Rust the same held, and a bool field rejected no outright because serde_yaml follows 1.2 strictly. That makes the practical advice uniform: declare a type for every field, make identifiers strings, and turn on strict mode for unknown keys. The destination type, not the YAML text, decides the meaning, and the ambiguity that bites in dynamic languages largely disappears. The cost is keeping the type definitions in step with the file, which is a check you want anyway.

Language by language

Node. The yaml package is the full-featured choice. parse follows YAML 1.2, parseDocument keeps comments and a list of errors, and version: "1.1" switches the rules, which turned our document into debug: false, zip: 1116 and a Date. Duplicate keys throw. js-yaml 5.5.0 gave the same values in default mode and parsed the large file in a fraction of the time. Editing through a Document kept comments but shrank the spacing before a trailing comment and re-indented a list. See reading YAML in Node with the yaml package.

Go. yaml.v3 decodes into structs or maps. Untyped maps give a mixture of rules (string no and octal 1116), and typed structs keep text in string fields and accept no for a bool. KnownFields(true) turned a misspelled portt into field portt not found in type main.Small. An UnmarshalYAML(*yaml.Node) method gives custom validation with the line number, and time.Duration fields decoded 30s directly. See parsing YAML in Go with yaml v3.

Java. SnakeYAML 2.4 implements 1.1, so no is false and 02134 is 1116. Version 2 refused !!java.io.File with Global tag is not allowed, closing the construction hole of 1.x, but still allows duplicate keys with a logged warning unless setAllowDuplicateKeys(false). Typed loading through Constructor kept 3.10 and 02134 in string fields and failed on an unknown property. See reading YAML in Java with SnakeYAML.

Ruby. Psych 5.2.2 is 1.1-based, refuses dates under safe_load until Date is in permitted_classes, refuses aliases until aliases: true, and refuses unlisted classes in both load and safe_load. Duplicates are silent. The writer quoted even the one-letter key n. See reading YAML in Ruby and Rails.

Rust. serde_yaml 0.9.34 is marked deprecated, which is the first thing to know. It is strict and typed: duplicate keys error, deny_unknown_fields lists the allowed names, and a bool field refused no. See parsing YAML in Rust with serde_yaml.

C#. YamlDotNet's untyped path returns strings for everything, which loses nothing and defers interpretation. Typed classes convert. The first thing to trip over is naming: a property Name does not match the key name without CamelCaseNamingConvention. Duplicates are silently last-wins. See reading YAML in C# with YamlDotNet.

PHP. symfony/yaml turns a date into an integer Unix timestamp by default and into a DateTimeImmutable with Yaml::PARSE_DATETIME. A !php/object tag parsed to NULL silently, and with PARSE_EXCEPTION_ON_INVALID_TYPE it raised Object support when parsing a YAML file has been disabled. A small wrapper that sets the flags once makes the choices consistent. See reading YAML in PHP with Symfony Yaml.

Shell. yq prints scalars raw and keeps the original spelling of numbers (3.10). A missing key prints null and exits 0 unless you pass -e, which exits 1, and interpolating a shell variable into an expression let a quote and a pipe rewrite .port, where strenv(NAME) kept the text as data. See parsing YAML in Bash with yq.

Duplicate keys and unknown keys

Two mistakes share a shape: the file says something the program does not use. A repeated key is read twice; a misspelled key is read zero times. The libraries split on both.

For duplicates, Node yaml, Go, Rust and PHP raise errors, and Java, Ruby and C# keep the last value (Java logs a warning, the others say nothing). For unknown keys, every typed path can be made strict, and every default is lenient except Java's Constructor and Rust with the attribute. The practical rule is the same for all languages. Turn on duplicate-key rejection if the option exists, turn on unknown-field rejection on every configuration type, and add yamllint's key-duplicates rule in CI to catch what your parser lets through. The security reason is in YAML security pitfalls: a parser differential on a duplicate key is a way to make a check and a use disagree.

Errors worth having

Error quality differs a lot. Go prints line 2: mapping key "a" already defined at line 1. Rust prints the field path, the received value, the expected type and a line and column. Java and Ruby (both libyaml-style) print did not find expected ',' or ']' while parsing a flow sequence. Node's message for an unclosed bracket was Flow sequence in block collection must be sufficiently indented and end with a ] at line 2, column 1. PHP's was the terse Malformed inline YAML string at line 2. C# gave the property name for a mismatch but no file name. A wrapper that adds the file name to every error, as in the PHP wrapper in the PHP post, is worth writing in any language, because a message with a line number and no file is a puzzle when the program reads six files.

Why the libraries disagree

The table looks arbitrary until you see where the libraries come from. YAML 1.1 was written in 2005, and the C library libyaml and the Python library PyYAML implemented it; Ruby's Psych binds to libyaml, and SnakeYAML, the Java library, began as a port of PyYAML's design. That lineage explains why Python, Ruby and Java agree with each other: no is false, 02134 is octal and 2026-10-27 is a date. YAML 1.2 appeared in 2009 and changed the rules, dropping the extra boolean words and the leading-zero octal, and a later generation of libraries (the Node yaml package, and the Rust and Go libraries) implemented the newer text, or something close to it. Go's yaml.v3 sits in between: its decoder follows 1.2 for untyped booleans yet accepts 1.1 booleans when the target is a bool, and it keeps the octal reading for untyped integers.

Nobody replaced the old rules, so both are in use. Files written for Kubernetes tooling in Go, for Rails, for dbt (which sits on PyYAML) and for Spring Boot (SnakeYAML) therefore live under different rules, and a file shared between them is read under all of them. A few libraries pick a path of their own for a corner: Symfony Yaml follows 1.2 for booleans and numbers but turns dates into Unix timestamps for historical PHP reasons, and serde_yaml keeps a leading-zero number as a string, which is neither version's reading. When you meet an odd result, the explanation is usually lineage, not a bug.

Choosing a library when you have a choice

Most of the time you do not choose; the framework brought one. When you do, the questions that matter are short.

Which rules do your other readers use? If the file is also read by PyYAML-based tools, a 1.1 library in your language gives the same values. If every reader is 1.2-style, pick a 1.2 library. Matching removes surprises.

Does the library preserve comments? If your program edits files that humans maintain, you need a round-trip document model: the Node yaml package's Document, yaml.Node in Go, Ruby's Psych.parse AST. If your program only reads, skip this.

What are the defaults for duplicate and unknown keys? Prefer a library that rejects duplicates, or one with an option you will switch on. A typed path with unknown-field rejection is worth more than speed.

Is it maintained? serde_yaml is marked deprecated, and yaml.v3's maintenance state deserves a look before a new project. A library with no releases for years will not follow the next change in a language or in the YAML ecosystem.

How big are your files? For kilobyte configuration, none of the speed differences matter. For megabytes, measure; for more than that, use a different format.

Does it fail clearly? A library that reports line, column and field name is worth more at 2 a.m. than one that is a little faster.

Mixed-language repositories

A repository with a Python script, a Go service and a Java job that all read config.yaml needs an owner for the file's meaning. The pattern that works is a single schema, most often JSON Schema, that all three validate against in CI. Each service also has its own typed struct, and a test in each language loads the shared file into the struct and asserts the values that matter. When someone adds a key, the schema and the three structs change together, and a mismatch fails a test in the language that missed it. Generating the structs from the schema avoids drift, and tools exist for most languages.

Add the test document from this guide to the same repository as a canary. A fixture file called yaml-canary.yaml with the ambiguous values, plus a test per language that records what its parser returns, costs little and fails on the day a dependency upgrade changes a default. We found while testing for this guide that yq announced a change to its merge-key behavior, that serde_yaml is deprecated and that js-yaml changed what load means between major versions; each of those would have shown up as a failed canary test.

Migrating between libraries

Switching libraries, or upgrading across a major version, is where the differences bite. The checklist is the same each time.

  1. Run the canary file through the old and new versions and diff the results. Differences in booleans, numbers and dates are expected and should be intentional.

  2. Search for the old API. yaml.load without a loader in PyYAML 6, safeLoad in js-yaml 4, YAML.load semantics in Psych 4, and SnakeYAML's new Yaml() in 2.0 all changed meaning.

  3. Run your whole config set through both and compare the loaded data, not the text. The script that prints changed paths from reviewing YAML config changes in a pull request does this.

  4. Check the error paths. A new library may throw different exception types, and a catch for the old one will let errors escape.

  5. Re-test the safety controls. Alias limits, size limits and tag handling differ, and a control that the old library had by default may be absent.

A practical example from this guide: moving a Java service from SnakeYAML 1.x to 2.x closes the arbitrary-class hole and also changes how the default Yaml handles tags, so an application that relied on global tags in its own config files stops working. That is the intended outcome, and you learn it in the canary test and not in production.

What the tests did not cover

This comparison has limits worth stating. It used one document of eight keys. It did not test YAML features such as complex keys, tags other than the object tags, folded and literal block scalars, merge keys across languages or multi-line flow collections; the corner cases there are many, and the libraries differ in them as well. We ran duplicate-key and syntax-error cases in each language, an alias bomb in some, and multi-document parsing in Node only. We did not test Windows line endings, byte order marks or very large scalars. The conclusions above hold for the behaviors we ran and are a guide for the rest, not a guarantee.

Writing YAML back: who quotes what

Reading is half the story. A file written by one library is read by another, and a writer that leaves an ambiguous string bare plants the next reader's bug. We wrote the same six values from each language, among them the strings "3.10", "02134", "no", "n" and "2026-10-27" and the boolean true, and recorded which were quoted:

Writer3.1002134non2026-10-27
Go yaml.v3QuotedQuotedQuotedQuotedQuoted
Java SnakeYAMLQuotedQuotedQuotedBareQuoted
Ruby PsychQuotedQuotedQuotedQuotedNot run
PHP Symfony YamlQuotedQuotedQuotedQuotedQuoted
Rust serde_yamlQuotedQuotedBareBareBare
Node yamlQuotedQuotedBareNot runNot run
C# YamlDotNetBareBareBareBareBare

The spread is wide. Go and PHP quote everything that any version of YAML could misread. Java quotes the 1.1 words it knows about but left the one-letter n bare, which a 1.1 reader takes for a boolean. Rust and Node write for YAML 1.2, so no and a date stay bare, correct under their rules and wrong for a 1.1 reader. And YamlDotNet quoted nothing: the strings 3.10, 02134 and no came out as plain scalars, so any reader that resolves types will read a float, an octal and a boolean, and the round trip changes the data.

The consequence is a rule for writing: do not assume the writer protects you. If the file crosses into another tool, either choose a writer that quotes aggressively (Go, PHP), configure the writer to force quoting of strings if it has an option, or run the output through a check that loads it with the reader that matters and compares with the source. The round-trip test from converting JSON to YAML without losing types works in any language: write, read back with the target parser and compare. In C#, that test fails on our data until you add quoting.

A review checklist for code that reads YAML

When you review a pull request that adds YAML parsing in any of these languages, a short list of questions finds most of the problems we hit in the tests above.

  1. Which function is called, and is it the safe one? safe_load, a default SnakeYAML 2.x Yaml, YAML.safe_load, Yaml::parse without object flags. A call named unsafe_load or an unconfigured constructor from an old tutorial is a finding.

  2. Is the target typed? A struct, a class or a model, with identifiers declared as strings. If the code reads a generic map and then casts, ask why.

  3. What happens to unknown and duplicate keys? Name the setting that rejects them, or the lint rule that catches them in CI.

  4. Where do dates go? Each library in this guide does something different. The code should say what it expects, by quoting in the file, by a flag or by parsing the string itself.

  5. Are errors reported with a file name? A message with only a line number does not help when the program reads several files.

  6. What bounds the input? A size cap before parsing, and an alias or depth limit if the file comes from outside.

  7. Is there a test with a hostile file? One with a duplicate key, an unknown key, no, 02134 and 3.10. The canary file from this guide takes ten lines to add.

  8. Is the write path checked? If the code writes YAML that another tool reads, a round-trip test with the strings that look like other types.

None of these needs a deep knowledge of YAML. They need the habit of asking what the library does with the awkward values, which is the thing the comparison table answers for the eight tools we ran.

Speed

We parsed the same 2.2 MB file of 20,000 small records in each library, in one process, after one warm-up run where the runtime needed it:

LibraryParse time
js-yaml 5.5.0 (Node)108 ms
serde_yaml (Rust, release build)161 ms
yaml.v3 (Go)211 ms
YamlDotNet (C#), second run321 ms
SnakeYAML (Java), second run614 ms
Symfony Yaml (PHP)644 to 682 ms
Psych (Ruby)667 to 699 ms
yaml 2.9.1 (Node)1,537 ms
PyYAML with libyaml (Python)1,170 ms
PyYAML pure Python6,310 ms

The spread is a factor of fifty between the fastest and slowest. Three observations. The numbers are fine for configuration, where files are kilobytes, and matter only if you parse megabytes in a request path. The slow entries are the full-featured ones: the yaml package keeps comments, positions and a full document model, and that costs; js-yaml skips most of it and runs in a tenth of the time. And first runs are slower in JIT runtimes (YamlDotNet took 1,228 ms on its first parse and 321 ms on the second; SnakeYAML 925 and 614), so a short-lived process pays the warm-up. If speed matters, measure with your own data and runtime, and consider moving bulk data out of YAML. Parse-speed comparisons for Python and larger files are in large YAML files and parsing speed.

Safety defaults

The classic YAML vulnerability, a tag that makes the loader construct an arbitrary object, depends on the language. Go and Rust decode into declared types, so the problem does not arise. Java's SnakeYAML 2.x refuses global tags by default, but 1.x did not. Ruby's Psych 4 and later refuses unlisted classes in load as well as safe_load. PHP's Symfony parser refuses object tags unless a flag is on. Node's yaml does not construct arbitrary objects. C# builds only the types in your target graph.

Alias bombs are the second class. Go's yaml.v3 refuses a nine-level bomb with excessive aliasing, SnakeYAML caps aliases at 50 by default, and Ruby's safe loaders reject aliases unless enabled. We did not test Node, Rust, PHP or C# against a bomb for this guide, so treat them as unmeasured and test before you accept untrusted files. Everywhere, cap the input size before parsing. The controls are in limiting alias expansion in a YAML parser and safe loaders in Python, Ruby and Java.

A cheat sheet by task

TaskNodeGoJavaRubyPHPRustC#
Parse a stringYAML.parseyaml.Unmarshalnew Yaml().loadYAML.safe_loadYaml::parseserde_yaml::from_strDeserializer.Deserialize<T>
Strict unknown keysSchema validatorKnownFields(true)Default for ConstructorValidate afterValidate afterdeny_unknown_fieldsDefault; IgnoreUnmatchedProperties relaxes
Reject duplicatesDefaultDefaultsetAllowDuplicateKeys(false)LintDefaultDefaultLint
Dates as objectsSchema optionDefault time.TimeDefault Datepermitted_classes: [Date]PARSE_DATETIMEParse the stringParse the string
Several documentsparseAllDocumentsDecoder looploadAllsafe_load_streamSplit firstDeserializer::from_strParser loop
Keep comments on editDocumentyaml.NodeLimitedPsych.parse ASTNoNoNo

(The multi-document entries for Java, Ruby, Rust and PHP follow the libraries' documentation; we ran the multi-document case only in Node.)

Using the same file in many languages

Teams that share a YAML file across services, such as a deployment config read by a Go operator, a Python script and a Java service, hit all of these at once. A short list of rules makes the file safe for everyone:

  1. Quote every value that is text: versions, zip codes, codes, anything with a leading zero.

  2. Write booleans as lowercase true and false, and never use yes, no, on or off as values or keys.

  3. Write dates as quoted ISO strings if they are meant as text, or accept that each reader will make its own object.

  4. Avoid duplicate keys, and lint for them.

  5. Validate with a schema in CI, run over the output of the loader that matters most (validating YAML before it breaks a deploy).

  6. Keep a test file like the one above in the repository and run it through every parser you use. When a library upgrade changes a result, the test fails instead of production.

When this cross-language comparison does not apply

You control both ends. If one language reads and writes the file, the differences vanish. Use that language's strict, typed path.

The format is JSON in disguise. A YAML file that uses only quoted strings, lowercase booleans, plain numbers and no anchors reads the same everywhere. Strip the cleverness and the problem goes away.

You use a different library. There are many libraries per language. The structure of the test, a document with ambiguous values and typed targets, applies to any of them. Run it.

Your libraries are newer than ours. Versions change defaults; for instance, yq announced a change to its merge-key behavior. Rerun the document after upgrades.

References: the YAML 1.2.2 specification and the YAML 1.1 type repository, which together explain the differences above; the library documentation linked in each language post; and the YAML to JSON converter for a quick look at how a document converts.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.