zuyaml converts between YAML 1.2 and ordinary R objects.
This article documents the conversion rules, which are part of the
package’s contract: they are meant to be predictable rather than clever,
and every place where information is lost is listed at the end.
Why another YAML package
R already has the mature yaml
package, built on LibYAML. zuyaml is not a re-spelling of
it. The differences are deliberate:
-
YAML 1.2 core schema, not 1.1 —
yesandnoare strings. -
Strict by default — duplicate keys are rejected and
partially named lists refuse to emit. Application tags are ignored
unless you ask for
tags = "error". - Explicit multi-document handling — a YAML stream and a YAML sequence are different things, and the API keeps them apart.
- No silent precision loss — integers beyond 2^53 are preserved rather than quietly rounded.
- No system dependency — the C library is bundled and pinned.
If you want compatibility with the existing yaml
package’s behaviour, use that package. zuyaml optimises for
predictability instead.
The API
Eight functions, on one rule: _all means “YAML
stream”.
| one document | stream of documents | |
|---|---|---|
| parse text | yaml_parse() |
yaml_parse_all() |
| emit text | yaml_emit() |
yaml_emit_all() |
| read file | yaml_read() |
yaml_read_all() |
| write file | yaml_write() |
yaml_write_all() |
A YAML stream holds several documents separated by
---. It is a different thing from a sequence, and
the API keeps them apart: yaml_parse() refuses anything but
exactly one document, so trailing documents are never silently
discarded.
yaml_parse("a: 1")
#> $a
#> [1] 1
yaml_parse_all("---\na: 1\n---\nb: 2\n")
#> [[1]]
#> [[1]]$a
#> [1] 1
#>
#>
#> [[2]]
#> [[2]]$b
#> [1] 2
try(yaml_parse("---\na: 1\n---\nb: 2\n"))
#> Error : YAML stream contains 2 documents; use yaml_parse_all().An empty stream is an error rather than NULL, because
NULL is the legitimate result of parsing a document whose
content is null.
Scalars
Scalars follow the YAML 1.2 core schema. The most
visible consequence is that yes and no are
strings, not booleans — that was a YAML 1.1 rule.
str(yaml_parse("[null, true, 42, 3.14, hello, yes, 2026-09-07]"))
#> List of 7
#> $ : NULL
#> $ : logi TRUE
#> $ : int 42
#> $ : num 3.14
#> $ : chr "hello"
#> $ : chr "yes"
#> $ : chr "2026-09-07"Dates stay strings: turning them into Date would be
policy beyond a parser’s job. Quoted scalars are always strings, so
quoting is how you protect a value that would otherwise look like a
number.
str(yaml_parse('[42, "42"]'))
#> List of 2
#> $ : int 42
#> $ : chr "42".inf and .nan map to Inf and
NaN, and an empty quoted string stays a string
rather than becoming NULL.
str(yaml_parse('["", ~, .inf]'))
#> List of 3
#> $ : chr ""
#> $ : NULL
#> $ : num InfLarge integers
R has no exact integer type beyond 2^53. Rather than round silently,
values past that point become a zuyaml_bigint, a character
vector holding the decimal value.
x <- yaml_parse("9223372036854775807")
x
#> <zuyaml_bigint>
#> [1] 9223372036854775807
class(x)
#> [1] "zuyaml_bigint"It carries the decimal normalisation, not the source text, so hexadecimal input compares equal to its decimal spelling:
as.character(yaml_parse("0x7FFFFFFFFFFFFFFF"))
#> [1] "9223372036854775807"Use big_integers to choose a different policy —
"double" opts in to losing precision, "error"
refuses the document.
yaml_parse("9223372036854775807", big_integers = "double")
#> [1] 9.223372e+18Sequences and mappings
A sequence becomes a list, and a mapping with scalar keys becomes a named list.
str(yaml_parse("- 1\n- two\n- true\n"))
#> List of 3
#> $ : int 1
#> $ : chr "two"
#> $ : logi TRUE
str(yaml_parse("host: localhost\nport: 8080\n"))
#> List of 2
#> $ host: chr "localhost"
#> $ port: int 8080simplify = TRUE collapses sequences whose elements are
all scalars of one type. It is not the default, because
it makes the shape of the result depend on the contents of the document:
[1, 2] would give an integer vector while
[1, "a"] gives a list, so code that indexes the result
would be correct only for the inputs its author happened to test.
str(yaml_parse("[1, 2, 3]"))
#> List of 3
#> $ : int 1
#> $ : int 2
#> $ : int 3
str(yaml_parse("[1, 2, 3]", simplify = TRUE))
#> int [1:3] 1 2 3Duplicate keys
Rejected by default, because the resulting object would be ambiguous to index.
try(yaml_parse("a: 1\na: 2\n"))
#> Error : Duplicate mapping key 'a'; set duplicate_keys = TRUE to allow duplicates.Non-string scalar keys are stringified, so 1 and
"1" would collide as R names. That collision is caught too
rather than quietly producing duplicate names.
str(yaml_parse("1: a\n2: b\n"))
#> List of 2
#> $ 1: chr "a"
#> $ 2: chr "b"
try(yaml_parse('1: a\n"1": b\n'))
#> Error : Duplicate mapping key '1'; set duplicate_keys = TRUE to allow duplicates.Keys that are not scalars
YAML allows a sequence or mapping to be a key. Such a mapping cannot
become a named list without destroying structure, so it becomes a
zuyaml_map: two parallel lists, keys and
values.
yaml_parse("? [one, two]\n: value\n")
#> <zuyaml_map> 1 pair
#> key: list("one", "two")
#> value: "value"Anchors and aliases
Aliases are resolved to the value of their target. Node identity is not preserved — R has no portable way to express it.
str(yaml_parse("defaults: &d\n timeout: 30\nserver: *d\n"))
#> List of 2
#> $ defaults:List of 1
#> ..$ timeout: int 30
#> $ server :List of 1
#> ..$ timeout: int 30Cycles are refused, and aliases = "error" rejects
aliases outright.
Tags
A tag overrides schema resolution: !!str 12 is the
string, not the integer. Core schema tags are honoured in every spelling
— the shorthand !!str, the verbatim
!<tag:yaml.org,2002:str>, and a handle bound by a
%TAG directive — and the non-specific tag !
forces string resolution too.
yaml_parse("!!str 12")
#> [1] "12"
yaml_parse("! 12")
#> [1] "12"
yaml_parse('!!int "12"')
#> [1] 12A core tag on text that does not conform to it resolves as a string, which is what an unresolvable scalar is in YAML.
yaml_parse("!!float abc")
#> [1] "abc"Application tags such as !duration are ignored by
default: the value is converted as though it carried no tag. Refusing
them would reject a great deal of ordinary YAML — the specification’s
own examples carry application tags — so tags = "error" is
opt-in, for callers who would rather hear about the semantics they are
dropping.
yaml_parse("v: !duration 5m")
#> $v
#> [1] "5m"
try(yaml_parse("v: !duration 5m", tags = "error"))
#> Error : Unsupported YAML tag '!duration' at line 1, column 4.A %TAG directive that rebinds a handle is honoured, so
the shorthand no longer names a core tag.
yaml_parse("%TAG !! tag:example.com,2000:app/\n---\n!!int 1 - 3\n")
#> [1] "1 - 3"Emitting
cat(yaml_emit(list(host = "localhost", port = 8080L, tls = TRUE)))
#> host: localhost
#> port: 8080
#> tls: trueStrings that would resolve as another type under the core schema are quoted, so they survive a round trip:
cat(yaml_emit(list(version = "42", ratio = 0.5, name = "true")))
#> version: "42"
#> ratio: 0.5
#> name: "true"Ambiguous input is refused rather than guessed at. A partially named list has no obvious meaning as either a sequence or a mapping:
try(yaml_emit(list(a = 1, 2)))
#> Error : Cannot emit a partially named list or vector: element 2 has no name. Name every element or none.
try(yaml_emit(data.frame(a = 1)))
#> Error : Data frames are not emitted automatically; convert explicitly to a list.list() cannot express both an empty sequence and an
empty mapping, so it emits as [], and an empty
named list is how you ask for {}.
Untrusted input
Three limits apply, all enforced by zuyaml itself:
-
max_size— input bytes; -
max_depth— nesting depth; -
max_nodes— total values materialised.
The third exists because the first two do not bound alias expansion. A billion-laughs document is small and shallow by construction, yet expands exponentially:
bomb <- paste0(
"a: &a [x,x,x,x,x,x,x,x,x]\n",
"b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]\n",
"c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]\n",
"d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]\n",
"e: [*d,*d,*d,*d,*d,*d,*d,*d,*d]\n"
)
nchar(bomb)
#> [1] 163
try(yaml_parse(bomb, max_nodes = 1000))
#> Error : Document exceeds the node limit (1000); see max_nodes.Errors
Failures are classed conditions carrying a stable code and a position, so callers never have to match on message text.
err <- tryCatch(yaml_parse("a: [1, 2"), zuyaml_error = identity)
err$code
#> [1] "syntax"
err$line
#> [1] 2
err$column
#> [1] 1Numbers at the edges
A number outside what a double can hold still has a well-defined nearest value, so it is converted rather than refused or turned into text. One extreme value does not change the type of a field:
str(yaml_parse("[1e309, -1e309, 1e-324, 1e-323]"))
#> List of 4
#> $ : num Inf
#> $ : num -Inf
#> $ : num 0
#> $ : num 9.88e-324Text that is not a number is still text — the generosity is about numbers only:
str(yaml_parse("[!!float abc, 1e309 and more]"))
#> List of 2
#> $ : chr "abc"
#> $ : chr "1e309 and more"Integers are the one place this package is deliberately less
generous. Beyond 2^53 an integer becomes a zuyaml_bigint
rather than a rounded double, because a silently rounded integer in a
configuration file is a wrong number someone will diff.
big_integers = "double" opts out.
str(yaml_parse("id: 9007199254740993"))
#> List of 1
#> $ id: 'zuyaml_bigint' chr "9007199254740993"
str(yaml_parse("id: 9007199254740993", big_integers = "double"))
#> List of 1
#> $ id: num 9.01e+15What does not round-trip
Every entry below is a deliberate trade-off, not an oversight.
| Construct | Behaviour | Round-trips? |
|---|---|---|
| Comments | Discarded | No |
| Quoting and block style | Re-chosen on emit | Semantically |
| Anchors and aliases | Resolved to values | Semantically |
| Tags | Applied on parse, then discarded | No |
| Non-string scalar keys | Stringified (1 → "1") |
Semantically |
| Collection-valued keys |
zuyaml_map on parse |
No — cannot be emitted |
| Duplicate keys (opt-in) | Duplicate R names | No — cannot be emitted |
NA |
Emitted as an explicit null, parses back as
NULL
|
No |
list(1L) vs c(1L)
|
Both emit - 1
|
No |
big_integers = "double" |
Precision lost | No (opt-in) |
Everything else does round-trip, including the cases that are easy to
get wrong. Strings with a line break or with leading or trailing
whitespace are quoted, mapping keys are treated exactly like values, and
a whole-numbered double emits as 1.0 so that it does not
come back as an integer:
x <- list(notes = "first line\nsecond line\n", padded = " keep me ",
ratio = 2, ` spaced key ` = 1L)
cat(yaml_emit(x))
#> notes: "first line\nsecond line\n"
#> padded: " keep me "
#> ratio: 2.0
#> " spaced key ": 1
identical(yaml_parse(yaml_emit(x)), x)
#> [1] TRUEzuyaml is a semantic parser, not a round-trip editor: it
converts values, and formatting is not part of the value. Preserving
comments and layout would need a different API built on a retained
document.