NodaTime
dotnet add package Janzen.Pagination.NodaTimebuilder.Services.AddPagination(pagination => pagination.UseNodaTime());
// Non-DI hosts:
PaginateNodaTime.Register();Registration is idempotent and process-wide. It adds three things:
- Filter parsing for the types in the table below, so they work in
.Filterable(...):httpAn unparseable value is a?filter.publishedAt=$btw:2026-01-01T00:00:00Z,2026-01-31T23:59:59Z ?filter.birthDate=$lte:2008-01-31400 Value 'x' is not a valid instant. - Leaf-type classification for all of them, so automatic projection copies them straight across instead of trying to recurse into them as nested DTOs.
- Projection conversions onto the BCL type a DTO holds — see the table.
The package registers all three through the same public registry any application can use — see Custom types.
Supported types
Every format is ISO-8601 in the invariant culture, and wherever a format carries a time, its seconds component is mandatory: 23:59 is a 400, 23:59:59 is not. A fraction after the seconds is optional (23:59:59.25). This is stricter than the BCL siblings — a TimeOnly field accepts 23:59 — so the spelling each type refuses is named in its row.
| Type | Accepted format | Projects onto |
|---|---|---|
Instant | 2026-01-31T23:59:59Z, or an offset form such as 2026-02-01T00:59:59+01:00. Refuses 2026-01-31T23:59Z and a lowercase z. | DateTimeOffset |
LocalDate | 2026-01-31 | DateOnly |
LocalDateTime | 2026-01-31T23:59:59. Refuses 2026-01-31T23:59. | DateTime (unspecified kind) |
LocalTime | 23:59:59. Refuses 23:59. | TimeOnly |
OffsetDateTime | 2026-01-31T23:59:59+01:00. Refuses 2026-01-31T23:59+01:00, and an offset written without its colon (+0100). | DateTimeOffset |
YearMonth | 2026-01 | — (no BCL counterpart) |
Duration | 2:30:00 or ISO-8601 PT2H30M. The colon form refuses 2:30; the ISO form needs no seconds. P1M / P1Y are a 400: a month has no fixed length, and 30 days is an approximation, not an answer. | TimeSpan |
Every conversion also carries the nullable pair (Instant? → DateTimeOffset?). There are no reverse conversions: entities hold NodaTime, DTOs hold BCL types.
The two Duration spellings do not share a resolution. The colon form is parsed natively and resolves to a nanosecond; the ISO form is read as a TimeSpan and therefore resolves to 100 ns, so $eq:PT0.000000001S filters for Duration.Zero. It only matters on a store that keeps better than 100 ns — PostgreSQL interval, what Npgsql maps Duration to, stores microseconds.
Instant accepts an offset form because 2026-02-01T00:59:59+01:00 names exactly one instant — there is nothing ambiguous to refuse. A bare date is still a 400: it would silently mean midnight, and which midnight is a question the caller has not answered.
Not in the table, on purpose
ZonedDateTime has no canonical text form without first deciding on a zone provider — store an Instant and present it zoned. Period is calendar arithmetic rather than a comparable value, and Interval is two-valued, which $btw over Instant already expresses.
Why Instant → DateTimeOffset is free
Both map to PostgreSQL timestamptz: the stored value is the same UTC instant, so ToDateTimeOffset() has no SQL form — there is nothing for the database to compute. EF Core applies it in the shaper, client-side, over the page rows only.
That is why it composes freely inside a selector, sub-collections included, and still executes as one query with a narrow SELECT. It is not a fallback to client evaluation of the whole query, and it is not a reason to reach for PaginateMapAsync. See Projections → sub-collections and NodaTime.
A nullable Instant? can only be projected onto a nullable DateTimeOffset?; targeting a non-nullable one fails with the engine's usual projection error rather than silently substituting a default.
End to end
An entity storing Instant, a DTO exposing DateTimeOffset, and one config in between:
public sealed class Article {
public Guid Id { get; set; }
public string Title { get; set; } = "";
public Instant PublishedAt { get; set; }
public LocalDate? EmbargoUntil { get; set; }
}
// The DTO speaks DateTimeOffset, so JSON clients need no NodaTime serializer.
public sealed record ArticleDto(Guid Id, string Title, DateTimeOffset PublishedAt);
var config = PaginateConfig<Article>.Create(b => b
.WithLimits(25, 100)
.Sortable("publishedAt", a => a.PublishedAt)
.DefaultSortBy("publishedAt", PaginateSortDirection.Desc)
.WithTieBreaker(a => a.Id)
.Filterable("publishedAt", a => a.PublishedAt,
PaginateFilterOperator.GreaterThanOrEqual,
PaginateFilterOperator.LessThanOrEqual,
PaginateFilterOperator.Between)
.Filterable("embargoUntil", a => a.EmbargoUntil,
PaginateFilterOperator.Null, PaginateFilterOperator.LessThanOrEqual));GET /articles?filter.publishedAt=$btw:2026-01-01T00:00:00Z,2026-01-31T23:59:59Z&filter.embargoUntil=$nullBoth filter values are parsed by the registered NodaTime parsers, compared as Instant and LocalDate in SQL, and the page comes back with publishedAt already converted:
{ "id": "7f3c…", "title": "Widget shipped", "publishedAt": "2026-01-14T09:15:00+00:00" }Sorting works on Instant directly — it is the column, so nothing is converted before the ORDER BY. The conversion happens after paging, on the page's rows only.
In OpenAPI each of the seven types documents itself with its own value-type name and an example its own parser accepts. That mapping has one home — the types and examples table — and a copy of it here would only drift away from it.
Keep NodaTime out of the DTO
Filter parameters are documented by this library, but the response schema is generated by ASP.NET Core's own OpenAPI pipeline, which knows nothing about NodaTime. A DTO exposing an Instant directly is therefore described structurally — an object with the type's members — rather than as an ISO-8601 string, and clients generated from that document come out wrong.
Projecting to DateTimeOffset in the DTO, as above, avoids it: the conversion is free (see below), the JSON is an ISO-8601 string either way, and the generated schema is correct without a schema transformer of your own. Reach for a custom transformer only if the type genuinely has to survive into the DTO.