Migration guide
This page lists the source changes your code needs when you move to a new major version. The changelog describes each release in full. This page collects only the parts that need an edit where you call the library.
After 10.0.0, a minor can change an answer too: a fix where a call returned a wrong value without throwing. Such a minor opens its changelog entry with "Changed answers", and has a section here, "Changed answers in x.y.0", that says how to find the calls whose answer changed.
Migration from v1.x
Update code that depends on ToString()
If your code depends on the text that ToString() returns, update it to the new literal format.
In v1.x, calling .ToString() on any kind of range returned the default C# record text:
Finite { Start = 1, End = 10, StartInclusive = True, EndInclusive = True }
From v2.0.0, ToString() returns the PostgreSQL range literal:
[1,10]
This matters if you used the text for logging, display, serialization or string comparison. If you need the structured form, rebuild it from the properties of each kind of range with pattern matching.
Migration from v2.x
Add parentheses to the state checks
Add parentheses wherever you call IsEmpty, IsFinite, IsInfinity, IsUnboundedStart or
IsUnboundedEnd. They were extension properties in v2.x, and they are extension methods since
v3.0.0:
// v2.x
if (range.IsEmpty) { … }
// v3.0.0
if (range.IsEmpty()) { … }
The compiler flags every call you need to change, so the edit is mechanical. The reason is EF Core. Extension properties cannot appear in LINQ expression trees, so EF Core cannot turn them into SQL. As extension methods, they run fully in the database through the EF Core package. See Entity Framework Core.
Migration from v3.x
Check code that compares RangeSet values with ==
If you used == to test whether two RangeSet variables hold the same instance, replace it with
ReferenceEquals(a, b).
Since v4, operator == and != on RangeSet<TRange, T> compare values, through Equals. This
matches the range types themselves, which are records, and the SQL = that the EF Core package
generates. Before v4, == on two sets compared references. Recompiling against v4 switches those
calls to value comparison without a warning.
Check DoesNotExtendRightOf/LeftOf with infinite bounds
Check your calls where the range you call these methods on is unbounded. Before v4, they always
returned false for such a range.
In v4, an infinite bound compares equal to another infinite bound, exactly like PostgreSQL's &<
and &> operators. So [5, +∞).DoesNotExtendRightOf([100, +∞)) is now true, because +∞ ≤ +∞.
When the range you pass has finite bounds or is empty, the results are unchanged.
Everything else in v4 only adds features, so no other source changes are needed.
Migration from v4.x and v5.x
No source changes. Both majors only add features. Each one was a major version to mark the
model growing, not to signal a break: v5 added TimeRange and YearMonthRange, and v6 added the
value sets. You can recompile against either without an edit.
Migration from v6.x
v7.0.0 changed what several existing calls return. Nothing was removed, and no signature changed, so the compiler flags none of these. For each one, decide whether your code depended on the old result.
Check Contains with an empty range
If your code relied on Contains to mean "contains and is not empty", say so explicitly:
outer.Contains(inner) && !inner.IsEmpty()
The empty range is now contained by every range. [1,5].Contains(Int32Range.Empty) returned false
and now returns true. So do Int32Range.Empty.IsContainedBy(anything) and
Int32Range.Empty.Contains(Int32Range.Empty). The empty set is a subset of every set (∅ ⊆ S), and
PostgreSQL's @> returns the same. The old behaviour gave a different result in memory than in SQL.
Good to know:
- Only comparisons with an explicitly empty range change. Every other result stays the same.
Overlapsis unchanged. It still returnsfalsewhen the range you pass is empty. A check that meant "shares a value" was always better written withOverlaps.
IsStrictlyLeftOf/IsStrictlyRightOf are now correct for unbounded-start ranges
If you worked around the old result, remove the workaround. Otherwise no edit is needed.
((-∞,5]).IsStrictlyLeftOf([10,20]) returned false and now returns true. The << operator
compares the upper bound of the range you call it on with the lower bound of the range you pass.
(-∞, 5] has a perfectly finite upper bound. The EF Core translation always emitted << and was
always right. So the same check returned true in the database and false in memory. A RangeSet
with one range had the same bug.
Check Except with ranges unbounded in opposite directions
Review every Except call where the two ranges are unbounded in opposite directions. Look hardest
at this change. The old result was a well-formed range of the right shape, holding the wrong values.
The subtraction quietly kept part of what it was asked to remove.
((-∞,5]).Except([1,+∞)) returned {(-∞,5]}, which is the range you called it on, unchanged. The
correct result is {(-∞,0]}. The mirrored pair went wrong in the same way. Only ranges unbounded in
opposite directions were affected, but every element type was. RangeSet.Except runs the same
code, so it was affected too.
DecimalRange.Length returns null instead of throwing
If you catch OverflowException from DecimalRange.Length, check for null instead. Only code that
catches that exception is affected.
A range wider than decimal itself used to throw OverflowException, and now returns null. Only
a range that straddles zero can be that wide. This matches Int64Range.Length, which already
returned null for a span too large to count.
Check the JSON of numeric wrapper sets under WriteAsString
A wrapper set is a set over your own element type, such as Int64Set<OrderId>. The changelog and
the API docs call these wrapper arities.
If a client of yours depends on the old JSON numbers, give the element type its own
[JsonConverter]. Under JsonNumberHandling.WriteAsString, Int16Set<T>, Int32Set<T>,
Int64Set<T> and DecimalSet<T> used to write a bare JSON number. The sets over the plain types
wrote a string:
Int64Set ["9007199254740993"]
Int64Set<OrderId> [9007199254740993] ← v6
Int64Set<OrderId> ["9007199254740993"] ← v7
Your JSON changes if you serialize a numeric wrapper set with that setting. Reading is unaffected, because the numeric converters have always accepted a JSON string.
Make temporal wrapper elements forward the format
If you hand-write an element type for a temporal wrapper set, make its ToString(format, provider)
pass its format argument on. Wrappers from a generator already do this. Hand-written ones may not.
This applies to the temporal wrapper sets added in v7: DateSet<T>, TimeSet<T>,
DateTimeSet<T>, DateTimeOffsetSet<T>, and the NodaTime ones. Their default text form loses
data. TimeOnly prints as 09:30, and DateTime as 06/15/2024 10:30:00. When a wrapper ignores
the argument, storing it fails with an error that names the type and the rule. That way a truncated
value never reaches the database. See
Keep the text form of the underlying type.
One thing that starts working
A Contains call with a constant element now runs, and no edit is needed. Int64Range.Contains(25)
used to emit SQL that PostgreSQL would not run (42883: operator does not exist: int8range @> integer).
Constant element values now carry an explicit cast, so queries that threw now execute.
Migration from v7.x
v8.0.0 is a major version for the same reason as v7. Several fixes change what existing calls return, and one turns a query that ran into one that throws. No signature changed anywhere.
Rewrite equality over a database-computed Union
If a query compares a value set Union computed in the database with ==, != or Equals,
rewrite it. The query now throws when EF Core translates it. This is the only change here that turns
working code into an exception. It comes first because it is the most likely to affect you:
// v7: ran, and returned wrong rows. v8: throws at translation.
db.Rows.Where(r => r.Tags.Union(other) == expected);
If your query relied on this comparison, it was already returning wrong rows. The exception message names the alternatives, and you have two:
- Use the operators that ignore order and duplicates. These still work on a union:
Contains,Overlaps,IsSubsetOf,IsSupersetOf,IsEmpty, and the proper subset and superset pair. - Load the rows first with
AsEnumerable(), to get the in-memory result on purpose.
Here is why the old query returned wrong rows. Union runs in the database as array_cat, which
concatenates the arrays instead of deduplicating and sorting them. The result keeps duplicates and
the order of each input, and array equality is sensitive to both. Against a live server,
{a,c} ∪ {a,b} = {a,b,c} was false because of the repeated a. {a,c} ∪ {b} = {a,b,c} was
false too, where nothing repeats and only the order differs. In memory, both are true.
Good to know:
- 8.0 covered only some queries, and 9.0.0 covers the rest. The 8.0 check covered only
Where,Any,All, the predicate overloads, and the ordering and grouping keys. It assumed that EF Core evaluates a comparison in a projection in memory. EF Core runs it in the database instead, so the projection returned wrong results. 9.0.0 closes that gap and every other query shape. See the v8.x section.
RangeSet.Except with an infinite range, and Complement()
RangeSet<Int32Range, int>.Infinite.Except(Int32Range.Infinite) returned {(,)}, every value of the
element type. The correct result is the empty set, because X \ (-∞, +∞) is empty for every X.
Complement() on the infinite set went wrong through the same code. Both now return the empty set.
Pass ISO values to the YearMonthRange step functions
Pass ISO calendar values to NextValueAfter and PreviousValueBefore, or convert them first. The
two functions now reject a non-ISO value outright.
Before, they stepped in whatever calendar you passed. They could return a value that the type's own
constructors reject. LocalDateRange behaves differently: it converts values to ISO instead of
rejecting them.
Two calls that threw now return a value
If you catch an exception from either of these calls, you can remove the catch. Both used to fail loudly, so only code that catches them is affected:
RangeSet<Int32Range, int>.Infinite.Contains(value)threwInvalidOperationExceptionfor every value. It now returnstrue, because the infinite set contains everything.- The NodaTime step functions threw at the largest value of the element type when it was written
in the Gregorian calendar. They now return
null.
If you implement IRange<T> yourself
Don't. The rule that the five sealed kinds of range are the only ones has always forbidden it.
One change still matters here. IRangeFactory.ToString used to format a range it did not recognise
as "empty". That is the literal PostgreSQL reads back as the empty range. So such a value was
stored and queried as the empty range, and nothing threw. It now throws.
Migration from v8.x
No signature changed. What changed is what existing calls write, accept and run as SQL. Check these in order, starting with the most likely to affect you:
- Your JSON changes shape if you never called
AddRangeConverters(). Every type now writes its literal by default (details). - Input that used to parse now throws
FormatException. This covers non-ISO temporal text, aDateTimeOffsetwithout an offset, culture-formatted numbers, and literals that PostgreSQL itself rejects (culture, ISO 8601). - Queries that ran now throw
InvalidOperationException. This covers any query over a value setUnioncomputed in the database, other than projecting it or testing containment and overlap. Those queries were already returning wrong rows (details). - A
DateTimewith an offset keeps its wall clock. It no longer converts into the machine's time zone. NodaTime values in a non-ISO calendar are rejected on write (ISO 8601, JSON). - Temporal constants and JSON-column values reach the database truncated to the microsecond, as a parameter always did (details).
Check JSON payloads if you never called AddRangeConverters()
If you already call AddRangeConverters(), or register RangeJsonConverterFactory, your JSON does
not change, and you can keep the call. Every range type, each of its kinds of range,
RangeSet<TRange, T> and every value set now carries its own converter.
If you did not, your JSON changes from a property dump to the literal:
| Type | 8.x without registration | 9.0 |
|---|---|---|
DateRange |
{"Length":181} |
"[2026-01-01,2026-06-30]" |
range through object |
{"Start":…,"End":…,"StartInclusive":true,…} |
the literal |
RangeSet<Int32Range, int> |
[{"Length":5}] |
"{[1,5]}" |
StringSet |
{"Values":["a","b"],"Count":2,"IsEmpty":false} |
["a","b"] |
To keep an old shape for a property, put your own [JsonConverter] on it, or register one on the
options. Both take precedence over the type's attribute.
None of the old forms could be read back. So only code that writes and never reads can depend on
them, such as a client reading Length from a response, or a stored document nobody deserializes.
Parse culture-formatted text yourself
If you parsed culture-specific text, such as [01.01.2025,31.03.2025] with de-DE, parse the bounds
yourself and pass them to the factories:
var de = CultureInfo.GetCultureInfo("de-DE");
var range = DateRange.CreateFinite(DateOnly.Parse("01.01.2025", de), DateOnly.Parse("31.03.2025", de));
Parse(s, provider) and TryParse(s, provider, …) now read the invariant literal and ignore the
provider. ToString(null, provider) always writes the invariant literal. So do
ToString("", provider), string interpolation and string.Format. A provider still applies when you
pass an explicit, non-empty format string.
Before, the provider reached each bound:
DateRange.Parse(s, th-TH)was 543 years early.DecimalRange.Parse("[1.5,2.5)", de-DE)was[15,25).$"{decimalRange}"underde-DEwrote[1,5,2), which parsed back as[1,52).
Good to know:
- Decimal bounds now parse with PostgreSQL's
numericgrammar."1,000","(5)"and currency symbols throwFormatException. Before, they parsed as 1000, −5 and 5. - The wrapper sets follow the same grammar.
DecimalSet<T>and the integer ones read the element text with the plain type's grammar before your element'sParsesees it. So a generatedParsethat accepts group separators no longer turns{"1,5"}into fifteen. DecimalSet<T>now reads exponent notation ({1e3}, or a JSON1.5e1), asDecimalSetdoes.
Write temporal text as strict ISO 8601
Write every temporal bound and element as ISO 8601, the way this library and PostgreSQL write it.
This applies to Parse, TryParse, JSON, the TypeConverter and configuration binding. The BCL
parser used before read whatever the invariant culture could, month first. These inputs now throw
FormatException:
| Input | Was read as | Write instead |
|---|---|---|
01.02.2027, 01/02/2027, Feb 1 2027 |
January 2 | 2027-02-01 |
2027-02-01 for a DateTime or DateTimeOffset |
midnight | 2027-02-01T00:00 |
a DateTimeOffset without an offset |
the parsing machine's offset | append Z or the offset |
9:30 AM, 9:30 |
09:30 | 09:30 |
the same forms as JSON elements of a DateTimeOffsetSet, DateTimeSet or TimeSet |
as above — System.Text.Json's converter read the offset-less ones in the machine's offset | as above, or register a converter for the element type |
If a payload carries offsets because it means instants, use DateTimeOffsetRange (tstzrange).
Or convert explicitly with ToUniversalTime() before you build a DateTimeRange.
A DateTime bound or element with an offset, such as 2026-01-01T09:30+05:30, now keeps the wall
clock as written, with DateTimeKind.Local. That is what PostgreSQL's timestamp input does. It
used to be converted into the parsing machine's time zone: 05:00 in Berlin, 04:00 on a UTC server.
A tsrange stores that wall clock. Offsets are ±HH, ±HH:mm or ±HH:mm:ss. +0530 and +5:30
are rejected.
Good to know:
- These forms are accepted:
HH:mmwithout seconds, PostgreSQL's space-separated output, any number of fraction digits (truncated to 100 ns), andinfinity/-infinity. - An empty or blank bound is no longer infinite. A bound that is a quoted
""or only whitespace, such as["",5)or( , ), now throwsFormatException, as in PostgreSQL. Only an absent bound, as in(,5), is infinite. - A backslash outside quotes escapes the next character, in a range bound and in an array element
alike, as in PostgreSQL.
StringSet.Parse(@"{a\,b}")is the one elementa,b. It used to be the two elementsa\andb. - An array literal that PostgreSQL rejects, or reads as multi-dimensional, throws
FormatException. Examples are{{a,b}},{a}b}and{a"b}.{{a,b}}used to be the set{"{a","b}"}. - Only ASCII whitespace is skipped around elements and bounds. A no-break space is now kept as element text, and rejected inside a range bound. It used to be trimmed.
- The temporal wrapper sets read the text as their plain type first. So a lenient generated
element
Parseno longer sees non-ISO text. An element that ignores the format specifier now fails on read, instead of silently truncating. - The NodaTime types were already strict, and now also accept
HH:mmwherever the BCL types do: aLocalTime, the time part of aLocalDateTime, and anInstantwritten with its offset orZ. SoLocalTimeSet.Parse("{09:00}")reads likeTimeRange.Parse("[09:00,17:00)")instead of throwing.
Temporal values reach the database truncated to the microsecond
The EF Core package now writes a temporal SQL constant, and a temporal value inside a JSON column, as
the value Npgsql would bind. That is the value truncated to the microsecond toward 2000-01-01, with
the extremes written as -infinity / infinity. A constant used to carry seven fraction digits,
which the server rounded. So it could miss the row that a parameter matched.
If you need to ExecuteUpdate a value set inside a JSON column, set the whole complex property that
contains the set instead. Updating the value set itself now throws, however the setter reaches the
property. It would have written a document that the row could no longer be loaded from.
Good to know:
- Elements less than a microsecond apart are stored as one. A temporal set with such elements is
now stored with one of them. The column could only ever hold one value, and the stored array's
cardinalitydisagreed with the set'sCount.
Rewrite the remaining queries over a database-computed Union
If a query uses a value set Union computed in the database in one of these ways, rewrite it. Use
IsSubsetOf and IsSupersetOf together for set equality, or load the rows first. These queries now
throw InvalidOperationException, and the message names the fix:
db.Rows.Select(r => r.Tags.Union(x) == target); // was: false where memory says true
db.Rows.Where(r => (r.Id > 0 ? r.Tags.Union(x) : r.Tags) == target); // and the ?? form: was 0 rows
db.Rows.GroupBy(r => r.Tags.Union(x)); // was: one group per operand order
db.Rows.Select(r => r.Tags.Union(x)).Distinct(); // was: duplicates kept
db.Rows.OrderBy(r => r.Tags.Union(x));
db.Rows.Where(r => (r.OptionalTags ?? r.Tags.Union(x)).Count == 3); // was: duplicates counted
db.Rows.ExecuteUpdate(s => s.SetProperty(r => r.Tags, r => r.Tags.Union(x))); // was: stored {a,c,a,b}
A query that ran into one of these was already returning wrong rows. Union runs in the
database as array_cat, a concatenation. 8.0 rejected ==/Equals over it in predicates, and
Count over a bare union. Everything else ran and returned a different result from memory.
These keep working: projecting the union itself, and combining it with Contains, Overlaps, the
subset/superset family, Remove and IsEmpty.
If you ran an ExecuteUpdate of a union, the rows it wrote compare and count wrongly on the
server until they are rewritten. Load them and save them through SaveChanges to make them
deduplicated and sorted again.
If you replace IRelationalParameterBasedSqlProcessorFactory after UseValueRanges(), wrap the
registered factory instead. This applies whether you use ReplaceService or another library does it.
Otherwise every query with a Union that runs in the database now fails with
function valueranges_union_without_sql_guard(…) does not exist. The check runs from that service,
and a translated Union only becomes array_cat once the check has run.
Check three changes to JSON converters
These change which converter writes a value, and which properties get one:
- A converter you register on the options for a range type now also handles its kinds of range.
Before, a range serialized through
object, anobjectcollection, or a property typed as one kind of range skipped your converter and wrote the literal. Now it goes through your converter, so one payload no longer mixes two JSON forms. If you relied on the mixed form, declare those properties as a type your converter does not handle. - A property declared as
IRange<T>, a shape interface orIValueSet<T>now writes the literal (or array) of its runtime type. It used to write{}or a property dump. Reading one still cannot work, because the concrete type is unknown, and now throwsNotSupportedException. Declare the concrete type instead. - NodaTime: convert non-ISO values with
WithCalendar(CalendarSystem.Iso)before you write them. A bareLocalDate,LocalDateTimeorYearMonthin a non-ISO calendar is now rejected on write (ArgumentException). Before, it was written in its own calendar's fields and read back days off. The same holds for an element ofLocalDateSet<T>,LocalDateTimeSet<T>orYearMonthSet<T>, in memory and through EF Core. Also,AddNodaTimeRangeConverters()no longer takes precedence overConfigureForNodaTimewhen you register it first.
Ranges can be dictionary keys
Dictionary<DateRange, …> used to throw NotSupportedException. It now writes the literal as the
property name. Nothing to change, unless you caught that exception.
Things that start working
No edit is needed. They are listed so you can remove a workaround:
- Configuration binding of a range, multirange or value set property from its literal used to bind
null, with no error. Every type now carries aTypeConverter, so the property binds. - A range, multirange or value set inside an EF Core
ToJson()column could not be saved.SaveChangesthrewNullReferenceExceptionfrom inside EF Core. Now the value is stored as the literal, and queries into the document run in the database. - Native AOT.
RangeSetand the wrapper sets failed with "missing native code". The core and NodaTime packages now build withIsAotCompatible. Your project needs only the source-generated JSON context that AOT always needs.
Migration from v9.x
Nothing was removed. Some calls return a different value, and each one now matches PostgreSQL
where the two sides disagreed before. Text that PostgreSQL rejects is now rejected here too, and each
range shape gained its own Parse and TryParse. The compiler can't show you where, so check these
in order, starting with the most likely to affect you:
- The EF Core packages need PostgreSQL 16 or later. 9.x said 14 or later, but nothing older than 16 was ever tested (details).
IsUnboundedStart()andIsUnboundedEnd()returntruefor the infinite range. A query and the same call in memory already disagreed on it (details).RangeSet.Overlapson a continuous element type returnstruefor more inputs. A query and the same call in memory already disagreed on them (details).- EF Core:
AnyandCountover a captured list of ranges return the in-memory answer. The query already disagreed with memory for lists with overlapping, adjacent or empty ranges, andEF.Parameter(wanted)there now throws (details). - UUID text that
Guid.Parseaccepts and PostgreSQL rejects now throwsFormatException. This covers the(…)form and whitespace inside a quoted element (details). - A
DateTimeOffsetoffset written+0530or+5:30now throwsFormatException. It was read, although the grammar and the other temporal types reject it. Write+05:30(details). - A string element holding a NUL character now throws.
FromandAddthrowArgumentException, and parsing it fails. So does integer or decimal text with a trailing NUL, which read as the number before (details). - A wrapper set reads only the JSON its plain set reads. A quoted number needs
AllowReadingFromString, and a wrapper Guid is the hyphenated form (details). - A wrapper element whose text form doesn't round-trip now throws, on every write and read, where it was stored or read back as a different value (details).
- Decimal text with more precision than
decimalholds now throws, where it was rounded (details). - A range literal whose lower bound is greater than its upper bound now throws, where it read as
empty(details). - A minimal-API parameter typed as one shape answers 400 for another shape, where it bound
nulland ran the handler (details). - A record derived from a range type now throws
InvalidOperationExceptionwhen it is constructed. It compiled, and the range methods answeredfalsefor it. Use the five variants the factories return (details).
Check IsUnboundedStart() and IsUnboundedEnd() on the infinite range
Check code that calls IsUnboundedStart() or IsUnboundedEnd() on a single range to pick out the
(-∞, end] or [start, +∞) shape, and could meet the infinite range (,).
Before 10.0.0 the two methods tested the kind of range, so both returned false for (,).
PostgreSQL's lower_inf and upper_inf, which they run as in SQL, return true for it, and so
does a RangeSet holding it. A query through EF Core therefore matched every row holding (,)
where the same call in memory did not. Now memory and SQL agree:
Int32Range.Infinite.IsUnboundedStart(); // v9: false v10: true
Int32Range.Infinite.IsUnboundedEnd(); // v9: false v10: true
Int32Range.CreateUnboundedStart(5).IsUnboundedEnd(); // false in both
If you meant the kind of range, pattern-match on it. The shape interfaces are exact, and the compiler checks them:
if (range is IUnboundedStartRange<int> s) { … s.End … } // (-∞, end] only, never (,)
IsInfinity() and IsFinite() are unchanged. (,) is the only range for which IsInfinity()
returns true, and IsFinite() returns false for it, as before.
Check RangeSet.Overlaps on a continuous element type
Check code that calls Overlaps on a RangeSet over decimal, DateTime, DateTimeOffset,
TimeOnly, LocalDateTime or Instant, passes a range or a set, and acts on false.
Before 10.0.0, Overlaps returned false when the range you passed stopped exactly where a later
element started, with one side exclusive, although an earlier element shared values with it. It
tested only the element that starts where your range stops. PostgreSQL's && returns true for
the same values, so a query through EF Core and the same call in memory disagreed on those rows.
var set = RangeSet<DecimalRange, decimal>.From([DecimalRange.CreateFinite(1, 4), DecimalRange.CreateFinite(5, 8)]);
// {[1,4),[5,8)}
set.Overlaps(DecimalRange.CreateFinite(3, 5)); // v9: false v10: true — shares [3,4)
set.Overlaps(DecimalRange.CreateUnboundedStart(5, false)); // v9: false v10: true — (,5) covers [1,4)
set.Intersect(DecimalRange.CreateFinite(3, 5)); // {[3,4)} in both, which is why true is right
Discrete types (Int32Range, Int64Range, DateRange, LocalDateRange, YearMonthRange) are
unaffected: their closed canonical form has no exclusive end to stop on.
Check Any and Count over a captured list of ranges
Check queries that call Any, Count or another LINQ operator over a captured list, array or
other collection of ranges, without a Contains on the same list in the same query.
Before 10.0.0 such a list went to PostgreSQL as one parameter, which Npgsql typed as a multirange.
A multirange merges overlapping and adjacent ranges and drops empty ones, so the query counted the
merged ranges. Now each range goes as its own parameter and the query reads them from VALUES,
the same way a Contains already did:
var wanted = new List<Int32Range> { Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(3, 8) };
// row: Seats = [1,5]
db.Reservations.Select(r => wanted.Count(w => w.Overlaps(r.Seats))); // v9: 1 v10: 2, as in memory
db.Reservations.Select(r => wanted.Count(w => w == r.Seats)); // v9: 0 v10: 1
var withEmpty = new List<Int32Range> { Int32Range.Empty, Int32Range.CreateFinite(1, 5) };
db.Reservations.Select(r => withEmpty.Any(w => w.IsEmpty())); // v9: false v10: true
Two more things change with it:
EF.Parameter(wanted).Any(…)throws (InvalidOperationException), asEF.Parameter(wanted).Contains(…)did since 9.1.0. The message says to removeEF.Parameter.EF.Constant(wanted)keeps working. It was the workaround the EF Core guide gave; you can remove the wrapper or leave it.
Read UUIDs as PostgreSQL reads them
GuidSet and GuidSet<T> parse an element with PostgreSQL's uuid_in grammar: 32 hex digits, a
hyphen allowed after any group of four, optionally in braces. Before, they used Guid.Parse, which
reads more and trims whitespace, so a set could parse in memory and then fail as a literal on the
server.
| Element | 9.x | 10.0 | PostgreSQL |
|---|---|---|---|
(6f9619ff-8b86-d011-b42d-00c04fc964ff) |
parses | FormatException |
rejects |
" 6f9619ff-8b86-d011-b42d-00c04fc964ff" (quoted, with a space) |
parses, trimmed | FormatException |
rejects |
6f96-19ff-8b86-d011-b42d-00c0-4fc9-64ff |
FormatException |
parses | reads |
{6f9619ff-8b86-d011-b42d-00c04fc964ff} (quoted) |
parses | parses | reads |
If you build a literal from text in another format, format the value first:
GuidSet.From(Guid.Parse(text)); // any format Guid.Parse reads
A GuidSet<T> hands your element's Parse the D form, whatever spelling the literal used. An
element whose Parse only accepts another form now throws on every read.
Keep NUL out of string elements
PostgreSQL text cannot hold U+0000. Before, StringSet.From("a\0b") built the set and the
failure came from the server when the row was saved: invalid byte sequence for encoding "UTF8": 0x00. Now the set refuses the element where the text comes in, with a message naming the rule:
StringSet.From("a\0b"); // ArgumentException
StringSet.Parse("{a\0b}", null); // FormatException
StringSet.TryParse("{a\0b}", null, out _); // false
StringSet<T> applies the rule to the element's text form, in From, Add and on every read. If
your data carries NUL as a delimiter, split it before building the set. Every other character,
including control characters and non-ASCII whitespace, is text as before.
An integer or decimal bound or element with a NUL fails too (FormatException). Before, 31\0 read
as 31, in a literal, a set element and JSON alike, because .NET's number parsers ignore trailing NULs.
Send a wrapper set the JSON you send the plain set
Int32Set<OrderId> and Int32Set have always written the same JSON. Now they read the same JSON
too. Before, a wrapper read a quoted number under any options and with the array literal's grammar,
and a wrapper Guid with the literal's uuid_in grammar:
| Payload | Int32Set |
Int32Set<OrderId> 9.x |
10.0 |
|---|---|---|---|
["7"], default options |
JsonException |
{7} |
JsonException |
["7"], AllowReadingFromString |
{7} |
{7} |
{7} |
[" 7 "], AllowReadingFromString |
JsonException |
{7} |
JsonException |
["{6f9619ff-8b86-d011-b42d-00c04fc964ff}"] (GuidSet / GuidSet<T>) |
JsonException |
reads | JsonException |
ASP.NET Core's web defaults set AllowReadingFromString, so an API that received ["7"] keeps
working. One that received padded or undashed text was relying on the wrapper alone, and now
rejects the request with the same error the plain set gave.
WriteAsString alone no longer round-trips a wrapper set, as it never did a plain one: set
AllowReadingFromString too, or read the payload with the options that wrote it.
Check that your element's text form round-trips
Every core wrapper set (StringSet<T>, GuidSet<T>, the integer versions, DecimalSet<T> and the
four temporal ones) now checks that the text your element's ToString gives reads back, through
the family's own parser and your Parse, as the same element. The NodaTime versions already did.
Before, only the backing type's grammar was checked, so a wrong value that still parsed went through:
| Element | 9.x | 10.0 |
|---|---|---|
Int32Set<T>, ToString gives V + 1 |
wrote {124} for 123, read it back as 124 |
throws on write and on read |
StringSet<T>, ToString adds CUST-, Parse keeps any text |
wrote {CUST-123}, read back CUST-CUST-123 |
throws on write and on read |
StringSet<T>, ToString adds CUST-, Parse refuses it |
wrote {CUST-123}, failed on read with your message |
throws on write naming the rule |
DateTimeSet<T>, ToString ignores the format |
wrote {"06/15/2024 10:30:00"}, failed on read |
throws on write naming the rule |
Where it throws: ToString and the SQL literal or parameter with InvalidOperationException (the
EF Core bridge throws ArgumentException for the Guid, integer, decimal and temporal versions), the
JSON write with JsonException, Parse with FormatException, TryParse with false, and
loading a column with FormatException. The message names the element type, the text and the rule.
A generated wrapper that forwards format and provider to the value inside, and parses with
that value's Parse, passes without a change. So does one that normalizes on parse, such as one
that trims or lowercases: the check compares values, not text. What fails is a ToString that
decorates, pads or computes, and a Parse that doesn't undo it. Move the decoration to a display
property and keep the text form the backing value's.
One case the check reaches that the grammar never did: a DateTime element with Kind Local,
at a wall clock the machine's time zone skips. Its "O" text carries the zone's offset, which
DateTime.Parse applies through the zone, so your Parse reads another wall clock. ToString and
JSON now refuse it; the EF Core bridge still stores the wall clock, as it did since 9.0, because the
column holds no offset.
Decimal text is read exactly or refused
DecimalRange, RangeSet<DecimalRange>, DecimalSet and DecimalSet<T> refuse decimal text whose
number decimal can't hold exactly, through every entry point: Parse, TryParse, JSON (a number
token, or a quoted one under AllowReadingFromString), the TypeConverter and configuration
binding, and an EF Core JSON column. Before, decimal.Parse rounded it to the nearest value,
silently, and the value in memory was a different number from what PostgreSQL holds for the same
text:
| Text | 9.x | 10.0 | PostgreSQL |
|---|---|---|---|
[1.00000000000000000000000000001,2) |
[1.0000000000000000000000000000,2), contains 1 |
FormatException |
doesn't contain 1 |
{1.00000000000000000000000000001,1} |
one element | FormatException |
two elements |
[1e-30] (JSON) |
0.0000000000000000000000000000 |
JsonException |
1e-30 |
{1e30} |
OverflowException |
OverflowException |
reads |
The check is on the value, not the text: 1e3, +1.5, 007.50, a trailing zero beyond the 28th
decimal place and every other spelling of a number decimal holds still read, with the text's scale.
A column holding more precision was already loud: Npgsql throws OverflowException reading it.
If you receive such text, decide what to keep before parsing: round it yourself with
decimal.Round, or store it as numeric text.
Write range literals with the lower bound first
Every range type and RangeSet reject a literal whose lower bound is greater than its upper bound,
as PostgreSQL does. This holds for Parse, TryParse, JSON, the TypeConverter and configuration
binding, and an EF Core JSON column. Before, the literal read as empty, so a swapped period in a
query string or a JSON body matched nothing, silently:
| Literal | 9.x | 10.0 | PostgreSQL |
|---|---|---|---|
[5,1) |
empty |
FormatException |
rejects |
[2026-06-30,2026-01-01] |
empty |
FormatException |
rejects |
{[5,1),[7,9]} |
{[7,9]}, the inverted range dropped |
FormatException |
rejects |
[5,5), (5,5) |
empty |
empty |
empty |
TryParse returns false, a minimal API or MVC binding answers 400, and a JSON body fails with
JsonException. CreateFinite(5, 1) still returns Empty: a factory call has computed bounds, and
that rule hasn't changed. If your input can arrive swapped, order the bounds before you build the
literal, or call CreateFinite with the parsed bounds.
Bind a shape-typed parameter
Every shape of every range type now has its own Parse and TryParse, which read only a literal of
that shape. Minimal APIs bind a parameter through TryParse, so a parameter typed as one shape now
binds:
app.MapGet("/bookings", (DateRange.Finite period) => …);
| Request | 9.x | 10.0 |
|---|---|---|
?period=[2026-01-01,2026-06-30] |
200, period is null |
200, period is the range |
?period=(,2026-06-30] |
200, period is null |
400 |
?period=garbage |
400 | 400 |
This holds for the query, a route value and a form field, and for a nullable parameter. Before, the
binder found the union's inherited TryParse, which succeeded, and then couldn't assign the result,
so the handler ran with null even when the parameter wasn't nullable. A client that sends another
shape now gets a 400. If an endpoint should take any shape, type the parameter as the union
(DateRange) and switch on it. MVC and JSON bodies already behaved this way.
DateRange.Finite.Parse now returns a DateRange.Finite and throws FormatException for another
shape, where it was the union's Parse and returned any shape. Code that called Parse through a
shape's name for a literal of another shape should call it on the union.
Run PostgreSQL 16 or later
The EF Core packages of 10.x support PostgreSQL 16 and later, the same as 11.x. 9.x said 14 or later, but its tests only ever ran on 16 and later. From 10.0.0, CI runs both integration suites on 16 and 18 for every change.
On PostgreSQL 14 or 15 you can meet these:
RangeAggover a multirange column throws on 14. It runs PostgreSQL'srange_agg(anymultirange), which 14 doesn't have, so the query fails with42883.- The library reads integer text the server rejects.
0x1F,0o17,0b101and1_000are read as PostgreSQL 16 reads them. PostgreSQL 15 and older reject them. - "Reads the literal as PostgreSQL reads it" was never checked there. The tests that compare each
literal with the server use
pg_input_is_valid, which PostgreSQL 16 added.
PostgreSQL 14 receives fixes until November 2026. PostgreSQL 16 is supported until November 2028, which is when support for .NET 10 ends too.
Changed answers in 10.1.0
One call returns a different value, and only when your application runs on the .NET 11 runtime.
The 10.x packages target net10.0. A net11.0 application that references them runs them on
.NET 11, where TimeOnly, DateTime and DateTimeOffset read an hour of 24 that .NET 10 rejects.
10.0.0 handed the text straight to them, so the same literal was a value on .NET 11 and threw on
.NET 10. If your application runs on the .NET 10 runtime, nothing changes.
Check temporal text with an hour of 24
Check the time, DateTime and DateTimeOffset text your application reads but doesn't write: a
literal in a query string or a configuration value, a JSON body, a value bound through the
TypeConverter, and a JSON column that another program fills. ISO 8601 allows 24:00:00 as the end
of a day, and PostgreSQL's time accepts it, so a client or another tool may send it.
Before 10.1.0, on the .NET 11 runtime, that text read as midnight: 24:00:00 as 00:00:00, and
2026-01-01T24:00:00 as the next day's midnight. Now it throws on both runtimes, as it always did
on .NET 10:
| Text | 10.0.0 on .NET 10 | 10.0.0 on .NET 11 | 10.1.0 |
|---|---|---|---|
TimeSet.Parse("{24:00:00}") |
FormatException |
{00:00:00.0000000} |
FormatException |
DateTimeRange.Parse("[2026-01-01 00:00:00,2026-01-01T24:00:00)") |
FormatException |
[2026-01-01T00:00:00.0000000,2026-01-02T00:00:00.0000000) |
FormatException |
DateTimeOffsetRange.Parse("[2026-01-01 00:00:00Z,2026-01-01T24:00:00Z)") |
FormatException |
[2026-01-01T00:00:00.0000000+00:00,2026-01-02T00:00:00.0000000+00:00) |
FormatException |
TimeRange.Parse("[23:00:00,24:00:00)") |
FormatException |
FormatException, because 00:00:00 is below the lower bound |
FormatException |
This holds for every entry point: Parse, TryParse (which returns false), JSON
(JsonException), the TypeConverter and configuration binding, a minimal-API or MVC parameter
(a 400 response), and an EF Core JSON column.
The compiler can't show you the calls whose value changed, because your code is the same on both runtimes. Look at the text instead:
- Search what reaches your application for an hour of 24. Request logs, test fixtures and
configuration files:
24:00finds a time, andT24:or24:a date and time. - Search JSON columns that another program writes. Before 10.1.0, a document holding
24:00:00loaded on .NET 11 as midnight. Now the row fails to load:SELECT id FROM t WHERE doc::text LIKE '%24:00%'finds it. - Say "the end of the day" another way. Use an unbounded end,
[22:00,), or an inclusive23:59:59.9999999bound (TimeRange and the custom timerange type).
Changed answers in 10.2.0
Some calls return a different value. Your code stays the same, so the compiler can't show you where. Each section below says how to find the calls whose answer changed.
Check bound comparisons with null in queries
Check EF Core queries that call LowerBound() or UpperBound() on a range or RangeSet column and
then test the result for null. That covers == null, != null, HasValue and ?? with a
fallback, in a filter or a projection. It also covers a filter on a bound you projected first, which
is what a data grid sends for a "blank" filter.
PostgreSQL's lower and upper return NULL for an empty range and for an unbounded side. The
in-memory methods return null for the same values. Before 10.2.0, the EF Core package declared
that a bound is NULL only when the column is, and EF Core simplified the query on that basis. On a
required column:
| Query | Before 10.2.0 | Now |
|---|---|---|
Where(r => r.Period.LowerBound() == null) |
no rows | the empty rows and the rows with no lower bound |
Where(r => r.Period.UpperBound() != null), or HasValue |
every row | the rows with an upper bound |
Where(r => (r.Period.UpperBound() ?? DateOnly.MaxValue) >= day) |
the empty rows and the rows with no upper bound are missing | those rows match through the fallback |
Select(r => r.Period.UpperBound() ?? DateOnly.MaxValue) |
throws (InvalidOperationException) at an empty row or a row with no upper bound |
returns the fallback for it |
Select(r => new { To = r.Period.UpperBound() }).Where(x => x.To == null) |
no rows | the empty rows and the rows with no upper bound |
On a nullable column, LowerBound() == null ran as the column's own IS NULL. It found the NULL
rows and missed the empty and unbounded ones.
This holds for every range type and for RangeSet: the .NET and NodaTime ranges, TimeRange, the
float ranges and YearMonthRange. Code that runs in memory is unchanged.
- Search your queries for
LowerBound()andUpperBound(). Each one followed by== null,!= null,HasValueor??now returns the rows the same code returns in memory. - Keep or drop the workarounds you wrote for it. A query that adds
IsEmpty(),IsUnboundedStart()orIsUnboundedEnd()beside thenulltest returns the same rows as before, so the extra test is now optional. - Check reports built on the old rows. On a required column, a count of rows with no end date
was 0, and a filter on a date with a
??fallback left out every open-ended row.
Check RangeAgg and RangeIntersectAgg over null ranges
Check code that aggregates ranges in memory from a source that can hold null: the values of a
nullable property, or query results you load first and aggregate afterwards.
PostgreSQL's range_agg and range_intersect_agg skip a NULL row, so a GroupBy over a
nullable column aggregates the ranges it has. Before 10.2.0 the in-memory versions didn't. With a
as [1,10] and b as [5,25]:
| Sequence | Before 10.2.0 | Now |
|---|---|---|
[a, b, null].RangeIntersectAgg() |
empty |
[5,10] |
[null, a, b].RangeIntersectAgg() |
[5,10] |
[5,10] |
[(,), null].RangeIntersectAgg() |
throws NullReferenceException |
(,) |
[a, null].RangeAgg() |
throws InvalidOperationException |
{[1,10]} |
[null].RangeAgg() |
a set holding null: Count is 1, IsEmpty() is false |
{} |
Over sets, a null set made RangeAgg() throw NullReferenceException, and RangeIntersectAgg()
too when it came after another set. Now it is skipped. This holds for every range type, the NodaTime
ones included. Queries are unchanged, because the database already skipped NULL rows.
- Search for
RangeIntersectAgg()over a sequence that can holdnull. A!inrows.Select(r => r.Period!).RangeIntersectAgg()often marks the place: it silenced the nullable warning for a property that can benull. - Remove a
Where(r => r.Period != null)you added before aggregating, if you like. The answer is the same with or without it. - A group of nothing but
nullstill differs from the database. In memoryRangeAgg()returns the empty set, where the query returnsnull, as for zero rows (Read bounds, merge ranges and aggregate).
Keep null out of a range set
Check code that builds a RangeSet from ranges that can be null: RangeSet.From, a collection
expression such as [a, b], and RangeSet.Create.
Before 10.2.0, a null range went into the set. RangeSet<Int32Range, int>.From([null]) had a
Count of 1, IsEmpty() returned false, and the first Contains threw NullReferenceException.
With two or more ranges, From threw InvalidOperationException while sorting them, and next to
(,) the null was ignored. Now each of these throws ArgumentException, wherever the null
stands. Union and Except called on a null range threw or built such a set, and they throw now
too.
- Filter before you build the set if
nullmeans "no range" in your data:RangeSet<DateRange, DateOnly>.From(periods.OfType<DateRange>())leaves thenullranges out. - Aggregate with
RangeAgg()if you collect ranges from a nullable column. It skipsnull, as PostgreSQL'srange_aggdoes (details).
Check predicates over a null range or a NULL column
Check code that calls a predicate such as Contains, Overlaps or IsEmpty() on a range or set that
can be null, in memory or in a query over a nullable column.
The rule is the one EF Core uses for its own types. A predicate with a null range or set on either
side is false, and its negation true. EF Core writes !b.Name.StartsWith("a") so that it keeps
the rows whose Name is NULL, and the range predicates now do the same.
In memory, most predicates already returned false for a null range, but not all. With nothing
as a null DateRange:
| Call | Before 10.2.0 | Now |
|---|---|---|
nothing.Contains(DateRange.Empty) |
true |
false |
nothing.IsContainedBy(DateRange.Infinite) |
true |
false |
DateRange.Infinite.Contains(nothing) |
true |
false |
nothing.Contains(day) |
throws NullReferenceException |
false |
range.Overlaps(nullSet), set.Contains(nothing) |
throws NullReferenceException, or true |
false |
In a query over a nullable column, a predicate over a NULL value was NULL. A filter dropped the row whether the predicate was negated or not, and a projected predicate threw when it read the NULL:
Query on a nullable DateRange? Period |
Before 10.2.0 | Now |
|---|---|---|
Where(b => !b.Period!.Overlaps(p)) |
the NULL rows are missing | the NULL rows are included |
Where(b => b.Period!.Overlaps(p)) |
no NULL rows | no NULL rows |
Select(b => b.Period!.IsEmpty()) |
throws (InvalidOperationException) at a NULL row |
false for it |
Where(b => !b.Tags!.Contains("x")) on a nullable value set |
the NULL rows are missing | the NULL rows are included |
Where(b => !b.Period.Contains(b.Visit!.On)), for a booking without a visit |
the row is missing | the row is included |
Select(b => b.Period.Contains(b.Day!.Value)) on a nullable DateOnly? Day |
throws (InvalidOperationException) at a NULL Day |
false for it |
Where(b => DateRange.CreateUnboundedEnd(b.Visit!.From).Contains(v)), for a booking without a visit |
the row is included: the range was (,) |
the row is missing: the range is NULL |
The same goes for the element Contains(T) looks for, when it is NULL: a required column of a
missing child in an outer join, or a nullable column read with .Value. The SQL of a required
column doesn't change. A nullable one gets b."Period" IS NULL OR NOT (…) under a negation. A
range factory over a NULL element is NULL, where it built empty (CreateFinite) or (,).
- Search for negated predicates over a nullable column. A
!that silenced the nullable warning, as in!b.Period!.Overlaps(p), often marks the place. - Say "has a period and doesn't overlap" when that is what you mean. Write
Where(b => b.Period != null && !b.Period.Overlaps(p)). It selects the rows the negated predicate selected before. CompareToand==keep their own rules for a NULL column (Sort ranges).- Value sets still throw in memory for a
nullset. Only the query changes for them.
Changed answers in 10.3.0
Some calls return a different value. Your code stays the same, so the compiler can't show you where. Each section below says how to find the calls whose answer changed. 11.0.0 makes the same changes.
Check value set Union and Remove over NULL in queries
Check EF Core queries that load a value set Union or Remove where a set or an element can be NULL:
a nullable column, a column of a missing child in an outer join, or a null parameter.
PostgreSQL's array_cat and array_remove skip a NULL: array_cat(NULL, '{z}') is {z}, and
array_remove('{a}', NULL) is {a}. Before 10.3.0, the EF Core package called them as they are,
while a filter treated the result as NULL. So the same expression was NULL in a Where and a set in a
Select. Now it is NULL in both. With more as {z}:
| Query | Before 10.3.0 | Now |
|---|---|---|
Select(b => b.MaybeTags!.Union(more)), for a NULL MaybeTags |
{z} |
null |
Select(b => b.Tags.Remove(b.Label!)), for a NULL Label |
Tags as stored |
null |
Select(b => b.Tags.Union(b.Visit!.Tags)), for a booking without a visit |
Tags as stored |
null |
Where(b => b.Tags.Remove(b.Label!).IsEmpty), for a NULL Label |
the rows whose Tags is empty |
none of them |
Where(b => b.MaybeTags!.Union(more) == null) |
the NULL rows | the NULL rows |
The SQL of a required column, a non-null parameter and a constant doesn't change. A set or element
that can be NULL gets CASE WHEN … IS NULL THEN NULL ELSE array_cat(…) END.
- Search for
UnionandRemovein aSelect. A!on a nullable set or element, as inb.MaybeTags!.Union(more), often marks the place. - Say which set you want for a NULL one.
(b.MaybeTags ?? StringSet.Empty).Union(more)loadsmorefor the NULL rows, as the query did before. - In memory, both still throw for a
null. Only the query changes.
Move RangeLowerBoundComparer out of queries
Check EF Core queries that call Compare on RangeLowerBoundComparer, on
RangeSet.LowerBoundComparer, or on another IComparer of ranges or sets.
EF Core reads every Compare(x, y) as the natural order of x and y, whatever the comparer. For a
range, that is the range order CompareTo uses, not the lower bound. Before 10.3.0 the comparer ran
in SQL in that order. Now it runs in memory only, like the other members under
What runs where. With p as [2026-03-01,2026-03-10]:
| Query | Before 10.3.0 | Now |
|---|---|---|
Where(r => comparer.Compare(r.Period, p) == 0) |
only the rows equal to p |
throws (InvalidOperationException): could not be translated |
OrderBy(r => comparer.Compare(r.Period, p)) |
sorted in the range order | throws, as above |
Select(r => comparer.Compare(r.Period, p)), for [2026-03-01,) |
1 |
0, computed in memory |
Select(r => comparer.Compare(r.Period, p)), for a NULL Period |
throws (InvalidOperationException) |
-1, computed in memory |
Comparer<DateRange>.Default.Compare runs in memory too. Once the comparer is a query parameter, a
query can't tell one comparer from another. So do comparers declared over another type, when what they
compare is a range or a set: an IComparer<IRange<DateOnly>>, a non-generic IComparer (such as
Comparer<DateRange>.Create(…) held as one) and an IComparer<StringSet>. The same goes for any
other method named Compare that takes two ranges or two sets, such as a static helper or one on a
class that isn't a comparer. The query runs the method you called, so a class whose public Compare
differs from its IComparer<T>.Compare gets the public one, as in memory.
- Search your queries for
Compare(. EachCompareover ranges or sets in aWhereor anOrderBynow throws, so running the query finds it. - Filter and sort by the lower bound itself.
LowerBound()andLowerBoundInclusive()run as SQL, soWhere(r => r.Period.LowerBound() == day)andOrderBy(r => r.Period.LowerBound())work. An ordering puts the rows without a lower bound where PostgreSQL puts NULL, last ascending (details). - Use
CompareTofor the range order.r.Period.CompareTo(p)runs as SQL in the orderComparer<DateRange>.Defaultuses.
Changed answers in 10.4.0
Some calls return a different value. Your code stays the same, so the compiler can't show you where. Each section below says how to find the calls whose answer changed. 11.0.0 makes the same changes.
Check string set columns with a collation
Check StringSet and StringSet<TElement> properties configured with UseCollation, queries that
call EF.Functions.Collate on one, and queries that compare one with a value from a column configured
with UseCollation.
PostgreSQL compares the elements of a text[] column with the column's collation, and memory compares
them ordinally. A deterministic collation breaks every tie by the bytes, so the two agree. A
nondeterministic one, such as a case-insensitive ICU collation, treats A and a as equal. Before
10.4.0 the EF Core package translated the set operators as they are, so with Tags holding {A}:
| Query | Memory | Before 10.4.0 |
|---|---|---|
Where(p => p.Tags.Contains("a")) |
no match | matched |
Where(p => p.Tags.IsSubsetOf(StringSet.From("a"))) |
no match | matched |
Where(p => p.Tags == StringSet.From("a")) |
no match | matched |
Select(p => p.Tags.Remove("a")) |
{A} |
{} |
ExecuteUpdate(… p => p.Tags.Remove("a")) |
— | stored {} |
Now building the model throws InvalidOperationException and names the property. The collations that
compare bytes are allowed: "C", "POSIX", ucs_basic, pg_c_utf8 and default. A query over
EF.Functions.Collate(p.Tags, …) with any other collation throws too.
- Search for
UseCollationon string set properties. The exception names each one. - If you wanted case-insensitive matching, store the elements folded. Lower-case them before you build the set, in your code or in a wrapper element type, so memory and the database compare the same text.
- Otherwise remove the collation, or use
"C". A deterministic collation other than those five answers as memory does, but the model can't tell it from a nondeterministic one by its name. - A collation given to the column outside your model isn't seen. Keep it deterministic.
The value you compare with a string set counts too, because PostgreSQL compares under the collation of
either side. With Members holding {A,b}, no collation on it, and Owner holding "a" under a
case-insensitive collation:
| Query | Memory | Before 10.4.0 |
|---|---|---|
Where(g => g.Members.Contains(g.Owner)) |
no match | matched |
Where(g => g.Members.Contains(EF.Functions.Collate("a", "case_insensitive"))) |
no match | matched |
ExecuteUpdate(… g => g.Members.Remove(g.Owner)) |
— | stored {b} |
Now such a query throws InvalidOperationException and names the collation. The same five collations
are allowed on the value's column.
- Compare with
EF.Functions.Collate(g.Owner, "C"). An explicit collation wins over the column's, so the query compares bytes, as memory does. Unless the set column is collated"C"too, PostgreSQL then can't use a GIN index on it for that comparison. - Or load the value first. A value your code passes in has no collation.
Check date bounds at an exclusive infinity in queries
Check EF Core queries that call LowerBound(), UpperBound(), LowerBoundInclusive() or Clamp on
a DateRange, a LocalDateRange or a RangeSet of them. Rows that another tool wrote are the ones
to look at, and so are ranges your query computes at the date extremes.
PostgreSQL keeps an infinite bound as written, so [2026-01-01,infinity) and (-infinity,2026-01-01)
exist. Memory loads them one day inside the extreme, as [2026-01-01,9999-12-30] and
[0001-01-02,2025-12-31]. Before 10.4.0 a query read the bound the database holds instead:
| Query | Memory | Before 10.4.0 |
|---|---|---|
Select(r => r.Period.UpperBound()) on [2026-01-01,infinity) |
9999-12-30 | 9999-12-31 |
Select(r => r.Period.Clamp(DateOnly.MaxValue)) on [2026-01-01,infinity) |
9999-12-30 | 9999-12-31 |
Select(r => r.Period.LowerBound()) on (-infinity,2026-01-01) |
0001-01-02 | 0001-01-01 |
Select(r => r.Period.Clamp(DateOnly.MinValue)) on (-infinity,2026-01-01) |
0001-01-02 | 0001-01-01 |
Where(r => r.Period.LowerBoundInclusive()) on (-infinity,2026-01-01) |
matches | no match |
Now the query returns what memory returns. The EF Core package never writes these forms, so the rows
it stored give the same answers as before. A range your query computes can hold one:
r.Period.Except(DateRange.CreateUnboundedStart(DateOnly.MinValue, true)) on (,) is
(-infinity,) in the database, and its LowerBound() was 0001-01-01.
- On a
LocalDateRange, only the upper side changes. Memory starts(-infinityon -9998-01-02, and PostgreSQL has no such date. So in a queryLowerBound()still givesLocalDate.MinIsoValue. - With
Npgsql.DisableDateTimeInfinityConversionsset, nothing changes. Such a row doesn't load, and a query that returns its infinite bound throws as it did. - A
YearMonthRangecan't hold these forms. Loading one throws, as before (Rows written by other tools).
Check YearMonthRange upper bounds compared in queries
Check EF Core queries that compare UpperBound() or Clamp of a YearMonthRange, or UpperBound()
of a RangeSet of them, with a month. Look in Where, in OrderBy keys compared later, and in
projected comparisons.
A YearMonthRange is stored as a month-aligned daterange, and a month you pass in is sent as its
first day. Before 10.4.0, a query read the upper bound as the last day of the month. It loaded as the
right month, but a comparison saw two different dates:
Query, on [2026-01,2026-01] |
Memory | Before 10.4.0 |
|---|---|---|
Where(r => r.BillingPeriod.UpperBound() == new YearMonth(2026, 1)) |
matches | no match |
Where(r => r.BillingPeriod.UpperBound() > new YearMonth(2026, 1)) |
no match | matches |
Where(r => r.BillingPeriod.Clamp(new YearMonth(2026, 2)) == new YearMonth(2026, 1)) |
matches | no match |
Select(r => r.BillingPeriod.UpperBound()) |
2026-01 | 2026-01 |
Now the query reads the first day of the month and compares as memory does. A projected bound loads as before.
Clampon aYearMonthRangefirst runs as SQL in 10.4.0. Before, it ran in memory in a final projection and failed translation in a filter, so onlyUpperBound()gave other answers.LowerBound()doesn't change. It always read the first day of the month.