Skip to content

Fix: option KnownFields not being respected inside custom UnmarshalYAML()#332

Open
stoewer wants to merge 10 commits into
yaml:mainfrom
stoewer:fix-known-fields
Open

Fix: option KnownFields not being respected inside custom UnmarshalYAML()#332
stoewer wants to merge 10 commits into
yaml:mainfrom
stoewer:fix-known-fields

Conversation

@stoewer
Copy link
Copy Markdown

@stoewer stoewer commented Apr 14, 2026

When a type implements UnmarshalYAML and calls node.Decode() inside it, the
loader options (e.g. KnownFields) were silently dropped. Node.Decode
always constructed a new decoder from DefaultOptions, so unknown fields would
pass through unchecked regardless of what the caller configured.

Fixes #321

The fix walks the node tree after parsing and sets a snapshot of the loader's
options onto each node. Node.Decode then picks up the snapshot instead of
falling back to defaults.

The same propagation is applied in ComposeAndResolve, which previously had
the same gap.

The cost is one addition Options struct copy per document and an O(N)
pointer-write walk over the node tree. Benchmarks show little to low sec/op and allocs/op
impact (not reliably measurable); B/op is up ~4% due to an extra pointer field on the Node struct.

Benchmark results (n=10, benchstat)
goos: linux
goarch: amd64
pkg: go.yaml.in/yaml/v4
cpu: AMD Ryzen AI 9 HX PRO 370 w/ Radeon 890M       
                                                       │  baseline   │                fix                 │
                                                       │   sec/op    │   sec/op     vs base               │
Decode/target=plain/option=default/size=small-4          8.951µ ± 1%   9.028µ ± 1%       ~ (p=0.060 n=10)
Decode/target=plain/option=known-fields/size=small-4     9.052µ ± 1%   9.141µ ± 2%  +0.98% (p=0.035 n=10)
Decode/target=custom/option=default/size=small-4         9.299µ ± 1%   9.527µ ± 2%  +2.46% (p=0.000 n=10)
Decode/target=custom/option=known-fields/size=small-4    9.357µ ± 1%   9.661µ ± 1%  +3.25% (p=0.000 n=10)
Decode/target=plain/option=default/size=medium-4         78.10µ ± 1%   78.74µ ± 1%  +0.81% (p=0.023 n=10)
Decode/target=plain/option=known-fields/size=medium-4    78.65µ ± 1%   78.82µ ± 1%       ~ (p=0.579 n=10)
Decode/target=custom/option=default/size=medium-4        78.10µ ± 1%   79.10µ ± 3%  +1.28% (p=0.004 n=10)
Decode/target=custom/option=known-fields/size=medium-4   78.13µ ± 1%   78.92µ ± 2%  +1.02% (p=0.035 n=10)
Decode/target=plain/option=default/size=large-4          1.665m ± 3%   1.686m ± 1%  +1.21% (p=0.043 n=10)
Decode/target=plain/option=known-fields/size=large-4     1.675m ± 1%   1.674m ± 1%       ~ (p=0.853 n=10)
Decode/target=custom/option=default/size=large-4         1.671m ± 1%   1.666m ± 1%       ~ (p=0.739 n=10)
Decode/target=custom/option=known-fields/size=large-4    1.663m ± 1%   1.664m ± 1%       ~ (p=0.796 n=10)
geomean                                                  106.2µ        107.2µ       +0.98%

                                                       │   baseline   │                 fix                 │
                                                       │     B/op     │     B/op      vs base               │
Decode/target=plain/option=default/size=small-4          12.84Ki ± 0%   13.27Ki ± 0%  +3.41% (p=0.000 n=10)
Decode/target=plain/option=known-fields/size=small-4     12.84Ki ± 0%   13.27Ki ± 0%  +3.41% (p=0.000 n=10)
Decode/target=custom/option=default/size=small-4         13.09Ki ± 0%   13.53Ki ± 0%  +3.34% (p=0.000 n=10)
Decode/target=custom/option=known-fields/size=small-4    13.09Ki ± 0%   13.53Ki ± 0%  +3.34% (p=0.000 n=10)
Decode/target=plain/option=default/size=medium-4         67.82Ki ± 0%   71.07Ki ± 0%  +4.79% (p=0.000 n=10)
Decode/target=plain/option=known-fields/size=medium-4    67.82Ki ± 0%   71.07Ki ± 0%  +4.79% (p=0.000 n=10)
Decode/target=custom/option=default/size=medium-4        68.08Ki ± 0%   71.33Ki ± 0%  +4.77% (p=0.000 n=10)
Decode/target=custom/option=known-fields/size=medium-4   68.08Ki ± 0%   71.33Ki ± 0%  +4.77% (p=0.000 n=10)
Decode/target=plain/option=default/size=large-4          686.9Ki ± 0%   718.3Ki ± 0%  +4.57% (p=0.000 n=10)
Decode/target=plain/option=known-fields/size=large-4     686.9Ki ± 0%   718.3Ki ± 0%  +4.57% (p=0.000 n=10)
Decode/target=custom/option=default/size=large-4         687.2Ki ± 0%   718.5Ki ± 0%  +4.57% (p=0.000 n=10)
Decode/target=custom/option=known-fields/size=large-4    687.2Ki ± 0%   718.5Ki ± 0%  +4.57% (p=0.000 n=10)
geomean                                                  84.59Ki        88.17Ki       +4.24%

                                                       │  baseline   │                fix                 │
                                                       │  allocs/op  │  allocs/op   vs base               │
Decode/target=plain/option=default/size=small-4           179.0 ± 0%    180.0 ± 0%  +0.56% (p=0.000 n=10)
Decode/target=plain/option=known-fields/size=small-4      179.0 ± 0%    180.0 ± 0%  +0.56% (p=0.000 n=10)
Decode/target=custom/option=default/size=small-4          183.0 ± 0%    184.0 ± 0%  +0.55% (p=0.000 n=10)
Decode/target=custom/option=known-fields/size=small-4     183.0 ± 0%    184.0 ± 0%  +0.55% (p=0.000 n=10)
Decode/target=plain/option=default/size=medium-4         1.268k ± 0%   1.269k ± 0%  +0.08% (p=0.000 n=10)
Decode/target=plain/option=known-fields/size=medium-4    1.268k ± 0%   1.269k ± 0%  +0.08% (p=0.000 n=10)
Decode/target=custom/option=default/size=medium-4        1.272k ± 0%   1.273k ± 0%  +0.08% (p=0.000 n=10)
Decode/target=custom/option=known-fields/size=medium-4   1.272k ± 0%   1.273k ± 0%  +0.08% (p=0.000 n=10)
Decode/target=plain/option=default/size=large-4          12.08k ± 0%   12.08k ± 0%  +0.01% (p=0.000 n=10)
Decode/target=plain/option=known-fields/size=large-4     12.08k ± 0%   12.08k ± 0%  +0.01% (p=0.000 n=10)
Decode/target=custom/option=default/size=large-4         12.09k ± 0%   12.09k ± 0%  +0.01% (p=0.000 n=10)
Decode/target=custom/option=known-fields/size=large-4    12.09k ± 0%   12.09k ± 0%  +0.01% (p=0.000 n=10)
geomean       

@stoewer stoewer marked this pull request as ready for review April 14, 2026 07:29
Copilot AI review requested due to automatic review settings April 14, 2026 07:29
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a long-standing gap where loader/decoder options (notably KnownFields) were not respected when a custom UnmarshalYAML implementation delegated to node.Decode(), because Node.Decode always used DefaultOptions. It addresses issue #321 by snapshotting the loader’s options onto the parsed node tree and having Node.Decode inherit from that snapshot; the same propagation is added to ComposeAndResolve.

Changes:

  • Add an options snapshot pointer on Node and make Node.Decode inherit loader options when available.
  • Propagate loader options across the node tree after resolve (both Loader.Load and Loader.ComposeAndResolve).
  • Add regression tests (including multi-document behavior) and a benchmark to quantify overhead.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
yaml_bench_test.go Adds benchmark coverage for decode with/without custom UnmarshalYAML and with/without KnownFields.
node_test.go Adds regression tests ensuring Node.Decode inherits KnownFields and a race-focused test.
internal/libyaml/node.go Adds per-node option snapshot storage and updates Node.Decode to use it.
internal/libyaml/loader.go Stamps a snapshot of loader options onto all nodes; ensures SetKnownFields updates loader options.
internal/libyaml/loader_test.go Adds test ensuring ComposeAndResolve propagates options so Node.Decode respects them.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/libyaml/node.go
Comment on lines +193 to +196
// options is set by propagateLoadOptions when a Loader produces this node. It carries
// the loader options so that Decode can inherit them in custom UnmarshalYAML functions.
// Is typically nil for user-constructed nodes.
options *Options
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the unexported options *Options field to Node changes the public yaml.Node (it’s a type alias). This can break downstream code that (a) uses unkeyed composite literals for yaml.Node{...} and (b) relies on reflect.DeepEqual/cmp equality against expected node literals (the new hidden pointer will differ). If this compatibility impact is acceptable, it should be explicitly called out (e.g., in release notes); otherwise consider an approach that doesn’t add a field to the public struct (e.g., side-table keyed by *Node, or storing options only on decoder/constructor paths).

Copilot uses AI. Check for mistakes.
Comment thread yaml_bench_test.go
Comment thread node_test.go
@stoewer
Copy link
Copy Markdown
Author

stoewer commented Apr 20, 2026

Force pushed to branch to fix commit messages

@ingydotnet
Copy link
Copy Markdown
Member

Thanks for this. I was away but back now.
I'll try to get to this one next week.

@ingydotnet ingydotnet requested review from ccoVeille May 1, 2026 15:25
@ingydotnet
Copy link
Copy Markdown
Member

@ccoVeille is this at all in conflict with #329?

Look forward to your review of this one...

@ccoVeille
Copy link
Copy Markdown
Contributor

ccoVeille commented May 2, 2026

I don't think so. And if does, the changes would be very minimal.

I would merge #329 separately.

Then about the current PR here, I'm unsure. I feel like it brings a complexity, and something that bounds things together.

The problem is this

type Config struct {
	Name string `yaml:"name"`
}

func (c *Config) UnmarshalYAML(value *yaml.Node) error {
	type rawConfig Config
	return value.Decode((*rawConfig)(c)) // Decoder ignores options from parent decoder
}

For now the workaround would be to call node.Load with option in the WithKnowFields option, instead of calling node.Decode, and expect "parent decoder options" to be inherited all down the stacks of nodes and their leaves.

So something like this

type Config struct {
	Name string `yaml:"name"`
}

func (c *Config) UnmarshalYAML(value *yaml.Node) error {
	type rawConfig Config
	return value.Load((*rawConfig)(c), yaml.WithKnownFields)
}

I feel like the changes in this PR should be considered carefully. Things that works because whatever reason may break if we merge.

Here asking to AI might help to figure what is the best.

Maybe only Decode method should inherit the parent decoder by default, maybe this behavior could become an option.

But I'm out of OSS for personal reasons I shared privately with you. I won't be able to look at this.

@stoewer
Copy link
Copy Markdown
Author

stoewer commented May 12, 2026

Thanks for taking the time to look at this!

For now the workaround would be to call node.Load with option in the WithKnowFields option, instead of calling node.Decode, and expect "parent decoder options" to be inherited all down the stacks of nodes and their leaves.

Agreed, node.Load(v, yaml.WithKnownFields()) works in many cases, but:

  • It requires every UnmarshalYAML() to know about the option the caller picked. The decision "do we accept unknown fields?" usually belongs at the decoder construction site, and is ideally not scattered across every custom unmarshaler
  • It's still a footgun that has been tripping users up since 2019. Strict mode silently isn't strict, and most users won't discover that

Maybe only Decode method should inherit the parent decoder by default, maybe this behavior could become an option.

That's actually exactly what this PR does: Node.Decode inherits, Node.Load does not (it still takes explicit options). The asymmetry is documented on Node.Load so it isn't surprising.

I feel like the changes in this PR should be considered carefully. Things that works because whatever reason may break if we merge.

By "break" do you mean code that relies on KnownFields not being inherited? In this case I'd argue that v4 is the right window for this kind of breaking change, what do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

KnownFields(true) is not propagated when UnmarshalYAML calls Node.Decode

4 participants