You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ToFrozenDictionary overloads differ in duplicate handling #127798
However, the two overloads based on arbitrary (non-KVP) source do not share this behavior.
Interestingly, their documentation sort of reflects their current behavior, in that they lack the special remark about duplicate handling. The absence is not easy to spot unless you are already aware.
The current situation is a bit of a trap. I switched my source from KeyValuePairs to ValueTuples (plus key and element selectors), and was caught off-guard by the behavioral change.
Also, while the documentation is technically correct, it is quite obscure that some overloads simply omit a remark about their duplicate handling. Ideally, each overload has an explicit remark - ideally all the same, but worst-case to warn of their differences.
The documentation is explicit about the duplicate handling for each overload.
Ideally, all overloads have the same handling.
Actual behavior
The documentation says nothing about the duplicating handling on TSource overloads. The remark on the KeyValuePair-based overload easily misleads to the assumption that its behavior applies throughout.
The TSource-based overloads throw on duplicates, whereas the KeyValuePair-based one lets the last duplicate win.
The inconsistency looks incidental rather than designed. The two selector overloads are
one-liners that call Enumerable.ToDictionary first and freeze second, so the ArgumentException
is LINQ's and is thrown before any code in the Frozen namespace runs. The last-wins path, by
contrast, is a deliberate choice with a comment in the source saying exactly that.
The last-wins behaviour is a deliberate choice, and GetExistingFrozenOrNewDictionary says so in as
many words:
// Dictionary's constructor uses Add, which will throw on duplicates.// This implementation uses the indexer to avoid throwing and to overwrite// existing entries such that last one wins.newDictionary[pair.Key]=pair.Value;
The throwing behaviour is not a choice — it is inherited. Both selector overloads are one-liners
that convert with LINQ first and freeze second:
Enumerable.ToDictionary throws on duplicates, so the exception is raised before any code in the Frozen namespace runs. The stack from the two-argument overload on 10.0.12 contains no Frozen
frames at all:
System.ArgumentException: An item with the same key has already been added. Key: 1
at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
at System.Linq.Enumerable.SpanToDictionary[TSource,TKey](ReadOnlySpan`1 source, Func`2 keySelector, IEqualityComparer`1 comparer)
at System.Linq.Enumerable.ToDictionary[TSource,TKey](IEnumerable`1 source, Func`2 keySelector, IEqualityComparer`1 comparer)
at Program.<Main>$(String[] args)
Running all five against 10.0.12, with a bare Enumerable.ToDictionary as a control:
Runtime: .NET 10.0.12
FrozenDictionary.Create(ROS<KVP>) -> OK Count=1 d[1]=b
FrozenDictionary.Create(comparer, ROS<KVP>) -> OK Count=1 d[1]=b
ToFrozenDictionary(IEnumerable<KVP>) -> OK Count=1 d[1]=b
ToFrozenDictionary(source, keySelector) -> ArgumentException: An item with the same key has already been added. Key: 1
ToFrozenDictionary(source, keySelector, elementSelector) -> ArgumentException: An item with the same key has already been added. Key: 1
Enumerable.ToDictionary (control) -> ArgumentException: An item with the same key has already been added. Key: 1
Same type, same message as the control.
Code for the above
Each call is listed on its own; the run that produced the output wrapped them in separate try/catch blocks so that one failure did not stop the rest.
usingSystem.Collections.Frozen;varkvps=new[]{KeyValuePair.Create(1,"a"),KeyValuePair.Create(1,"b")};FrozenDictionary.Create(kvps);// OK, last winsFrozenDictionary.Create((IEqualityComparer<int>)null,kvps);// OK, last winskvps.ToFrozenDictionary();// OK, last winsnew[]{"a","b"}.ToFrozenDictionary(s =>1);// ArgumentExceptionnew[]{"a","b"}.ToFrozenDictionary(s =>1, s =>s);// ArgumentExceptionnew[]{"a","b"}.ToDictionary(s =>1);// control: same exception
So nobody actually picked this inconsistency — which is also why no documentation describes it.
The existing remark sharpens the point: it warns that the
behaviour "differs from Enumerable.ToDictionary, with which multiple duplicate keys will result in
an exception" — and Enumerable.ToDictionary is exactly what the other two overloads call. So the
contrast is spelled out on the three factories that do not exhibit it, and left unmentioned on the
two that do.
Suggestion
Split it, because the documentation half and the behaviour half carry very different risk.
1. Now — documentation only. State duplicate handling explicitly on all five factories, and
state it in <summary> rather than <remarks>. datvm's point in #113096 is that the
existing remark "does not even show up on the IntelliSense tooltip", and that is precisely how this
trap survives: the behaviour is technically documented, in the one place a caller is least likely to
be looking at the moment they make the mistake. Adding a <remarks> to the selector overloads would
reproduce the reported failure mode rather than fix it. I am happy to send this PR if it is wanted.
2. Behavioural alignment — defer to #113096. Changing either side silently is unattractive.
Making the selector overloads last-wins would stop throwing for callers who rely on the ArgumentException as an integrity check, with no compile-time signal; changing the KeyValuePair overloads would break documented, shipped, intentional behaviour. An explicit DuplicateKeyHandling argument resolves it in both directions without breaking anyone, and
eiriktsarpalis's direction on that issue — "augment collection constructors accepting IEnumerable
with overloads accepting this enum in dictionary and set types" — would appear to cover these
factories already.
On the opposite-defaults point above: that pair may be directly useful to #113096 as well.
GrabYourPitchforks asked there for concrete scenarios to strengthen the case for the API, and this
is two real ones pulling in opposite directions on the same type — which argues for selectable
handling rather more strongly than either would on its own.
For completeness, FrozenSet does not have the equivalent problem. None of its three factories
document duplicate handling either, but all three collapse duplicates through HashSet<T>, none
throws, and so there is no pair of overloads that disagree — and for a set, collapsing duplicates is
the defining behaviour rather than a surprise.
Note
AI-generated, written at my direction and reviewed by me before posting. The source references
were read against main at d84e42c, and the stack trace and sample output above come from running
the sample on .NET 10.0.12.
Activity
Timovzl commentedon May 5, 2026
Somewhat related to #113096.
dotnet-policy-service commentedon May 5, 2026
Tagging subscribers to this area: @dotnet/area-system-collections
See info in area-owners.md if you want to be subscribed.
jeffhandley commentedon Aug 17, 2026
We're open to suggestions for how best to address this while taking #113096 into consideration as well.
caraioniurie47 commentedon Sep 11, 2026
Summary
one-liners that call
Enumerable.ToDictionaryfirst and freeze second, so theArgumentExceptionis LINQ's and is thrown before any code in the
Frozennamespace runs. The last-wins path, bycontrast, is a deliberate choice with a comment in the source saying exactly that.
<summary>, not<remarks>— and defer any behavioural alignment to [API Proposal]: Enhance dictionary and set constructors/factories with Duplicate Key Handling Options #113096.overloads throw, and datvm's comment on [API Proposal]: Enhance dictionary and set constructors/factories with Duplicate Key Handling Options #113096 is from someone surprised that the
KeyValuePairones do not. That argues for making the handling selectable rather than picking awinner now.
Detail below, read against
mainat d84e42c; the file itself last changed in #128300 back in May,so it should still hold.
Where the five factories stand today, all in
src/libraries/System.Collections.Immutable/src/System/Collections/Frozen/FrozenDictionary.cs:Create(params ReadOnlySpan<KeyValuePair<TKey, TValue>>)<remarks>Create(IEqualityComparer<TKey>?, params ReadOnlySpan<KeyValuePair<TKey, TValue>>)<remarks>ToFrozenDictionary(IEnumerable<KeyValuePair<TKey, TValue>>, IEqualityComparer<TKey>?)<remarks>ToFrozenDictionary(IEnumerable<TSource>, Func<TSource, TKey>, ...)ToFrozenDictionary(IEnumerable<TSource>, Func<TSource, TKey>, Func<TSource, TElement>, ...)The last-wins behaviour is a deliberate choice, and
GetExistingFrozenOrNewDictionarysays so in asmany words:
The throwing behaviour is not a choice — it is inherited. Both selector overloads are one-liners
that convert with LINQ first and freeze second:
Enumerable.ToDictionarythrows on duplicates, so the exception is raised before any code in theFrozennamespace runs. The stack from the two-argument overload on 10.0.12 contains noFrozenframes at all:
Running all five against 10.0.12, with a bare
Enumerable.ToDictionaryas a control:Same type, same message as the control.
Code for the above
Each call is listed on its own; the run that produced the output wrapped them in separate
try/catchblocks so that one failure did not stop the rest.So nobody actually picked this inconsistency — which is also why no documentation describes it.
The existing remark sharpens the point: it warns that the
behaviour "differs from
Enumerable.ToDictionary, with which multiple duplicate keys will result inan exception" — and
Enumerable.ToDictionaryis exactly what the other two overloads call. So thecontrast is spelled out on the three factories that do not exhibit it, and left unmentioned on the
two that do.
Suggestion
Split it, because the documentation half and the behaviour half carry very different risk.
1. Now — documentation only. State duplicate handling explicitly on all five factories, and
state it in
<summary>rather than<remarks>. datvm's point in#113096 is that the
existing remark "does not even show up on the IntelliSense tooltip", and that is precisely how this
trap survives: the behaviour is technically documented, in the one place a caller is least likely to
be looking at the moment they make the mistake. Adding a
<remarks>to the selector overloads wouldreproduce the reported failure mode rather than fix it. I am happy to send this PR if it is wanted.
2. Behavioural alignment — defer to #113096. Changing either side silently is unattractive.
Making the selector overloads last-wins would stop throwing for callers who rely on the
ArgumentExceptionas an integrity check, with no compile-time signal; changing theKeyValuePairoverloads would break documented, shipped, intentional behaviour. An explicitDuplicateKeyHandlingargument resolves it in both directions without breaking anyone, andeiriktsarpalis's direction on that issue — "augment collection constructors accepting
IEnumerablewith overloads accepting this enum in dictionary and set types" — would appear to cover these
factories already.
On the opposite-defaults point above: that pair may be directly useful to #113096 as well.
GrabYourPitchforks asked there for concrete scenarios to strengthen the case for the API, and this
is two real ones pulling in opposite directions on the same type — which argues for selectable
handling rather more strongly than either would on its own.
For completeness,
FrozenSetdoes not have the equivalent problem. None of its three factoriesdocument duplicate handling either, but all three collapse duplicates through
HashSet<T>, nonethrows, and so there is no pair of overloads that disagree — and for a set, collapsing duplicates is
the defining behaviour rather than a surprise.
Note
AI-generated, written at my direction and reviewed by me before posting. The source references
were read against
mainat d84e42c, and the stack trace and sample output above come from runningthe sample on .NET 10.0.12.