Ranges and range sets
This page shows you how to create ranges, match on their kind, ask questions about them and
combine them. It also covers RangeSet<TRange, T>, which holds several ranges as one value, like a
PostgreSQL multirange. Everything here runs in memory, with no database involved.
Entity Framework Core shows what the same calls become in SQL, and
What runs where lists exactly which ones run in the database.
Create a range
Every range type has three factory methods and two properties, one for each kind of range:
// Bounded on both sides
Int32Range closed = Int32Range.CreateFinite(1, 10); // [1, 10]
Int32Range half = Int32Range.CreateFinite(1, 10, endInclusive: false); // [1, 10)
// Unbounded on the left — end exclusive by default
DateRange upToToday = DateRange.CreateUnboundedStart(DateOnly.FromDateTime(DateTime.Today)); // (-∞, today)
// Inclusive variant:
DateRange throughToday = DateRange.CreateUnboundedStart(DateOnly.FromDateTime(DateTime.Today), endInclusive: true);
// Unbounded on the right — start inclusive by default
Int32Range fromFive = Int32Range.CreateUnboundedEnd(5); // [5, +∞)
// Unbounded on both ends
Int32Range everything = Int32Range.Infinite; // (-∞, +∞)
// Explicitly empty
Int32Range empty = Int32Range.Empty;
CreateUnboundedStart excludes its end unless you pass endInclusive: true. CreateUnboundedEnd
includes its start by default.
Which bounds CreateFinite includes by default depends on the element type:
| Range type | CreateFinite() default |
|---|---|
Int32Range, Int64Range, DateRange |
[start, end] — closed |
DecimalRange, Float64Range, Float32Range, DateTimeRange, DateTimeOffsetRange |
[start, end) — half-open |
Discrete types, whose values have a next value (integers and dates), include both bounds. Continuous types include the start and exclude the end. That half-open form is the usual convention for money amounts and timestamps.
Good to know:
- An impossible range comes back empty.
CreateFinite()returnsEmptywhen the bounds form an empty or inverted interval. Examples arestart > end, or equal bounds that are both exclusive. Parsing is stricter: an inverted literal such as[5,1)fails, as it does in PostgreSQL (Parse a literal).
Create a month, a quarter, a year or a week
The date ranges have factories for the calendar ranges you would otherwise build by hand:
DateRange.Month(2026, 10); // [2026-10-01,2026-10-31]
DateRange.Quarter(2026, 4); // [2026-10-01,2026-12-31]
DateRange.Year(2026); // [2026-01-01,2026-12-31]
DateRange.IsoWeek(2026, 1); // [2025-12-29,2026-01-04] — Monday to Sunday
var today = new DateOnly(2026, 8, 15);
DateRange.Month(today); // [2026-08-01,2026-08-31] — the month that holds today
DateRange.IsoWeek(today); // [2026-08-10,2026-08-16]
Each factory takes either the numbers of the period or any day inside it. You get a DateRange
like any other, so you can pass it to Overlaps, Except or Gaps.
LocalDateRange in the NodaTime package has the same factories, taking a LocalDate.
YearMonthRange has Quarter and Year: YearMonthRange.Quarter(2026, 4) is [2026-10,2026-12].
Good to know:
- Weeks are always ISO 8601, never your culture's. A week starts on Monday, and week 1 is the week that holds the year's first Thursday. The current culture's first day of the week plays no part, the same way it plays no part in a literal.
- The week-year can differ from the calendar year. January 1, 2027 is in week 53 of 2026, so
DateRange.IsoWeek(new DateOnly(2027, 1, 1))isDateRange.IsoWeek(2026, 53). A week-year has 52 or 53 weeks. - A number out of range throws. A month outside 1 to 12, a quarter outside 1 to 4, or a week
past the last of its week-year throws
ArgumentOutOfRangeException. So does a week that runs past the last date the type can hold, such as week 52 of 9999. - A
LocalDatein another calendar is read as its ISO day.LocalDateRange.Monthreturns the ISO month that holds that day, as every bound of this type is ISO. - In an EF Core query, pass values your code already has.
b.Period.Overlaps(DateRange.Month(2026, 10))runs as SQL with the month as one constant. OnDateRange, a factory that reads a column, such asDateRange.Month(b.Day), runs as SQL too. OnLocalDateRangeandYearMonthRangeit runs in memory only (What runs where).
Pattern matching
Every range is one of five kinds, which this page calls its shape: Finite, UnboundedStart,
UnboundedEnd, Infinity and EmptyRange. Each shape is a nested sealed record, so you can
switch on it:
string Describe(Int32Range range) => range switch
{
Int32Range.EmptyRange => "empty",
Int32Range.Finite f => $"[{f.Start}, {f.End}]",
Int32Range.UnboundedStart s => $"(-∞, {s.End}]",
Int32Range.UnboundedEnd e => $"[{e.Start}, +∞)",
Int32Range.Infinity => "(-∞, +∞)",
_ => throw new UnreachableException(), // System.Diagnostics
};
Every shape that carries a bound also deconstructs into exactly what its constructor takes. So positional patterns and deconstruction work on all of them:
- A discrete type's
Finitegives(start, end), both inclusive. That is its canonical form. - A continuous type's
Finitegives(start, end, startInclusive, endInclusive).
var (first, last) = (DateRange.Finite)period; // pass straight back to DateRange.CreateFinite
if (price is DecimalRange.Finite(var low, var high, var lowInclusive, var highInclusive)) { … }
A continuous type has no two-value form, on purpose. If you passed only the bounds back to
CreateFinite, they would pick up its half-open default, and (1.5,2] would come back as
[1.5,2).
Why the discard arm must throw
The five shapes are the only ranges that can exist. A record derived from a range type compiles,
because C# gives every record a protected copy constructor, but the first new of it throws
(InvalidOperationException). Only the five variants nested in the type can be constructed. Your
five arms are complete in fact, and the discard arm can never run.
C# can't prove that, though. Its exhaustiveness check doesn't reason about a closed class
hierarchy. So a switch expression over the shapes warns CS8509 unless it has a discard arm.
Adding a null arm doesn't help: the warning moves to not null.
Keep the discard arm, and make it throw rather than return a value. The library follows the same rule in its own shape dispatches, for the same reason. A fallback that returns a plausible answer is how four range-algebra bugs stayed hidden (architecture).
Good to know:
- A switch statement needs no discard arm.
if/ischains aren't affected either.
Ask questions about a range
Every query method is an extension method on IRange<T>. Each one works for any combination of
shapes.
If the range you call it on, or the range or set you pass, is null, a query method returns
false. That is what the same query returns for a NULL column in the database. The RangeSet
methods return false for a null range or set you pass, too.
The methods that build a range or a set have no answer for a null range. Union and Except
throw ArgumentException. Intersect, Gap and Merge return a value or throw, depending on the
range you pass, so check for null before you call them.
Check containment
var sprint = DateRange.CreateFinite(new DateOnly(2025, 1, 6), new DateOnly(2025, 1, 17));
sprint.Contains(new DateOnly(2025, 1, 10)); // true — point containment
sprint.Contains(new DateOnly(2025, 1, 20)); // false
var inner = DateRange.CreateFinite(new DateOnly(2025, 1, 8), new DateOnly(2025, 1, 14));
sprint.Contains(inner); // true — range containment
inner.IsContainedBy(sprint); // true — symmetric alias
sprint.Contains(DateRange.Empty); // true — ∅ ⊆ S, for every S
DateRange.Empty.Contains(DateRange.Empty); // true — including itself
DateRange.Empty.Contains(sprint); // false — ∅ contains nothing
Contains takes a value or a range, and IsContainedBy asks the same question the other way
round.
The empty range is contained by every range, including itself. That is the vacuous reading of
"every value of the inner range is also in the outer one", and it matches PostgreSQL's @>.
Good to know:
- Containment and overlap differ for the empty range.
sprint.Overlaps(DateRange.Empty)isfalse, because overlap needs a shared value, and the empty range has none.
Check overlap
var a = Int32Range.CreateFinite(1, 5);
var b = Int32Range.CreateFinite(5, 10);
var c = Int32Range.CreateFinite(6, 10);
a.Overlaps(b); // true — they share the point 5
a.Overlaps(c); // false
Check adjacency
Two ranges are adjacent when they touch with no gap and no overlap. Their union would be a single range.
// Discrete: consecutive integer values are adjacent
var a = Int32Range.CreateFinite(1, 5);
var b = Int32Range.CreateFinite(6, 10);
a.IsAdjacentTo(b); // true — NextValueAfter(5) == 6
// Continuous: touching bounds with complementary inclusiveness
var x = DecimalRange.CreateFinite(1m, 5m, endInclusive: true); // [1, 5]
var y = DecimalRange.CreateFinite(5m, 10m, startInclusive: false); // (5, 10)
x.IsAdjacentTo(y); // true — one side claims 5, the other does not
- For a discrete type, consecutive values touch.
[1, 5]and[6, 10]are adjacent becauseNextValueAfter(5)is6. - For a continuous type, the two bounds must be equal, and exactly one of the two ranges includes that value.
An unbounded range is adjacent on its bounded edge. The relation is symmetric: the shape of the
range you call it on doesn't change the result. This matches PostgreSQL's -|-:
var upTo = Int32Range.CreateUnboundedStart(0, true); // (-∞, 0]
var from = Int32Range.CreateUnboundedEnd(1); // [1, +∞)
var between = Int32Range.CreateFinite(1, 3); // [1, 3]
upTo.IsAdjacentTo(between); // true between.IsAdjacentTo(upTo); // true
upTo.IsAdjacentTo(from); // true — the two halves close the domain with no overlap
// The empty and infinite ranges are adjacent to nothing, and two ranges open at the
// same end always overlap:
Int32Range.Infinite.IsAdjacentTo(between); // false
upTo.IsAdjacentTo(Int32Range.CreateUnboundedStart(9, true)); // false
Good to know:
- The empty and infinite ranges are adjacent to nothing.
- Two ranges open at the same end are never adjacent. They always overlap.
- In the database, a date at the extreme is adjacent to nothing.
DateOnly.MinValueandMaxValuereach PostgreSQL as-infinityandinfinity, so a query answersfalsewhere memory answerstruefor[0001-01-01,0001-01-01]and[0001-01-02,)(EF Core). - Changed in 6.2.1. Before 6.2.1,
IsAdjacentToreturnedfalsewhenever the range you called it on was unbounded. So the relation was not symmetric, and it disagreed with PostgreSQL.RangeSetmerges neighbours after sorting them by lower bound, which always puts an unbounded-start element in the position of the rangeIsAdjacentTois called on. As a result,RangeSet.From([(,0], [1,)])returned{(,0],[1,)}instead of{(,)}. See the changelog.
Measure a range
Length tells you how much a range covers, and the rule depends on the element type. A discrete
range counts its values, including both bounds. A continuous range measures the span between its
bounds.
DateRange.CreateFinite(new DateOnly(2024, 1, 1), new DateOnly(2024, 1, 31)).Length; // 31 (days)
Int32Range.CreateFinite(1, 10).Length; // 10 (integers)
DecimalRange.CreateFinite(1m, 5m).Length; // 4 (span)
DateTimeRange.CreateFinite(nineAm, fiveThirtyPm).Length; // TimeSpan of 8.5 hours
An empty range and an unbounded one give different results, so you can always tell them apart. The empty range contains nothing, and an unbounded one contains too much to measure:
Int32Range.Empty.Length; // 0
Int32Range.Infinite.Length; // null
The type of Length also depends on the element type:
| Range type | Length type |
|---|---|
| The integer ranges | long? |
DateRange |
int?, in days |
| The timestamp ranges | TimeSpan? |
DecimalRange |
decimal? |
Float64Range |
double?, which can be +∞ |
Float32Range |
float?, which can be +∞ |
| The NodaTime ranges | Duration? or Period? |
In the NodaTime package, an instant range measures exact elapsed time (Duration?). A wall-clock
range measures a calendar quantity (Period?).
A Float64Range measures the difference End - Start as a double. For
[double.MinValue, double.MaxValue] that difference is larger than any double, so Length returns
double.PositiveInfinity. The range itself is still finite: the infinity is only the measurement. Float32Range works the same
way with float.
Good to know:
Lengthruns as SQL onInt32RangeandInt64Range. On the other range types it runs in memory (What runs where).
List the values of a discrete range
A discrete range can list the values it contains with Values(). The continuous types don't
declare Values() at all, so the compiler catches the mistake, not a failure at runtime:
foreach (var day in DateRange.CreateFinite(monday, friday).Values())
Schedule(day);
Int32Range.CreateFinite(1, 5).Values(); // 1, 2, 3, 4, 5
DecimalRange.CreateFinite(1m, 5m).Values(); // does not compile — no step to walk
Good to know:
- An unbounded range throws at the call.
Values()throwsNotSupportedExceptionwhen you call it, not at the first iteration. So the error points at the line that was wrong. Values(step)skips ahead.Int32Range.CreateFinite(1, 10).Values(3)gives 1, 4, 7, 10. The step is in the element type's unit: days forDateRange, months forYearMonthRange, aPeriodforLocalDateRange.
Split a range into pieces
Split cuts a range into consecutive pieces of one step each, from its start. The last piece is
clipped to the range's end:
Int32Range.CreateFinite(1, 10).Split(4); // [1,4] [5,8] [9,10]
var hours = TimeRange.CreateFinite(new TimeOnly(9, 0), new TimeOnly(17, 30));
hours.Split(TimeSpan.FromMinutes(30)); // 17 half-hour slots, 09:00–09:30 through 17:00–17:30
Pass a calendar factory instead of a step to cut at calendar boundaries. Each piece is one period, and the first and last are clipped to the range:
var contract = DateRange.CreateFinite(new DateOnly(2026, 1, 15), new DateOnly(2026, 4, 14));
contract.Split(DateRange.Month);
// [2026-01-15,2026-01-31] [2026-02-01,2026-02-28] [2026-03-01,2026-03-31] [2026-04-01,2026-04-14]
DateRange.Quarter(2026, 1).Split(DateRange.IsoWeek); // 14 pieces, Monday to Sunday
The pieces are adjacent and hold exactly the values of the range. So
RangeSet<DateRange, DateOnly>.From(contract.Split(DateRange.Month)) is {contract} again.
Good to know:
- Each range type takes its own step. An
intforInt32Range, adoubleforFloat64Range, afloatforFloat32Range, a number of days forDateRange, aTimeSpanfor the timestamp ranges andTimeRange, aPeriodforLocalDateRangeandLocalDateTimeRange, aDurationforInstantRange, and a number of months forYearMonthRange. - On a continuous type, the pieces are half-open. Each one ends where the next begins. The last
piece keeps the range's own end, so
[0,10]split by 5 is[0,5) [5,10]. - Any period function works.
Splitcalls it with the first day of each piece, and the piece runs to the end of the period it returns. A fiscal year or a billing cycle of your own works the same way. The function must return a bounded range that holds the day it was given, orSplitthrowsArgumentException. - Weeks follow the same ISO 8601 rule as
IsoWeek. They never depend on the current culture. - A period of months counts from the start.
LocalDateRange.Split(Period.FromMonths(1))from January 31 starts its pieces on February 28, March 31 and April 30. - Errors come at the call. An unbounded range throws
NotSupportedException. A step of zero or less throwsArgumentOutOfRangeException. So does a step too small to move the start, such as0.5mneardecimal.MaxValue. - A
Float64RangeorFloat32Rangestep is rounded at each boundary, once. The boundaries areStart + n × step, so the error doesn't build up from piece to piece. Near the top of the domain, wheren × stepalone is too large for the type, a boundary is the one before it plus the step. A step too small to move the start at its magnitude, such as1.0near1e17, throwsArgumentOutOfRangeException. SplitandValues(step)run in memory only. An EF Core query doesn't translate them. In SQL, each would begenerate_seriescollected into an array (What runs where).
Clamp a value into a range
var year = DateRange.CreateFinite(jan1, dec31);
year.Clamp(new DateOnly(2020, 5, 5)); // jan1 — pulled up to the lower bound
year.Clamp(new DateOnly(2024, 6, 15)); // unchanged — already inside
DateRange.Empty.Clamp(anyDate); // null — nothing to snap to
Clamp returns the value itself when the range contains it. Otherwise it returns the bound the
value falls outside of.
Good to know:
- The empty range returns
null. There is no value to snap to. - An unbounded side never constrains. Clamping into
(-∞, 10]only ever pulls a value down. Clampruns as SQL on every range type. The one exception is aDateTimeRangeclamping aDateTimestored with a time zone, which runs in memory (What runs where).
Compare positions
Int32Range.CreateFinite(1, 3).IsStrictlyLeftOf(Int32Range.CreateFinite(5, 9)); // true
Int32Range.CreateFinite(1, 5).IsStrictlyLeftOf(Int32Range.CreateFinite(5, 9)); // false — they share 5
Int32Range.CreateFinite(7, 9).IsStrictlyRightOf(Int32Range.CreateFinite(1, 5)); // true
Two ranges that share a value are not strictly left or right of each other.
Two more methods match PostgreSQL's &< and &>:
// Does not extend to the right of other (&<)
Int32Range.CreateFinite(1, 5).DoesNotExtendRightOf(Int32Range.CreateFinite(1, 10)); // true
// Does not extend to the left of other (&>)
Int32Range.CreateFinite(3, 10).DoesNotExtendLeftOf(Int32Range.CreateFinite(1, 10)); // true
Sort ranges
Every range type implements IComparable<TRange> and IComparable, so Order(), List.Sort() and
SortedSet work with no comparer, also on a List<IRange<T>> or an ArrayList. The order is
PostgreSQL's, the one ORDER BY and < use on a range column:
var ranges = new[]
{
DecimalRange.CreateFinite(1m, 5m, false, false), // (1,5)
DecimalRange.CreateFinite(1m, 5m, true, true), // [1,5]
DecimalRange.CreateUnboundedStart(3m), // (,3)
DecimalRange.CreateFinite(1m, 5m), // [1,5)
DecimalRange.Empty
};
ranges.Order(); // empty, (,3), [1,5), [1,5], (1,5)
The empty range sorts first. Every other range sorts by its lower bound, then by its upper bound. A missing bound is the smallest lower bound and the largest upper bound. At the same value, an inclusive lower bound sorts first and an inclusive upper bound sorts last.
CompareTo returns 0 exactly when the two ranges are equal, so a SortedSet keeps every distinct
range. null sorts before every range. The non-generic CompareTo(object) throws ArgumentException for an
argument that is not the same range type.
Good to know:
- In a query,
CompareToagainst 0 runs as the comparison operator.b.Period.CompareTo(other) < 0runs asb."Period" < @other, andOrderBy(b => b.Period)asORDER BY b."Period". With anullrange on the right, the query returns the rows memory returns:> 0,>= 0and!= 0hold for every row, and< 0,<= 0and== 0for none. The same holds when you compareCompareTowith-1or1, which EF Core also turns into operators.CompareToanswers only-1,0or1, so== 1means "sorts after" in memory as in SQL. A projectedCompareTo, such asSelect(b => b.Period.CompareTo(other)), returns the same number in the query as in memory, also for anulloperand. - A NULL column compares as
Comparer<T>.Defaultorders a null. It is equal to anullrange, and before every other range. So with anulloperand, a NULL column matches== 0,>= 0and<= 0, and with a non-null operand it compares lower, so it matches< 0,<= 0and!= 0. In memory, callingCompareToon a null throws. - PostgreSQL puts
NULLlast in an ascendingORDER BY. In memory,nullsorts first. On a nullable column, the two orders differ only in where the nulls go. An ordering byLowerBound()orUpperBound()meets the same difference on any column, because the bound of an empty or unbounded range is NULL (Read bounds). - This is not the set's lower-bound order.
RangeSet<TRange, T>.LowerBoundComparercompares only the lower bound, so it ties[1,5]with[1,9](Sort ranges the way a set does).
Read the bounds
These methods are PostgreSQL's lower, upper, lower_inc and upper_inc, and they work on any
shape. The shapes only have Start and End properties where that bound exists. The bound methods
give you the same information on every shape, as T?, with null for a missing bound. That is
exactly how PostgreSQL returns NULL.
Int32Range.CreateFinite(1, 10).LowerBound(); // 1
Int32Range.CreateFinite(1, 10).UpperBoundInclusive(); // true
Int32Range.CreateUnboundedStart(5, true).LowerBound(); // null — no lower bound
Int32Range.Empty.UpperBound(); // null
Int32Range.Infinite.LowerBoundInclusive(); // false
// On RangeSet: the first element's lower bound, the last element's upper bound.
var set = RangeSet<Int32Range, int>.From([Int32Range.CreateFinite(1, 3), Int32Range.CreateFinite(7, 9)]);
set.LowerBound(); // 1
set.UpperBound(); // 9
On a RangeSet, LowerBound() is the first element's lower bound, and UpperBound() the last
element's upper bound.
Move or widen a range
Shift moves a range, and Expand grows it on both sides. Both keep the shape and the
inclusiveness of each bound, unless the move leaves nothing between the bounds:
var week = DateRange.CreateFinite(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 7));
week.Shift(7); // [2026-06-08,2026-06-14] — the same week, one week later
week.Shift(-7); // [2026-05-25,2026-05-31]
var meeting = DateTimeRange.CreateFinite(nineAm, tenAm); // [09:00, 10:00)
meeting.Expand(TimeSpan.FromMinutes(15)); // [08:45, 10:15) — a buffer on each side
Int32Range.CreateFinite(1, 10).Expand(-2); // [3, 8] — a negative amount shrinks
Each range type takes the step its element type uses:
| Range type | Shift and Expand take |
|---|---|
Int32Range, Int64Range, DecimalRange |
an int, a long, a decimal |
Float64Range, Float32Range |
a finite double, a finite float |
DateRange |
a number of days |
DateTimeRange, DateTimeOffsetRange, TimeRange |
a TimeSpan |
LocalDateRange, LocalDateTimeRange |
a NodaTime Period |
InstantRange |
a NodaTime Duration |
YearMonthRange |
a number of months |
Good to know:
- An empty or infinite range comes back as it is. It has no bound to move. An unbounded side stays unbounded.
- Shrinking past the width gives you
Empty.Expandwith a negative amount moves the bounds inward, and bounds that cross make the empty range, asCreateFinitedoes. - A bound moved past the last value throws.
ShiftandExpandthrowOverflowExceptionwhen a bound would leave the element type, such as pastint.MaxValueorDateOnly.MaxValue. They never wrap around. - A bound that stands for infinity stays where it is. On the date and time ranges,
DateOnly.MaxValue,DateTime.MaxValueand the other extremes stand for PostgreSQL'sinfinityand-infinity.ShiftandExpandleave such a bound alone, asinfinity - 1is stillinfinityin PostgreSQL. A closed date range counts the day before the maximum too, because a column holding[2026-01-01,infinity)loads as[2026-01-01,9999-12-30]. So that range shifted back a day is[2025-12-31,9999-12-30], and it still ends at infinity. A closed date range's end at the minimum is a date, though, and moves. The integer and float ranges have no infinity, so their extremes still throw. - A finite bound can't move onto infinity.
ShiftandExpandthrowOverflowExceptionwhen they would move a finite bound onto a value that stands for infinity, as they do for a move past it. Saved, the bound would come back as infinity, and shifting back wouldn't restore the range. SoDateRange.CreateFinite(new DateOnly(9999, 12, 1), new DateOnly(9999, 12, 29)).Shift(1)throws, because its end would land on 9999-12-30. - A
Float64RangeorFloat32Rangemoves by IEEE addition. Each moved bound is rounded to the nearestdouble, so[0.1,1).Shift(0.2)starts at0.30000000000000004. A bound pushed pastdouble.MaxValuethrowsOverflowException, as for the other types. ANaNor infinite step throwsArgumentException. - A
TimeRangecan't cross midnight. Moving a bound past the end of the day throwsOverflowException, whereTimeOnly.Addwould wrap to the next morning. - A
DateTimeOffsetRangekeeps its offsets. A day is exactly 24 hours, whatever a time zone does that night. - A period of months follows the calendar.
LocalDateRangeandLocalDateTimeRangemove by NodaTime's calendar arithmetic, which clips to the end of a shorter month. ALocalDateRangemoves the day after its last day, as PostgreSQL stores adaterange, so a whole month moves to a whole month:LocalDateRange.Month(2026, 2).Shift(Period.FromMonths(1))isLocalDateRange.Month(2026, 3), and moving it back gives February again. A start on the 31st still clips: January 31 plus one month is February 28. - A month or year shift of a short range can give you
Empty. The start and the day after the end clip separately, so they can meet.[2024-02-28,2024-02-28]moved by one year starts on 2025-02-28 and would end the day before, so it isEmpty. PostgreSQL gives the same answer for thatdaterange, and so doesLocalDateTimeRangefor[2024-02-28,2024-02-29). It happens only for a range ending on the 28th to the 31st, a few days long at most. A whole month never empties. - They run in memory only. Rebuilding the range in SQL answers differently for the empty range,
for
timepast midnight and for a bound moved past the last date .NET holds. See What runs where.
Combine two ranges
The set operations are extension methods on the range types. They are written against
IRangeFactory<TRange, T>, which every range type in the library implements.
Intersect two ranges
Intersect returns the largest range that is contained by both ranges.
var a = Int32Range.CreateFinite(1, 10);
var b = Int32Range.CreateFinite(5, 15);
Int32Range intersection = a.Intersect(b); // [5, 10]
a.Intersect(Int32Range.CreateFinite(11, 20)); // Empty — no overlap
The intersection of two ranges is always a single range, so Intersect returns the range type
itself. An Empty result really means that the two ranges have no value in common.
Every combination of shapes works, such as Finite ∩ UnboundedStart or
UnboundedEnd ∩ UnboundedStart. Each one returns a result of the right shape.
Union of two ranges
Union returns a RangeSet<TRange, T> that holds every value of both ranges.
var a = Int32Range.CreateFinite(1, 5);
var b = Int32Range.CreateFinite(5, 10);
var c = Int32Range.CreateFinite(7, 10);
var ab = a.Union(b); // { [1, 10] } — overlapping, one element
var ac = a.Union(c); // { [1, 5], [7, 10] } — disjoint, two elements
ab.Count; // 1
ac.Count; // 2
ac[1]; // [7, 10]
- Ranges that overlap or are adjacent give a set with one merged range.
- Ranges with a gap between them give a set with both. The union of two separate ranges really is two ranges, and the result type says so.
Good to know:
- Unbounded ranges merge into unbounded ones. An
UnboundedEndmerged with an overlappingFinitegives anUnboundedEnd. AnUnboundedStartthat overlaps anUnboundedEndcovers the entire domain, and gives{ Infinity }.
Subtract one range from another
Except removes from the range you call it on every value it shares with other. You get a
RangeSet<TRange, T>, and its number of elements tells you what happened.
var range = Int32Range.CreateFinite(1, 10);
var remove = Int32Range.CreateFinite(4, 6);
// [4, 6] is interior to [1, 10] — the result is split in two
var result = range.Except(remove);
// result[0] = [1, 4) ≡ [1, 3]
// result[1] = (6, 10] ≡ [7, 10]
| Result | Meaning |
|---|---|
0 elements |
The range you call it on is fully contained by other; nothing remains |
1 element |
One-sided trim or no overlap; the remaining range |
2 elements |
other was strictly inside the range you call it on; that range is split in two |
At each cut, the bound flips between inclusive and exclusive. So no value is lost, and no value is counted twice across the pieces.
Cover two ranges and the gap between them
Merge returns the smallest single range that contains both ranges. It is PostgreSQL's
range_merge, also known as the convex hull. Unlike Union, the result also covers any gap
between two separate ranges.
var a = Int32Range.CreateFinite(1, 3);
var b = Int32Range.CreateFinite(10, 12);
a.Union(b); // { [1, 3], [10, 12] } — two elements, the gap stays open
a.Merge(b); // [1, 12] — one range, the gap is covered
// Empty operands are ignored; unbounded edges span accordingly:
Int32Range.CreateUnboundedStart(3, true).Merge(Int32Range.CreateUnboundedEnd(10)); // (-∞, +∞)
// RangeSet.Merge() spans the whole set:
RangeSet<Int32Range, int>.From([a, b]).Merge(); // [1, 12]
Merge ignores an empty range, and an unbounded edge stays unbounded in the result. On a
RangeSet, Merge() covers the whole set.
Find the gap between two ranges
var a = Int32Range.CreateFinite(1, 3);
var b = Int32Range.CreateFinite(10, 12);
a.Gap(b); // [4, 9]
b.Gap(a); // [4, 9] — the order doesn't matter
a.Gap(Int32Range.CreateFinite(4, 6)); // Empty — adjacent, nothing lies between them
Gap returns the range that lies between two ranges. It holds every value that Merge covers and
neither range holds. So the two ranges and their gap together make up exactly a.Merge(b).
Good to know:
- No gap gives you
Empty.GapreturnsEmptywhen the ranges overlap or are adjacent, and when either of them is empty. - Each bound of the gap flips at the edge it meets. On a continuous type,
[1,3)and(3,5]leave the single value 3, so their gap is[3,3]. - An unbounded range still has a gap on its bounded side.
(-∞, 3]and[10, +∞)leave[4, 9]. Gapruns in the database. In an EF Core query it becomesrange_mergeover the multirange difference, because PostgreSQL has no gap function. See What runs where.
Aggregate a sequence of ranges
RangeAgg() and RangeIntersectAgg() combine a sequence of ranges. They are the in-memory
versions of PostgreSQL's range_agg and range_intersect_agg:
new[] { Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(3, 8), Int32Range.CreateFinite(20, 25) }
.RangeAgg(); // { [1, 8], [20, 25] } — a normalized RangeSet
new[] { Int32Range.CreateFinite(1, 10), Int32Range.CreateFinite(5, 15) }
.RangeIntersectAgg(); // [5, 10] — the common intersection; null for an empty source
RangeAgg()returns a normalizedRangeSetwith every value of every range.RangeIntersectAgg()returns the intersection of all the ranges, ornullwhen the sequence is empty.- A
nullrange is skipped, as PostgreSQL skips aNULLrow. So a group over a nullable column aggregates the ranges it has, and a sequence of nothing butnullcounts as empty.
Both also take a sequence of sets, as PostgreSQL's aggregates also take multiranges:
IntSet[] blocked =
[
IntSet.From([Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(20, 25)]),
IntSet.From([Int32Range.CreateFinite(3, 22)])
];
blocked.RangeAgg(); // { [1, 25] } — every value of every set
blocked.RangeIntersectAgg(); // { [3, 5], [20, 22] } — the values every set holds
Over sets, RangeIntersectAgg() returns a RangeSet, again null for an empty sequence. An empty
set among the inputs makes it the empty set. A null set is skipped. These two are generic, so they
work for every range type, the NodaTime ones included.
In an EF Core query, all four run as the SQL aggregates inside a GroupBy projection. See
Entity Framework Core.
RangeSet — multirange support
RangeSet<TRange, T> holds several ranges as one value. It is the in-memory version of a
PostgreSQL multirange (int4multirange, nummultirange, …). A set is immutable, and it is
always normalized.
using IntSet = RangeSet<Int32Range, int>;
// Construction normalizes: [1, 5] and [6, 10] are adjacent for int and merge.
var set = IntSet.From([
Int32Range.CreateFinite(6, 10),
Int32Range.CreateFinite(1, 5),
Int32Range.CreateFinite(20, 30)
]);
// { [1, 10], [20, 30] }
// Query operations
set.Contains(7); // true
set.Contains(Int32Range.CreateFinite(2, 8)); // true — within a single element
set.Overlaps(Int32Range.CreateFinite(15, 25)); // true
// Set operations — single-range and bulk variants, with operator aliases (|, &, -)
set.Union(Int32Range.CreateFinite(11, 19)); // { [1, 30] } — bridges the gap
set | Int32Range.CreateFinite(11, 19); // { [1, 30] }
set.Intersect(Int32Range.CreateFinite(5, 25)); // { [5, 10], [20, 25] }
set & Int32Range.CreateFinite(5, 25); // { [5, 10], [20, 25] }
set.Except(Int32Range.CreateFinite(4, 6)); // { [1, 3], [7, 10], [20, 30] }
set - Int32Range.CreateFinite(4, 6); // { [1, 3], [7, 10], [20, 30] }
// Complement — every value not covered by the set
set.Complement(); // { (-∞, 0], [11, 19], [31, +∞) }
// State checks — isempty / lower_inf / upper_inf equivalents, plus the two derived shapes
set.IsEmpty(); // false
set.IsUnboundedStart(); // false
set.IsUnboundedEnd(); // false
set.IsFinite(); // true — non-empty and bounded at both ends
set.IsInfinity(); // false — only the set covering the whole domain answers true
// Set-operand comparisons — with a range on the left too, see below
set.Contains(IntSet.From([Int32Range.CreateFinite(2, 8)])); // true (@>)
set.Overlaps(IntSet.From([Int32Range.CreateFinite(25, 40)])); // true (&&)
set.IsStrictlyLeftOf(Int32Range.CreateFinite(40, 50)); // true (<<)
set.DoesNotExtendRightOf(Int32Range.CreateFinite(1, 30)); // true (&<)
set.IsAdjacentTo(Int32Range.CreateFinite(31, 40)); // true (-|-)
Every way you create a set keeps three rules. The elements are sorted by lower bound, no two of them overlap, and no two are adjacent. To keep those rules, creating a set:
- Drops empty ranges.
- Merges ranges that overlap or are adjacent.
- Collapses the set to
RangeSet<TRange, T>.Infinitewhen any input isInfinity.
A null range is not an empty range, so it isn't dropped. Creating a set from one throws
ArgumentException, wherever it stands among the inputs.
What the example shows:
- Union, intersection and difference each take a single range or a whole set. They also have
the operators
|,&and-. Complement()returns every value the set does not cover.- The state checks match PostgreSQL's
isempty,lower_infandupper_inf.IsFinite()andIsInfinity()are the two derived shapes. - The comparisons also take a whole set. With the range on either side, you get every range and multirange operator PostgreSQL has (Compare a range with a set).
Adjacency counts only at the outer edges
A set's adjacency matches PostgreSQL exactly. It is directional, through the set's outer edges. The range you pass must end exactly where the set's first element begins, or begin exactly where the set's last element ends. Touching any boundary inside the set doesn't count, not even the inner side of the first or last element (verified against live PostgreSQL):
var three = IntSet.From([
Int32Range.CreateFinite(1, 3), Int32Range.CreateFinite(7, 9), Int32Range.CreateFinite(20, 22)
]);
three.IsAdjacentTo(Int32Range.CreateFinite(23, 25)); // true — attaches after the last element
three.IsAdjacentTo(Int32Range.CreateFinite(4, 6)); // false — inner side of the first element
three.IsAdjacentTo(Int32Range.CreateFinite(10, 12)); // false — touches only the interior [7, 9]
The positional operators (<<, >>, &<, &>) work the same way. They compare the bounds of the
first and last element.
A set open at both ends can still have a gap
IsInfinity() is not the same as IsUnboundedStart() and IsUnboundedEnd() both being true. For
a single range it would be. A range has no gaps, so a range unbounded on both sides covers the whole
domain. A set can be open at both ends and still have a hole:
var gapped = IntSet.From([
Int32Range.CreateUnboundedStart(5, true), // (-∞, 5]
Int32Range.CreateUnboundedEnd(10) // [10, +∞)
]);
gapped.IsUnboundedStart(); // true
gapped.IsUnboundedEnd(); // true
gapped.Contains(7); // false — the gap
gapped.IsInfinity(); // false
IntSet.Infinite.IsInfinity(); // true — the only set that covers everything
Creating a set collapses any Infinity input to the one-element Infinite set. So that set is the
only way to represent full coverage. That is what makes the check exact, both in memory and in SQL.
Compare a range with a set
You can call the comparisons on a range and pass a set. Each one is the PostgreSQL operator with the range on the left:
var blocked = IntSet.From([Int32Range.CreateFinite(3, 4), Int32Range.CreateFinite(8, 9)]);
Int32Range.CreateFinite(1, 10).Contains(blocked); // true (@>)
Int32Range.CreateFinite(8, 9).IsContainedBy(blocked); // true (<@)
Int32Range.CreateFinite(5, 7).Overlaps(blocked); // false (&&) — 5 to 7 is a gap
Int32Range.CreateFinite(1, 2).IsStrictlyLeftOf(blocked); // true (<<)
Int32Range.CreateFinite(5, 7).DoesNotExtendRightOf(blocked); // true (&<) — 7 ≤ 9
Int32Range.CreateFinite(5, 7).DoesNotExtendLeftOf(blocked); // true (&>) — 5 ≥ 3
Int32Range.CreateFinite(10, 12).IsAdjacentTo(blocked); // true (-|-)
blocked.IsContainedBy(Int32Range.CreateFinite(3, 9)); // true (<@)
blocked.IsContainedBy(blocked | Int32Range.CreateFinite(20, 30)); // true (<@)
A range compares with a set through the set's outer elements. IsStrictlyLeftOf and
DoesNotExtendLeftOf read the first element, and IsStrictlyRightOf and DoesNotExtendRightOf
read the last. Contains is true when the range holds the first and the last element, and so
everything between them.
Good to know:
&<and&>are not the set's methods turned around.range.DoesNotExtendRightOf(set)compares upper bounds, whileset.DoesNotExtendLeftOf(range)compares lower bounds. So[4,5) &< {[3,9)}istrueand{[3,9)} &> [4,5)isfalse.- An empty set is contained by every range, the empty range included. With an empty set on
either side,
IsStrictlyLeftOf,IsStrictlyRightOf,DoesNotExtendLeftOf,DoesNotExtendRightOf,OverlapsandIsAdjacentToreturnfalse.
Find the free slots in a set
var booked = IntSet.From([
Int32Range.CreateFinite(1, 10), Int32Range.CreateFinite(20, 30), Int32Range.CreateFinite(35, 40)
]);
booked.Gaps(); // { [11, 19], [31, 34] }
booked.Complement(); // { (-∞, 0], [11, 19], [31, 34], [41, +∞) }
booked.Gaps(Int32Range.CreateFinite(5, 50)); // { [11, 19], [31, 34], [41, 50] }
Gaps() returns every value between the set's first and last element that the set doesn't cover.
Unlike Complement(), it leaves out what lies before the first element and after the last.
Gaps(window) returns the parts of window that the set doesn't cover, such as the free days of a
month. It gives the same values as Complement().Intersect(window).
Good to know:
- A set with fewer than two elements has no gaps.
Gaps()returns theEmptyset for it. - An unbounded window keeps its open side.
booked.Gaps(Int32Range.CreateUnboundedEnd(5))ends with[41, +∞). - Both run in the database.
Gaps()becomesdatemultirange(range_merge(x)) - x, andGaps(window)becomesdatemultirange(window) - x.
Build a set with a collection expression
RangeSet<Int32Range, int> set = [
Int32Range.CreateFinite(10, 12),
Int32Range.CreateFinite(1, 3),
Int32Range.CreateFinite(2, 5)
];
// { [1, 5], [10, 12] } — sorted and merged, not wrapped as written
RangeSet<Int32Range, int> none = []; // the Empty singleton
RangeSet<Int32Range, int> all = [Int32Range.Infinite, someRange]; // the Infinite singleton
A collection expression normalizes the ranges exactly as From does. An empty one gives you the
Empty singleton, and one that contains Infinite gives you the Infinite singleton.
Good to know:
- The builder is
RangeSet.Create<TRange, T>. It lives on the non-genericRangeSet, because a[CollectionBuilder]target can't itself be generic. - Prefer the collection expression to calling
Createyourself. C# doesn't infer type arguments from constraints. It can't deduceTfromTRange, so a direct call has to name both. Fromhas two overloads. There isFrom(params ReadOnlySpan<TRange>)besideFrom(IEnumerable<TRange>).
Read and compare sets
A set implements IReadOnlyList<TRange>. You can enumerate it in lower-bound order, and use
Count and the indexer. In an EF Core query, Count runs as SQL and the indexer runs in memory
(What runs where).
Sets also have structural equality, including == and !=. Two sets built from different ranges
are equal when they normalize to the same ranges:
var a = IntSet.From([Int32Range.CreateFinite(1, 10)]);
var b = IntSet.From([Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(6, 10)]);
a.Equals(b); // true — both normalize to { [1, 10] }
a == b; // true — same value semantics as the ranges themselves
Sets implement IComparable<RangeSet<TRange, T>> and IComparable in PostgreSQL's multirange order. Two sets compare
element by element in the range order, and a set that runs out of elements first
sorts first. So the empty set sorts before every other set, and {[1,3]} before {[1,3],[5,7]}.
As for ranges, CompareTo returns 0 exactly when the two sets are equal.
Sort ranges the way a set does
RangeSet<TRange, T>.LowerBoundComparer sorts ranges by lower bound, in the same order the set uses
inside. It is an IComparer<TRange> singleton. Use it to sort any List<TRange>, for example to
sort your ranges before you pass them to From.
var unsorted = new List<Int32Range>
{
Int32Range.CreateFinite(20, 30),
Int32Range.CreateFinite(1, 5),
Int32Range.CreateUnboundedStart(10, true)
};
unsorted.Sort(RangeSet<Int32Range, int>.LowerBoundComparer);
// { (-∞, 10], [1, 5], [20, 30] }
The order follows two rules:
- An
IUnboundedStartRange<T>sorts first. Its lower bound is -∞. - At the same finite value, an inclusive lower bound sorts first.
[5, …comes before(5, ….
The same instance is also available as RangeLowerBoundComparer<Int32Range, int>.Instance. Use it
where you only have the comparer type, not the set type.
The comparer sorts in memory. Inside an EF Core query it runs in memory only, so a filter or an ordering that calls it fails translation; see What runs where.
Write generic code over any range
The library has a set of interfaces for writing generic code:
| Interface | Purpose |
|---|---|
IRange<T> |
Base marker for all range types |
IFiniteRange<T> |
Start, End, and their inclusiveness flags |
IUnboundedStartRange<T> |
End and EndInclusive |
IUnboundedEndRange<T> |
Start and StartInclusive |
IEmptyRange<T> |
Marker for the empty range; no bound properties |
IInfinityRange<T> |
Marker for the range covering the entire domain |
IRangeFactory<TRange, T> |
Abstract static factories; also NextValueAfter/PreviousValueBefore for step-aware (discrete) types |
In all of these interfaces, T is constrained to struct, IComparable<T>, IEquatable<T>.
IRangeFactory<TRange, T> and IValueSetFactory<TSet, T> both extend ISpanParsable<TSelf>,
and so IParsable<TSelf>, and IFormattable. That lets generic code parse and format any range
or set without knowing its concrete type:
static T Load<T>(ReadOnlySpan<char> literal) where T : ISpanParsable<T>
=> T.Parse(literal, CultureInfo.InvariantCulture);
IRangeFactory<TRange, T> also extends IComparable<TRange>, so generic code can sort any range
type in the range order.
For the lower-bound order the set uses inside, use RangeLowerBoundComparer<TRange, T>, an
IComparer<TRange> singleton. See
Sort ranges the way a set does.