Entity Framework Core (PostgreSQL)
With the EF Core package you store ranges, range sets and value sets in native PostgreSQL columns, and your LINQ queries over them run as SQL. A query gives the same answer in the database as the same code gives in memory.
dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL
Map ranges to PostgreSQL columns
Turn the package on with one line:
options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges());
You don't configure value converters, comparers or column types. Every range type maps to its
PostgreSQL range column, and every RangeSet<TRange, T> maps to the matching multirange column,
by convention:
| Property type | Column type |
|---|---|
Int32Range |
int4range |
RangeSet<Int32Range, int> |
int4multirange |
DateRange |
daterange |
RangeSet<DateRange, DateOnly> |
datemultirange |
TimeRange |
timerange (custom type) |
Float64Range |
float8range (custom type) |
Float32Range |
float4range (custom type) |
| … and so on for all types |
Good to know:
- The package bridges to Npgsql through
NpgsqlRange<T>. The conversion happens at the provider boundary, and your properties keep the range types. - Scaffolding gives you
NpgsqlRange<T>.dotnet ef dbcontext scaffoldmaps range columns toNpgsqlRange<T>, not to these types, because the package provides no design-time services. Change the property types by hand after scaffolding. - A property's type picks its mapping, before any column type.
HasColumnType("int4range")on anNpgsqlRange<int>property keeps Npgsql's own mapping. On anInt32Rangeproperty, the column type of another range, such asint8range, fails when EF Core builds the model (InvalidOperationException). - A range or multirange column type on its own maps to these types. Ask EF Core's
IRelationalTypeMappingSourceforFindMapping("int4range")with no property type, and you get theInt32Rangemapping, orRangeSet<Int32Range, int>forint4multirange. WithUseValueRangesNodaTime, which turns on Npgsql's NodaTime plugin, that plugin takesdaterange,tsrangeandtstzrangefirst (DateInterval,NpgsqlRange<LocalDateTime>,Interval). The multiranges still map to the core types. - An array column type on its own stays Npgsql's.
text[]is also the column type of everystring[]andList<string>property, so a value set maps only from a property typed as one.
Query ranges with LINQ
The full range algebra runs as SQL:
var day = new DateOnly(2024, 6, 15);
// b."Period" @> @day
bookings.Where(b => b.Period.Contains(day));
// b."Period" && b."Blocked", b."Period" << @other, b."Period" -|- @other, ...
bookings.Where(b => b.Period.Overlaps(other));
// b."Period" * @other (intersection)
bookings.Select(b => b.Period.Intersect(other));
// datemultirange(b."Period") + datemultirange(@other) (union -> multirange)
bookings.Select(b => b.Period.Union(other));
// b."BlockedDays" @> @day, multirange + - * operators, complement, ...
bookings.Where(b => b.BlockedDays.Contains(day));
bookings.Select(b => b.BlockedDays | b.Period);
// CASE WHEN b."From" <= b."To" THEN daterange(b."From", b."To", '[]') ELSE 'empty' END
bookings.Where(b => DateRange.CreateFinite(b.From, b.To).Contains(day));
Each method becomes one PostgreSQL operator or function call:
Contains,OverlapsandIsContainedBybecome@>,&&and<@.IsStrictlyLeftOf/RightOfbecome<<and>>.DoesNotExtendLeftOf/RightOfbecome&<and&>.IsAdjacentTobecomes-|-.CompareToagainst 0 becomes<,<=,>,>=or=, for a range and a multirange, in the orderOrderBysorts by (Sort ranges).Intersectbecomes*.UnionandExceptturn both ranges into multiranges first, then use+and-. That matches theirRangeSetreturn type. A union of two ranges that don't touch is a real two-element multirange, never an error.CreateFinite,CreateUnboundedStartandCreateUnboundedEndbecome range constructor calls inside a guard. As in memory, a start after the end gives you an empty range. Over a NULL element, such as a column of a missing child in an outer join, the range is NULL, as EF Core's ownAddDaysis.
Since v4 the eight predicates above (Contains through IsAdjacentTo) also work on a RangeSet,
with either a range or a multirange on the other side. Since 10.0.0 they also work on a range with a
multirange on the other side, as the operator with the range on the left
(b."Period" &< b."Blocked"), and RangeSet.IsContainedBy runs as <@.
On a nullable column, a predicate over a NULL value is false, and its negation true. That is what
EF Core does for a string or string[] column, and what the methods return in memory for a null
range. So !b.Period.Overlaps(other) keeps the rows whose Period is NULL, as
!b.Name.StartsWith("a") keeps the rows whose Name is NULL. The SQL adds
b."Period" IS NULL OR NOT (…) for a nullable column and nothing for a required one. Value set
predicates follow the same rule in a query, though in memory they throw for a null set. So does
Contains(day) when day is NULL, as it is for a column of a missing child in an outer join or a
nullable column read with .Value.
CompareTo keeps its own rule for a NULL column (Sort ranges).
Read bounds, merge ranges and aggregate
Since v4 you can also read bounds, merge ranges, aggregate per group and compare multiranges:
// ORDER BY lower(b."Period") — bound accessors: lower / upper / lower_inc / upper_inc
// A row with no lower bound, or an empty one, sorts last (see "Good to know" below)
bookings.OrderBy(b => b.Period.LowerBound());
// range_merge(b."Period", @other) and range_merge(b."BlockedDays")
bookings.Select(b => b.Period.Merge(other));
bookings.Select(b => b.BlockedDays.Merge());
// range_merge((datemultirange(range_merge(b."Period", @other)) - datemultirange(b."Period"))
// - datemultirange(@other)) (the gap between them)
bookings.Where(b => !b.Period.Gap(other).IsEmpty());
// datemultirange(range_merge(b."BlockedDays")) - b."BlockedDays" and
// datemultirange(@month) - b."BlockedDays" (the free slots)
bookings.Select(b => b.BlockedDays.Gaps());
bookings.Select(b => b.BlockedDays.Gaps(month));
// range_agg(b."Period") / range_intersect_agg(b."Period") per group
bookings.GroupBy(b => b.CustomerId)
.Select(g => g.Select(b => b.Period).RangeAgg());
// range_agg(b."BlockedDays") / range_intersect_agg(b."BlockedDays") over a multirange column
bookings.GroupBy(b => b.CustomerId)
.Select(g => g.Select(b => b.BlockedDays).RangeIntersectAgg());
// isempty / lower_inf / upper_inf on multirange columns
bookings.Where(b => !b.BlockedDays.IsEmpty());
// Value equality on multirange columns — b."BlockedDays" = @set
bookings.Where(b => b.BlockedDays == someSet);
Merging ranges and finding gaps run as functions over multiranges:
Mergebecomesrange_merge.Gapbecomesrange_mergeof the hull minus both ranges, computed on multiranges. PostgreSQL has no gap function, and subtracting one range from another fails when two ranges would remain.Gaps()andGaps(window)on aRangeSetbecome the hull minus the set, and the window minus the set.
The state checks on a range become PostgreSQL functions:
IsEmpty()becomesisempty.IsUnboundedStart()becomeslower_inf.IsUnboundedEnd()becomesupper_inf.IsInfinity()becomeslower_inf AND upper_inf.IsFinite()becomesNOT lower_inf AND NOT upper_inf AND NOT isempty.
Good to know:
- On a
RangeSet,IsInfinity()is an equality check. The other state checks use the multirange forms of the same functions.IsInfinity()instead becomes an equality with the infinite multirange:x = '{(,)}'::datemultirange.lower_inf AND upper_infis right for a range but wrong for a multirange, because a multirange can be unbounded at both ends and still have a gap. PostgreSQL normalizes multiranges the same way this library does, so the equality is exact (verified against live PostgreSQL). LowerBound()andUpperBound()returnT?. PostgreSQL'slowerandupperreturnNULLfor an unbounded or empty range, and the in-memory methods match that. So== null,HasValueand??on a bound select the same rows in a query as in memory, on a range or aRangeSetcolumn.- Ordering by a bound puts the rows without it where PostgreSQL puts NULLs.
OrderBy(b => b.Period.LowerBound())sorts the empty rows and the rows with no lower bound last, andOrderByDescending(b => b.Period.UpperBound())sorts those with no upper bound first. That holds on a required column too, because the bound is NULL there. In memory,nullsorts first ascending and last descending. To sort as memory does, put a!= nullkey in front:OrderBy(b => b.Period.LowerBound() != null).ThenBy(b => b.Period.LowerBound()), orOrderByDescendingandThenByDescendingfor the other direction. To sort open starts first, as a range's own order does, order by the range:OrderBy(b => b.Period)(Sort ranges). - Discrete upper bounds are adjusted for you. For
int4range,int8rangeanddaterange, PostgreSQL normalizes to the half-open form[lower, upper). This library normalizes to the closed form[lower, upper]. SoUpperBound()becomesupper(x) - 1.UpperBoundInclusive()becomesNOT upper_inf(x) AND NOT isempty(x), andLowerBoundInclusive()becomesNOT lower_inf(x) AND NOT isempty(x). The database always returns the same results as memory (verified against live PostgreSQL). On a date range, an exclusiveinfinityor-infinityis read one day inside, as the row loads (Rows written by other tools). On aYearMonthRange,UpperBound()steps back a month instead of a day (Store a YearMonthRange). - Aggregates over zero rows differ. In SQL the aggregates return
NULLfor zero input rows, which is standard PostgreSQL aggregate behaviour. The in-memoryRangeAgg()returns the empty set.RangeIntersectAgg()returnsnullin both. - The factory flags must be constants. To run as SQL, the bound-inclusiveness flags of the
factory methods must be compile-time constants, because they pick the bounds literal, such as
'[]'. In practice they always are, because the flags take their default at the call site.
Match a column against a list of ranges
var wanted = new List<Int32Range> { Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(10, 20) };
// b."Seats" IN (@wanted1, @wanted2) — one parameter per range
bookings.Where(b => wanted.Contains(b.Seats));
Each range in your list goes to PostgreSQL as its own parameter, and Contains runs as IN. You
get exactly the rows whose range equals one in your list, the same rows as in memory. This works
for a list, an array or any other collection you capture, and for every range type, TimeRange
and the NodaTime ranges included.
Any and Count over the list read the ranges from VALUES, so overlapping, adjacent, duplicate
and empty ranges stay what they are. An empty list matches nothing: Contains is false, Any
with a condition is false and Count with a condition is 0.
Good to know:
EF.Parameter(wanted)throws. It asks for the whole list as one parameter, and Npgsql would send that as a multirange rather than as an array of ranges. RemoveEF.Parameter, and the list goes out one parameter per range.- Before 9.1.0 a captured list failed. PostgreSQL rejected the parameter
(
42809 op ANY/ALL (array) requires array on right side), and forTimeRangeNpgsql threw (InvalidCastException). A constant list and an inlinenew[] { … }already worked. - Before 10.0.0,
AnyorCountalone over a captured list returned a different answer from memory. Without aContainsin the same query, the list went out as one parameter that PostgreSQL read as a multirange, which merges overlapping and adjacent ranges and drops empty ones.wanted.Count(w => w.Overlaps(b.Seats))over[1,5]and[3,8]was 1 where memory says 2. If you wrapped the list inEF.Constantto avoid that, you can remove the wrapper; it still works. - A list of
RangeSetvalues needs nothing. It goes out as one array of multiranges, which is what it is.
Store timestamp ranges
Here is what happens to the bounds of DateTimeRange and DateTimeOffsetRange on the way to the
column:
DateTimeRangekeeps the wall-clock time. Its bounds are written astimestampwithDateTimeKind.Unspecified. A UTCDateTimeis read as wall-clock time, not converted.DateTimeOffsetRangekeeps the instant, not the offset. Its bounds are converted to UTC fortimestamptz. Values you read back carry the offset+00:00. They still compare equal to what you wrote, becauseDateTimeOffsetequality compares instants.- A single value you pass is converted the same way.
range.Contains(dto)takes aDateTimeOffsetwith any offset, andrange.Contains(dt)takes aDateTimeof anyKind. DateTime.MaxValuebecomesinfinity. By default Npgsql mapsDateTime.MinValueandMaxValueto PostgreSQL's-infinityandinfinity. So a finite bound ofDateTime.MaxValuebecomes an explicitinfinitybound in the database. That is still different from an unbounded end (upper_infstaysfalse), so the shape checks behave consistently.- A date at the extreme is adjacent to nothing in the database. Npgsql also writes
DateOnly.MinValueandMaxValue, andLocalDate.MinIsoValueandMaxIsoValue, as-infinityandinfinity, and no date is next to those. SoIsAdjacentToreturnsfalsein SQL for[0001-01-01,0001-01-01]and[0001-01-02,), andtruein memory, where 0001-01-01 is a day like any other. One day inside the extremes the two sides agree. If you setNpgsql.DisableDateTimeInfinityConversions, the extremes stay dates and the two sides agree there too.
Indexing a range column, and preventing overlaps
A GiST index over a range column is ordinary EF configuration, and the package isn't involved:
modelBuilder.Entity<Booking>()
.HasIndex(b => b.Period)
.HasMethod("gist");
The index serves &&, @>, <@ and the positional operators. It does not stop two overlapping
rows from being written, and your application code can't close that gap on its own. Checking for an
overlap and then inserting is a read-then-write race, so under concurrency two requests can both
find the slot free. Only a database constraint is atomic.
Reject overlapping rows with an exclusion constraint
PostgreSQL's tool for this is an exclusion constraint.
EFCore.ComplexIndexes.PostgreSQL
declares one from your model. It accepts a range column mapped by this package exactly as it accepts
an NpgsqlRange<T>. That works because the constraint is declared over the column, and these
types map as ordinary scalars:
modelBuilder.Entity<Booking>().HasExclusionConstraint(ex => ex
.WithEquality(b => b.RoomId) // same room …
.WithOverlaps(b => b.Period) // … must not overlap in time
.HasName("ex_booking_room_period"));
The migration creates this:
CREATE EXTENSION IF NOT EXISTS btree_gist; -- injected automatically for the scalar `=` element
ALTER TABLE "Bookings" ADD CONSTRAINT "ex_booking_room_period"
EXCLUDE USING gist ("RoomId" WITH =, "Period" WITH &&);
Now a second booking for the same room whose Period overlaps an existing one fails. PostgreSQL
raises 23P01 (exclusion_violation), and you get a DbUpdateException wrapping a
PostgresException that names the constraint.
- Different rooms aren't affected.
- Adjacent periods are accepted.
daterangeis discrete and canonical, so[2024-01-01, 2024-01-10]and[2024-01-11, 2024-01-20]are adjacent, not overlapping, and both are saved. That is the same distinctionIsAdjacentToandOverlapsdraw in memory.
EnsureCreated()creates exclusion constraints only withUseNpgsqlComplexIndexes(). A design-time service emits them, anddotnet ef migrations addloads it automatically.EnsureCreated()builds the schema through the runtime differ, which doesn't know about them. The table is created, the constraint silently isn't, and overlapping rows are accepted. This was verified: a table fromEnsureCreated()carries zero exclusion constraints. Use migrations, add the constraint with explicit SQL, or callUseNpgsqlComplexIndexes()on the options. That registers the package's differ forEnsureCreated()too, so the constraints are created.
Everything in this section was run against PostgreSQL 17 with a DateRange property, including
the rejected overlap and the accepted adjacent pair.
Reject overlapping rows with a temporal key (PostgreSQL 18)
PostgreSQL 18 has this rule built in, as a temporal key: a UNIQUE constraint whose last column is a
range column marked WITHOUT OVERLAPS. EFCore.ComplexIndexes.PostgreSQL declares one with
HasTemporalConstraint, over any range or multirange column this package maps:
modelBuilder.Entity<Booking>()
.HasTemporalConstraint(b => b.RoomId, b => b.Period, "uq_booking_room_period");
The constraint it creates reads back from PostgreSQL as:
UNIQUE ("RoomId", "Period" WITHOUT OVERLAPS)
The rule is the exclusion constraint's. A second booking for the same room whose Period overlaps
an existing one fails with a DbUpdateException wrapping a PostgresException. Its SqlState is
23P01 and its ConstraintName is the constraint's name. Bookings for another room, and adjacent
bookings, are saved.
A temporal foreign key goes one step further. It requires each row's period to lie inside the periods the referenced rows hold for the same key:
modelBuilder.Entity<Availability>()
.HasTemporalConstraint(a => a.RoomId, a => a.Period, "uq_availability_room_period");
modelBuilder.Entity<Booking>()
.HasTemporalForeignKey<Booking, Availability>(
b => b.RoomId, b => b.Period, a => a.RoomId, a => a.Period, "fk_booking_room_period");
That gives FOREIGN KEY ("RoomId", PERIOD "Period") REFERENCES "Availabilities"("RoomId", PERIOD "Period").
A booking passes when the room's availabilities cover its period together, even across two adjacent
rows. A booking that runs past them, or names a room with no availability, fails with SqlState
23503 (foreign_key_violation). PostgreSQL requires the referenced columns to carry a temporal key,
which is why Availability declares one.
Good to know:
- It needs PostgreSQL 18. Older servers reject
WITHOUT OVERLAPSas a syntax error. On 16 and 17, use the exclusion constraint above. btree_gistis created for you. The constraint indexes the scalar key with GiST, which needs the extension, and ComplexIndexes addsCREATE EXTENSION IF NOT EXISTS btree_gistto the schema.EnsureCreated()needsUseNpgsqlComplexIndexes(), exactly as for the exclusion constraint.- The API is ComplexIndexes'. This package adds nothing for temporal keys. They work because
every range and multirange type here maps to a column whose store type ends in
range, which is whatHasTemporalConstraintaccepts.
Everything in this section was run against PostgreSQL 18 with EFCore.ComplexIndexes.PostgreSQL
5.6.0, over a DateRange, a RangeSet<DateRange, DateOnly> and a TimeRange column for the
temporal key, and a DateRange column for the foreign key.
Store a value set in an array column
The same package maps every value set type to its native PostgreSQL array column,
by convention, with nothing to configure. Versions with your own element type, such as
StringSet<AccessRight>, are recognized automatically from the open generic type. There is no
registration per element type to forget.
| Property type | Column type |
|---|---|
StringSet, StringSet<TElement> |
text[] |
GuidSet, GuidSet<TElement> |
uuid[] |
Int32Set, Int32Set<TElement> |
integer[] |
DateSet |
date[] |
YearMonthSet (NodaTime) |
date[] (month-aligned) |
| … and so on for all types |
The set operations run as PostgreSQL array operators:
// b."Tags" @> ARRAY[@tag]::text[] — containment, not = ANY: a GIN index always serves it
bookings.Where(b => b.Tags.Contains(tag));
// b."Tags" && @wanted — order- and duplicate-insensitive, like all of these
bookings.Where(b => b.Tags.Overlaps(wanted));
// b."Tags" <@ @allowed / b."Tags" @> @required
bookings.Where(b => b.Tags.IsSubsetOf(allowed));
bookings.Where(b => b.Tags.IsSupersetOf(required));
// b."Tags" <@ @allowed AND NOT (b."Tags" @> @allowed) — the negated converse, not <>,
// so proper containment stays duplicate-insensitive like everything else here
bookings.Where(b => b.Tags.IsProperSubsetOf(allowed));
// cardinality(b."Tags") > 2 / cardinality(b."Tags") = 0
bookings.Where(b => b.Tags.Count > 2);
bookings.Where(b => b.Tags.IsEmpty);
// array_remove(b."Tags", @tag) — preserves canonical form, so it composes freely
bookings.Where(b => b.Tags.Remove(tag).Count > 1);
// array_cat(b."Tags", @more) @> ARRAY[@tag]::text[]
bookings.Where(b => b.Tags.Union(more).Contains(tag));
Union, Add, Intersect and Except run as SQL too, on every set type except the two text
ones. The result is deduplicated and sorted, as in memory, so you can compare it, count it, group
by it and store it:
// ARRAY((SELECT DISTINCT ON (vr0.x) vr0.x
// FROM unnest(array_cat(b."Codes", @more)) WITH ORDINALITY AS vr0(x)
// WHERE vr0.x IS NOT NULL
// ORDER BY vr0.x, vr0.ordinality)) = @expected
bookings.Where(b => b.Codes.Union(more) == expected);
// the same subquery over unnest(b."Codes"), keeping the elements @wanted contains
bookings.Where(b => b.Codes.Intersect(wanted).Count > 1);
// stores the deduplicated, sorted set
bookings.Where(b => b.Id == id)
.ExecuteUpdate(s => s.SetProperty(b => b.Codes, b => b.Codes.Add(code)));
On StringSet and StringSet<TElement>, Union, Add, Intersect and Except run as SQL
with the result left unsorted. PostgreSQL sorts text by the database collation rather than
ordinally, so it can't build the order your sets have in memory. They run where order and repeats
don't count, and the query throws everywhere else
(Combine sets with Union).
Good to know:
- Equal values keep the first copy, as in memory.
DecimalSetkeeps whichever of1.0and1.00comes first, andFloat64SetandFloat32Setwhichever of-0and0. For aUnion, the set you call it on comes first. - The deduplicated form costs a subquery per row. It took about 1.5 µs a row over ten elements
on PostgreSQL 16, roughly fifteen times a bare
array_cat. Where order and duplicates can't change the answer, the query skips it: underContains,Overlaps, the subset and superset methods,IsEmptyand a null test, and in the finalSelect, which is loaded and put in canonical form anyway. - Your own element types must sort as their underlying type does. In the database,
Int32Set<OrderId>sorts the stored integers. In memory it sorts byOrderId.CompareTo. How your element types sort and serialize says what that requires. Removekeeps the set canonical.array_removeleaves the array sorted and deduplicated, so you can combine it with anything. The text set operations are different, as the next section shows.- Your own element types travel as their underlying type. An
AccessRightparameter is bound astext, and loading a row runs the element's validation again. - An empty set and
NULLstay different. They are{}andNULLin the column. Whether the column allowsNULLis up to the property. - A NULL set or element gives NULL. In a query,
Union,IntersectandExceptgive NULL when either set is NULL, andAddandRemovegive NULL when the set or the element is. ASelectloadsnullfor those rows, andWhere(… == null)selects them. In memory, they throw for anull. - A string set column compares its elements by the column's collation. Memory compares them
ordinally. A case- or accent-insensitive collation would make
Contains, the subset operators,==andRemoveanswer differently in the database, andRemoveinExecuteUpdatewould delete elements memory keeps. So building the model throws for a collation on aStringSetorStringSet<TElement>property unless it compares bytes:"C","POSIX",ucs_basic,pg_c_utf8ordefault.EF.Functions.Collateover a string set throws the same way. A column that something outside your model gave a collation can't be checked, so keep that collation deterministic. - The value you compare with a string set brings its collation too. PostgreSQL compares under the
collation of either side. So a query throws when it compares a string set with a value from a column
whose collation may not compare bytes, or with
EF.Functions.Collate(value, …)under one, and the message names the collation. That coversContains,Add,Remove,==and the subset operators, inExecuteUpdatetoo. To compare such a value as memory does, writeg.Members.Contains(EF.Functions.Collate(g.Owner, "C")). - Plain arrays keep their own mapping.
T[]andList<T>properties keep their native Npgsql mapping, and both can sit next to value sets in one model. Scaffolding a database gives you plain arrays, because choosing a set type is a decision you make in your model. - The NodaTime package adds five set types. The same
UseValueRangesNodaTime()call registers them.YearMonthSetstores first-of-month dates, and reading checks that each date is the first of its month, exactly likeYearMonthRange.
Match a column against a list of sets
var wanted = new List<StringSet> { StringSet.From("a", "b"), StringSet.From("c") };
// b."Tags" IN (@wanted1, @wanted2) — one parameter per set
bookings.Where(b => wanted.Contains(b.Tags));
// EXISTS (SELECT 1 FROM (VALUES (@wanted1), (@wanted2)) AS w("Value") WHERE w."Value" && b."Tags")
bookings.Where(b => wanted.Any(w => w.Overlaps(b.Tags)));
Each set in your list goes to PostgreSQL as its own parameter, and Contains runs as IN. IN
compares the column with each set in turn. Every set is stored deduplicated and sorted, so you get
exactly the rows whose set equals one in your list, the same rows as in memory. This works for a
list, an array or any other collection you capture, and for every set type. Any and Count over
the list read the sets from VALUES.
Good to know:
EF.Parameter(wanted)throws. It asks for the whole list as one parameter. A set is already a PostgreSQL array, so that parameter would be an array of arrays, which PostgreSQL doesn't have. RemoveEF.Parameter, and the list goes out one parameter per set.- Your parameterized-collection setting is kept. A context set to
UseParameterizedCollectionMode(ParameterTranslationMode.Constant), and a list wrapped inEF.Constant, still put the sets into the SQL as literals. - An empty list matches nothing.
Containsisfalse,Anywith a condition isfalseandCountwith a condition is0, as in memory. Before 10.0.0,AnyandCountover an empty list threw (InvalidOperationException, "Empty collections are not supported as inline query roots"). - Before 9.1.0 a captured list failed. Npgsql sent it as one array parameter and threw
(
InvalidCastException) because it couldn't write the array of arrays. A constant list and an inlinenew[] { … }already worked. - A text set operation in the database matches one set, not a list of several.
wanted.Contains(b.Tags.Union(more))throws whenwantedholds more than one set, becauseINcompares arrays as written, as the next section explains. With one set, EF Core writes==, which runs. On the other set types both run.
Combine sets with Union
On every set type except the two text ones, Union, Add, Intersect and Except give the
deduplicated, sorted set shown above, and you can use them anywhere. This section is about
StringSet and StringSet<TElement>.
On text, PostgreSQL can't sort the result as memory does, because it sorts text by the database
collation rather than ordinally. So these four run as SQL and leave the result unsorted. Union is
array_cat, which also repeats an element both sets hold. Add appends the element, and
Intersect and Except filter the first set. Most uses don't depend on order or repeats, and
they run as SQL with the answer memory gives:
bookings.Select(b => b.Tags.Union(more)); // fine: read back deduplicated and sorted
bookings.Where(b => b.Tags.Union(more).IsSupersetOf(need)); // fine: @> ignores order and repeats
bookings.Where(b => b.Tags.Union(more).Count > 2); // fine: counts the distinct elements
bookings.Where(b => b.Tags.Intersect(more) == expected); // fine: compared by mutual containment
bookings.OrderBy(b => b.Tags.Union(more)); // throws
Count counts the distinct elements, (SELECT count(DISTINCT x) FROM unnest(…)). == and !=
compare by mutual containment: two sets are equal when each contains the other, a @> b AND b @> a, and neither order nor repeats change that. Plain array equality would get both wrong in
the database:
{a,c} ∪ {a,b} = {a,b,c}is false because of the repeated element.{a,c} ∪ {b} = {a,b,c}is false because of the order alone, where nothing repeats.
Comparing two stored columns keeps =, which a btree index can serve
(Rows written by other tools).
The check runs on the translated SQL, right before it is sent, so it doesn't matter how you
write the query. It follows every value derived from one of these four: the operation itself,
array_remove over one, a conditional or ?? that may return one, and a subquery or Concat
column that projects one. Such a value may feed:
@>,<@and&&, that isContains,Overlaps, and the subset and superset methods.Remove,IsEmptyandCount.==and!=.- A null test.
- The outermost projection, which is loaded and so put in canonical form.
Anything else throws, including:
GroupBy,DistinctandOrderBy.Union,IntersectandExceptof queries.Containsover a list of several sets.- An
ExecuteUpdatevalue. It would store the unsorted array, and from then on==against that row would be wrong for good.
Good to know:
- Before 10.4.0, less of this ran as SQL.
Intersect,ExceptandAddon text ran in memory only, andCount,==and!=over a textUnioncomputed in the database threw. In a projection,Countran in memory on the loaded set. - Before 9.0.0 the check missed most cases. A LINQ-level pattern caught only
==/Equalsin predicates andCountover a bare union. A projection, a conditional, a coalesce, a grouping key,DistinctandExecuteUpdateall ran and returned wrong answers. - Keep the SQL check in place. The check runs from a decorator around the provider's
IRelationalParameterBasedSqlProcessorFactory, and it fails closed. A textUnionrunning as SQL carries the placeholder namevalueranges_union_without_sql_guarduntil the check builds it asarray_cat, andAdd,IntersectandExceptcarryvalueranges_add_without_sql_guardand its siblings. Suppose something replaces that service afterUseValueRanges(), such as aReplaceServicecall or another extension that replaces it rather than decorating it. Then every query with one of them running as SQL fails with PostgreSQL'sfunction valueranges_union_without_sql_guard(…) does not exist, instead of running as if the check weren't there. On the other set types the check also builds the deduplicated form, so theirUnion,Add,IntersectandExceptfail the same way, namingvalueranges_canonical_union_without_sql_guardor itsadd,intersectorexceptsibling. Decorate the registered factory rather than replacing it, and both keep working. - There is no package-specific switch for the in-memory form, on purpose.
AsEnumerable()is the framework's own boundary, and everyone reads it the same way. Its cost, fetching every matching row, is visible where you pay it. A marker buried inside the expression would look local and cheap, and still force the same full load. - Text can't be sorted in SQL the way your sets are. PostgreSQL sorts
textby the database collation rather than ordinally, so a sorted result would disagree with the order your sets have in memory, one layer down. The other set types sort the same way in both, which is why theirUnionis deduplicated and sorted in the database.
Index a value set column
A GIN index is ordinary EF configuration, and the package isn't involved:
modelBuilder.Entity<Booking>()
.HasIndex(b => b.Tags)
.HasMethod("GIN");
Contains runs as containment (@>) on purpose, rather than as = ANY(...), because only
containment can use a GIN index. That gives you one code path, and it can always use the index.
Rows written by other tools
Set equality (==) runs as SQL =, which compares arrays in order. It means set equality exactly
because every writer stores canonical form. Rows that other tools wrote in a non-canonical order
still match every operator above correctly: @>, <@, && and the proper-containment pairs
ignore both order and duplicates. Those rows are put in canonical form when you load them.
Two members depend on every writer storing canonical form:
==, because SQL=compares arrays as sequences.Count, because it runs ascardinality, which ignores order but not duplicates. A row stored as{b,a,b}loads as the two-element set{a,b}. Soset.Countis 2 in memory, whileWHERE "Tags".cardinality = 2doesn't match the row and= 3does.
IsEmpty isn't affected. An array is empty exactly when it has no elements, however many times
any of them repeats.
Union, Add, Intersect and Except don't depend on it either. On every set type except text
they deduplicate and sort whatever the row holds. On text, Count over them counts the distinct
elements and == compares by mutual containment. Either way, comparing or counting their result
gives the answer memory gives, even over such a row.
A NULL element another tool stored can't be loaded: reading the row throws. Where a query builds
the deduplicated form, Union, Add, Intersect and Except drop it, as a set never holds one.
Under the containment operators the query skips that form, and there, as on the column itself, a
NULL element never matches. Count on the column counts it.
A date range that another tool wrote with an exclusive infinite bound loads one day short. For
example, daterange('2026-01-01', 'infinity') loads as [2026-01-01,9999-12-30], and
(-infinity,2026-01-01] as starting on 0001-01-02. DateOnly.MaxValue and MinValue stand for
infinity and -infinity, so the range can hold the extreme only when it includes the infinity.
The literal, JSON and a JSON column read the same text the same way.
In a query, the bound methods read those rows as they load. On a DateRange or a RangeSet of
them, LowerBound() gives 0001-01-02 and UpperBound() gives 9999-12-30 in the database as in
memory. LowerBoundInclusive() gives true, and Clamp clamps to those days. The same goes for a
bound the database computes: (,) minus a range that ends at DateOnly.MinValue starts at an
exclusive -infinity in the database, and on 0001-01-02 in memory.
- A
LocalDateRangethat starts at an exclusive-infinitykeeps the extreme in a query. It loads as starting on -9998-01-02, the day afterLocalDate.MinIsoValue, and PostgreSQL has no such date. So in a queryLowerBound()andClamp(LocalDate.MinIsoValue)giveLocalDate.MinIsoValue. Its upper side reads 9999-12-30, as aDateRangedoes. - With
Npgsql.DisableDateTimeInfinityConversionsset, such a row doesn't load. Reading it throws (InvalidCastException), and a query that returns its infinite bound throws the same way.
Shift and Expand keep a bound that stands for an infinity where it is, so a row read from
tsrange('2026-01-01', 'infinity'), moved and saved, still ends at infinity
(Move or widen a range). A daterange with an exclusive
infinity) is the exception on the way back: it loads as [x,9999-12-30], and that saves as
[x,9999-12-31), a finite date, whether or not you moved it.
If other tools write open-ended dates into your tables, have them write an absent bound
(daterange('2026-01-01', NULL), which loads as [2026-01-01,)) or an inclusive one
('[2026-01-01,infinity]', which loads as [2026-01-01,9999-12-31]). The same holds for
LocalDateRange (Domain width).
A YearMonthRange column can't load the exclusive form at all. [2026-01-01,infinity) holds every
day up to 9999-12-30, which ends part-way through a month, so the query throws
InvalidOperationException, as it does for any range that covers part of a month. (-infinity,x)
fails the same way at its start. The absent and the inclusive forms work: [2026-01-01,infinity]
loads as [2026-01,9999-12], and daterange('2026-01-01', NULL) as [2026-01,).
The difference comes from reading rows normalized while leaving them as written. The alternatives
are rewriting other tools' rows on read, or not translating Count at all, and neither is worth it
for the common case. If you query Count on a table that other tools also write, put the data in
canonical form when it comes in.
Put a range or set inside a JSON column
A range, multirange or value set can sit inside a complex or owned type mapped to a jsonb column:
modelBuilder.Entity<Contract>().ComplexProperty(c => c.Terms, terms => terms.ToJson());
The document holds the column's text, not the API's JSON. Each property is written as the text of its PostgreSQL store type. A query that reaches into the document casts it to that type, so the operators run exactly as on a plain column:
{ "Period": "[2026-01-01,2026-06-30]", "Blocks": "{[2026-02-01,2026-02-28]}", "Tags": "{a,b}" }
WHERE CAST(c."Terms" ->> 'Period' AS daterange) @> @day
That text belongs to the database, and it differs from what System.Text.Json writes for the same value in an API payload:
| Value | API JSON | Inside the document |
|---|---|---|
| a value set | ["a","b"] |
"{a,b}" |
a YearMonth range or set (wrapper arity too) |
"[2026-01,2026-06]", ["2026-01"] |
month-aligned dates: "[2026-01-01,2026-06-30]", "{2026-01-01}" — the cast reads dates |
| a temporal value | every tick (BCL) or nanosecond (NodaTime) | the store's microsecond, truncated toward 2000-01-01 as Npgsql truncates a parameter |
MinValue / MaxValue of DateTime, DateTimeOffset, DateOnly, and Instant, LocalDate, LocalDateTime extremes |
the extreme value | -infinity / infinity, as Npgsql sends them |
This text is also a stored format. Changing it later would mean migrating stored documents, not recompiling. The temporal rule keeps the three copies of a value in agreement:
- A document loads to the same value a native column would.
- A query that casts out of the document compares the value a parameter carries.
If the document held every digit and let the server round them, Doc.Period == period would miss
the row it was saved from. TemporalStoreParityTests checks the rule against a live server.
What works inside the document (measured against PostgreSQL 17):
| Operation | |
|---|---|
SaveChanges: insert, tracked change (to and from null too), nested complex types, replacing the whole document |
✓ — a complex type rewrites the whole document; an owned type writes a lone changed property with jsonb_set, and rewrites the document when several change |
null and empty, kept apart |
✓ |
the range and set operators, bounds, Count, projections |
✓ |
ExecuteUpdate of the whole JSON property, a JSON-to-JSON copy, a range or multirange |
✓ — a range is then stored in PostgreSQL's own text form, which reads back |
ExecuteUpdate of a value set |
refused — EF Core would write it with to_jsonb, a JSON array the document cannot hold, and the row would no longer load. Recognized in the translated UPDATE, so it holds however the setter reaches the property (a join, EF.Property, a nested complex type) |
ExecuteUpdate of a whole complex property that contains a value set |
✓ — EF serializes the complex value through the plugin's writer |
ExecuteUpdate setting a property to null |
fails for every type — a range or multirange in Npgsql (42804, the untyped NULL in to_jsonb(NULL)); a value set is refused before it reaches Npgsql, for the same reason. Set it to null with SaveChanges, or replace the whole JSON property |
ExecuteUpdate on an owned JSON type |
refused by EF Core |
Good to know:
- A filter inside the document doesn't use the column's index. It casts out of the document first. If such a query is hot, keep the value in a plain column of its own as well.
- Before 9.0 these types couldn't be saved in a
ToJson()type.SaveChangesthrew aNullReferenceExceptionfrom inside EF, because the package's type mappings had no JSON reader or writer.
Custom range types
Three range types map to a PostgreSQL range type the database doesn't ship. You create it once, from your model, and the rest works as for the built-in ones:
| .NET type | Column types | Create it with | Good to know |
|---|---|---|---|
TimeRange |
timerange, timemultirange |
HasPostgresRange("timerange", "time") |
No 24:00:00, see below |
Float64Range |
float8range, float8multirange |
HasPostgresRange("float8range", "double precision") |
Finite values only, and no subtype_diff, see below |
Float32Range |
float4range, float4multirange |
HasPostgresRange("float4range", "real") |
Finite values only, see below |
All three also need EnableUnmappedTypes() on the Npgsql data source, so Npgsql can send a type it
doesn't know by name. The value sets need nothing extra: Float64Set is a native double precision[] column, and Float32Set a native real[] column.
TimeRange and the custom timerange type
timerange isn't built into PostgreSQL. To use TimeRange columns you need two one-line additions
beyond UseValueRanges():
// 1. The database needs the type — this generates
// CREATE TYPE timerange AS RANGE (SUBTYPE = time) in your migrations
// (PostgreSQL auto-creates timemultirange alongside it):
modelBuilder.HasPostgresRange("timerange", "time");
// 2. Npgsql needs permission to resolve the unmapped type on the wire:
options.UseNpgsql(connectionString, npgsql => npgsql
.UseValueRanges()
.ConfigureDataSource(dataSource => dataSource.EnableUnmappedTypes()));
// (call EnableUnmappedTypes() on your own NpgsqlDataSourceBuilder instead
// if you pass a pre-built NpgsqlDataSource to UseNpgsql)
Everything else works on its own. All of PostgreSQL's range and multirange operators, functions
and aggregates are polymorphic (anyrange/anymultirange). So every LINQ query runs as SQL on the
custom type exactly as on the built-in ones
(verified against live PostgreSQL).
Good to know:
TimeOnlyhas no24:00:00. PostgreSQL'stimeaccepts the special value24:00:00, whichTimeOnlycan't represent. To say "until the end of the day", use an unbounded end or an inclusiveTimeOnly.MaxValuebound.- A stored
24:00:00bound fails to load. If another tool wrote one, such as[22:00,24:00), loading the row throwsArgumentOutOfRangeExceptionfrom Npgsql'stimeread, and so does a query that selects the bound. Rewrite such rows to an unbounded end:UPDATE … SET hours = timerange(lower(hours), NULL).
Float64Range and the custom float8range type
Create the type without a subtype_diff, and turn on unmapped types as for timerange:
// CREATE TYPE float8range AS RANGE (SUBTYPE = double precision) in your migrations;
// PostgreSQL creates float8multirange alongside it.
modelBuilder.HasPostgresRange("float8range", "double precision");
options.UseNpgsql(connectionString, npgsql => npgsql
.UseValueRanges()
.ConfigureDataSource(dataSource => dataSource.EnableUnmappedTypes()));
Every LINQ query on a Float64Range or RangeSet<Float64Range, double> column runs as SQL, as on
the built-in types. A constant you write in the query, such as r.Score.Contains(0.5), is sent as
'0.5'::double precision, the exact double you wrote.
Good to know:
- Leave out
subtype_diff. The usualsubtype_diff = float8mioverflows when PostgreSQL builds a GiST index over very large values, and the insert orCREATE INDEXfails (22003). Without it, the index builds and serves the range operators. - Only finite values load. A row that another tool wrote with
NaNor an infinity in a bound throws when you load it (ArgumentException). ANaNor infinite parameter throws before the query runs. A query over such a row still runs, though, in PostgreSQL's order: see Keep NaN and infinity out of a float column. Float32Rangeworks the same way overfloat4range. Create it withHasPostgresRange("float4range", "real"). There is nosubtype_diffto leave out:float4mireturnsreal, and PostgreSQL accepts only a function that returnsdouble precision.- The text is PostgreSQL's.
ToString(), a SQL literal and JSON all print adoublethe way PostgreSQL does, so1e23is9.999999999999999e+22and1e15is1e+15. Afloatprints asrealdoes:1e6fis1e+06, and0.1fis0.1, never the0.10000000149011612of the same value as adouble.
Keep NaN and infinity out of a float column
The finite-only rule covers every value your code creates, binds or loads. It can't cover a row that
another tool writes into the same column. A query that runs in the database over such a row answers
in PostgreSQL's order, where NaN sorts above every number, and only loading the entity throws:
| Another tool stored | Your query | Result |
|---|---|---|
Score = '[0,NaN)' |
Where(r => r.Score.Contains(1e308)) |
the row matches |
Ratio = 'NaN' in a double column |
Where(r => r.Score.Contains(r.Ratio)) on [0,) |
the row matches |
Weights = '{1,NaN}' |
Select(r => r.Weights.Count) |
2 |
To keep these values out of the database, give each float column a CHECK constraint:
modelBuilder.Entity<Reading>().ToTable(table =>
{
// A range: both bounds. lower and upper return NULL for an unbounded side and the empty range,
// and a CHECK that is NULL passes.
table.HasCheckConstraint("CK_Reading_Score_Finite",
"""lower("Score") NOT IN ('NaN', 'Infinity', '-Infinity') AND upper("Score") NOT IN ('NaN', 'Infinity', '-Infinity')""");
// A multirange: its text. Its lower and upper are only the outer bounds, so {(,-Infinity],[1,2)}
// would pass the range check above. A finite float never prints these letters.
table.HasCheckConstraint("CK_Reading_Bands_Finite",
""""Bands"::text !~* '(nan|inf)'""");
// An array: no element in common with the three values.
table.HasCheckConstraint("CK_Reading_Weights_Finite",
"""NOT ("Weights" && '{NaN,Infinity,-Infinity}')""");
});
The same three expressions work for float4range, float4multirange and real[]. For a range,
multirange or array column, they reject NaN, Infinity and -Infinity and accept every finite
value, an unbounded side, the empty range and NULL. On a column that already holds such rows,
adding the constraint fails until you fix them.
Store a YearMonthRange
The NodaTime package stores YearMonthRange as a month-aligned daterange. For example,
[2024-01, 2024-03] becomes [2024-01-01, 2024-04-01). You need no custom database type, and
every operator, bound accessor and aggregate runs as SQL and agrees with the in-memory results.
In a query, UpperBound() reads the first day of the end month, which is how a YearMonth you pass
in is sent. So r.BillingPeriod.UpperBound() == month and r.BillingPeriod.Clamp(month) compare
with a month in a filter as they do in memory. The last month, 9999-12, is stored as infinity and
reads as 9999-12-01 too.
Reading checks month alignment. A daterange that covers part of a month fails loudly, instead of
silently shifting the boundaries.
There is one restriction. Months are coarser than the date type underneath, so the
CreateFinite/CreateUnbounded* factories can't build a range in SQL from column values.
Constant and parameter ranges work as usual. A factory call that depends on a column fails
translation with a clear error. In practice that only matters when a query builds the range from
a column:
// Factories over constants and locals never reach the translator — EF evaluates
// them client-side and the result renders as a month-aligned daterange literal:
// r."BillingPeriod" && '[2024-01-01,2024-06-30]'::daterange
var from = new YearMonth(2024, 1); var to = new YearMonth(2024, 6);
reservations.Where(r => r.BillingPeriod.Overlaps(YearMonthRange.CreateFinite(from, to)));
// Building the range from a column would need month arithmetic in SQL — a closed
// upper bound must expand to first-of-next-month, which the element-wise bound
// conversion cannot express. Fails with the standard EF translation error:
reservations.Where(r => YearMonthRange.CreateUnboundedEnd(r.Day.ToYearMonth())
.Contains(month)); // ⛔ InvalidOperationException
To build a range from a column, use a LocalDateRange built from the date column instead. Building
a daterange in SQL is fully supported there.
What runs where
Most of the API runs as SQL and gives the same answers in the database as in memory. That is the
point of the library, and the live-PostgreSQL suite
checks it. The rest runs in memory. For some of it, composed SQL or a subquery could give the same
answers, and the table says what it would take; Gap and Gaps show that a missing operator alone
doesn't stop a translation. For the rest, SQL would answer differently from memory in cases the
library can't rule out. These two tables are the whole picture.
Runs as SQL. You can use these in Where, OrderBy and Select, on columns and on
parameters. On text, Union, Add, Intersect and Except compose only where order and repeats
don't count; see
Combine sets with Union.
| Surface | Operations | PostgreSQL |
|---|---|---|
| Ranges | Contains, IsContainedBy, Overlaps, IsAdjacentTo |
@>, <@, &&, -\|- |
IsStrictlyLeftOf/RightOf, DoesNotExtendLeftOf/RightOf |
<<, >>, &<, &> |
|
CompareTo against 0, OrderBy |
<, <=, >, >=, =, ORDER BY |
|
Intersect, Union, Except, Merge |
*, +, -, range_merge |
|
Gap |
range_merge over the multirange difference of the hull and both ranges |
|
IsEmpty, IsUnboundedStart/End, IsInfinity, IsFinite |
isempty, lower_inf, upper_inf, and combinations |
|
LowerBound/UpperBound, LowerBoundInclusive/UpperBoundInclusive |
lower, upper, lower_inc, upper_inc |
|
Length on Int32Range and Int64Range |
upper(x) - lower(x) after a widening cast, and 0 for the empty range |
|
Clamp(value) |
LEAST(GREATEST(value, lower(x)), upper(x)), one step back from a discrete type's exclusive upper bound, and NULL for the empty range |
|
CreateFinite/CreateUnboundedStart/CreateUnboundedEnd |
range constructor functions | |
DateRange.Month, Quarter, Year and IsoWeek with an argument read from a column |
daterange(first, last, '[]'), the days from date_trunc or make_date, and an error where memory throws |
|
RangeAgg, RangeIntersectAgg, over ranges or sets |
range_agg, range_intersect_agg |
|
RangeSet |
the same operations over multirange columns, plus Complement and IsContainedBy |
the multirange forms, and '{(,)}' - x |
Gaps(), Gaps(window) |
multirange(range_merge(x)) - x, multirange(window) - x |
|
Count |
(SELECT count(*) FROM unnest(x)), and NULL for a NULL multirange |
|
| Value sets | Contains, Overlaps, IsSubsetOf, IsSupersetOf, and the proper variants |
@>, &&, <@ |
Count, IsEmpty |
cardinality |
|
Union, Add, Intersect, Except, except on text |
ARRAY(SELECT DISTINCT ON … FROM unnest(…) WITH ORDINALITY …), deduplicated and sorted; array_cat or a plain filter where order and duplicates can't change the answer |
|
Union, Add, Intersect and Except on text |
array_cat or a filter of the first set, left unsorted; under Count, count(DISTINCT …), and under == and !=, @> both ways |
|
Remove |
array_remove |
|
| Both families | ==/!= and Equals on a column |
=, <> |
Runs in memory only. These compute the right answer in memory. In a predicate they fail translation, and in the final projection EF runs them in memory instead:
| Operation | Why it runs in memory |
|---|---|
Length on the other range types |
Not translated. A temporal range open to infinity differs: PostgreSQL fails (cannot subtract infinite dates) where memory returns a finite length. On Int32Range and Int64Range it runs as SQL. |
Values() and Values(step) on a discrete range, Split on any range |
Not translated. Each returns a sequence, which in SQL would be generate_series wrapped in ARRAY(SELECT …). A long range produces one element per step. |
ToRangeSet() / ToInt32Set() and the other bridge conversions |
Not translated. An array becomes a multirange through range_agg over one-element ranges, which needs care at the element type's extremes: [2147483647,2147483647] can't be built as an int4range. A multirange becomes an array only by enumerating it, as Values does. |
Shift, Expand on any range |
Not translated. Rebuilding the range from lower(x) + d differs from memory unless each case is handled: the empty range becomes (,), time wraps at midnight where TimeRange throws, and a bound moved past the last date .NET holds throws in memory where PostgreSQL returns a later date. A bound at infinity stays there in both. |
LocalDateRange.Month, Quarter, Year and IsoWeek, and YearMonthRange.Quarter and Year, with an argument read from a column |
Not translated. PostgreSQL has no dates before 4714-11-24 BC, so the month and the year that hold its first day have no start, and the database would fail where memory returns a range. That holds with Npgsql's infinity conversions on or off. Built from values your code passes in, the range is computed in memory and sent as one constant, so comparing a column against a month runs as SQL. On DateRange these run as SQL. |
Clamp(value) on a DateTimeRange, with a DateTime stored as timestamp with time zone |
Not translated. Npgsql stores a DateTime property as timestamp with time zone unless you map it otherwise, and a DateTimeRange as a tsrange. GREATEST and LEAST would compare the two through the session's time zone, while memory compares the clock times. Map the property with HasColumnType("timestamp without time zone") and Clamp runs as SQL. |
The value set indexer, set[0] |
Subscripting reads the stored array, which is canonical only for rows this library wrote (Rows written by other tools). PostgreSQL arrays also start at 1, and an index past the end is NULL where memory throws. |
Values on a value set |
It is the loaded array itself. In a query, use the set's own members, such as Contains and Count. |
The indexer on a RangeSet, set[0] |
Not translated. It would read the ranges unnest(x) returns, but an index past the end is NULL there, where memory throws. |
Compare on RangeLowerBoundComparer, RangeSet.LowerBoundComparer, any other IComparer of ranges or sets, and any other Compare(x, y) over them |
EF Core reads every Compare(x, y) as the natural order of x and y, whatever the comparer. In SQL the lower-bound comparer would compare in the range order. What counts is what you compare, not the type the comparer is declared over: an IComparer<IRange<T>>, a non-generic IComparer and a comparer of value sets run in memory too. So do a Compare method on a class that isn't a comparer and a static Compare. 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. For the range order in SQL, call x.CompareTo(y). |
From and Parse on any range or set |
They build a value in .NET. Given values your code passes in, EF Core runs them before the query and sends the result as a parameter. |
ToInstantRange, ToInterval, ToLocalDateRange, ToDateInterval and ToYearMonthRange (NodaTime package) |
They convert in .NET: between NodaTime's Interval or DateInterval and a range type, or between LocalDateRange and YearMonthRange. |
Good to know:
LengthonInt32RangeandInt64Rangecounts in the database as it does in memory.[1,10]measures 10, the empty range 0, and a range unbounded on either sidenull. AnInt64Rangewhose count is larger thanlong.MaxValuemeasuresnulltoo, so you can filter and sort byLength.Clampclamps in the database as it does in memory, on every range type. A value inside the range comes back as it is, and one outside comes back as the bound it passes, even an exclusive one. The empty range givesnull, and so does anullrange or value.- A value you pass to
Clampcomes back at the column's precision. PostgreSQL keeps a microsecond, soTimeOnly.MaxValuecomes back as23:59:59.999999, as any value sent to the database does. A bound at a date extreme, which the database holds as an infinity, clamps to the extreme as in memory. DateRange.Month,Quarter,YearandIsoWeekover a column build the same range in the database as in memory.DateRange.Month(b.Day)gives the month that holds each row's day, so you can filter, join and sort by it. The first period starts at-infinityand the last ends atinfinity, as Npgsql writesDateOnly.MinValueandMaxValue(the days themselves withNpgsql.DisableDateTimeInfinityConversions), so they still equal a stored range.- An argument out of range fails in the database too. A month outside 1 to 12, a year outside 1
to 9999, a week its week-year doesn't have, and the ISO week that runs past 9999-12-31 throw
ArgumentOutOfRangeExceptionin memory. In a query, PostgreSQL raises an error (PostgresException). For a week, or a year other than 0, the message names the argument. For a month, a quarter or the year 0, it shows the date PostgreSQL was asked to build:Quarter(2026, 5)reports2026-13-01. Counton aRangeSetcounts the same ranges in the database as in memory. PostgreSQL stores a multirange merged and sorted, as aRangeSetholds it, so you can filter and sort by how many separate ranges a row has. Anullmultirange givesnull.- Near the element type's extremes, a multirange the database computes can count one range
more.
(,)minus[int.MinValue,0]is(,-2147483648),[1,)in PostgreSQL, which counts 2. In memory it is[1,), because nointlies belowint.MinValue. A date atDateOnly.MinValueorMaxValueis adjacent to nothing in the database, so a union across it counts 2 there and 1 in memory. A stored multirange counts the same either way (Domain width). - An in-memory operation inside a
Wherefails loudly. EF throws rather than silently fetching the whole table, and that is the intended behaviour. - In the final
Selectit runs in memory. EF Core evaluates the outermost projection on the rows it is already returning. ASelectthat a laterWhereorOrderByreads from has to translate, so it fails there too. Each loaded row pays for the computation. ToString,GetHashCodeandGetEnumeratorrun in memory too. They are for loaded values: the literal, hashing andforeach. The literal is formatted in .NET, soToStringgives the library's literal rather than PostgreSQL's text output.Countover a text set operation counts its distinct elements.array_catrepeats an element both sets hold, so the query counts withcount(DISTINCT …)rather thancardinality. See Combine sets withUnion.
Known differences in 10.2.0
Two queries in 10.2.0 return something other than the same LINQ over the loaded rows, without an
error: a value set Union or Remove over NULL in a projection, and RangeLowerBoundComparer in a
query. 10.3.0 fixes both (Changed answers in 10.3.0).