Query Operators and DynamoDB Behavior
This document is a living reference for how LINQ operators behave in this provider. Each operator section includes how it translates today and the DynamoDB or provider-specific limitations you should keep in mind. Add to these sections as support expands.
At a glance
Supported today
WhereSelectOrderBy/OrderByDescendingThenBy/ThenByDescendingContains(supported shapes only)StartsWithLimit(n)(evaluation budget)First/FirstOrDefault(key-only safe path)WithIndex/WithoutIndex
Predicate operators
!(logical NOT) translates to PartiQLNOT (expr)== null/!= nulltranslates toIS NULL OR IS MISSING/IS NOT NULL AND IS NOT MISSINGEF.Functions.IsNull(prop),IsNotNull,IsMissing,IsNotMissingfor explicit per-predicate controlprop >= a && prop <= b(inclusive range) translates toprop BETWEEN a AND b
Not supported today
Take— use.Limit(n)for evaluation budgetAny/AllSingle/SingleOrDefaultCount,LongCount,Sum,Average,Min,MaxSkipGroupByJoin/GroupJoin/SelectMany/LeftJoin/RightJoinUnion/Concat/Except/IntersectDistinct,Reverse,Last/LastOrDefault- Method calls in predicates except supported
ContainsandStartsWithpatterns
Server-side only policy
- This provider only supports query shapes that can be translated to DynamoDB PartiQL.
- Unsupported operators fail translation with a detailed
InvalidOperationExceptionmessage. - The provider does not silently switch unsupported LINQ operators to client-side evaluation.
- Some projection shaping in
Selectmay still execute client-side after server-side query results are returned.
Operator matrix (current contract)
| Operator | Server translation | Client behavior | Notes |
|---|---|---|---|
Where |
PartiQL WHERE |
N/A | Boolean members normalize to = TRUE |
! (logical NOT) |
PartiQL NOT (expr) |
N/A | Operand is always parenthesised |
== null (constant) |
IS NULL OR IS MISSING |
N/A | Covers both DynamoDB null representations |
!= null (constant) |
IS NOT NULL AND IS NOT MISSING |
N/A | De Morgan inverse of == null |
EF.Functions.IsNull(prop) |
IS NULL |
N/A | Explicit: NULL type only |
EF.Functions.IsNotNull(prop) |
IS NOT NULL |
N/A | Explicit: not NULL type |
EF.Functions.IsMissing(prop) |
IS MISSING |
N/A | Explicit: absent attribute |
EF.Functions.IsNotMissing(prop) |
IS NOT MISSING |
N/A | Explicit: attribute present |
prop >= a && prop <= b |
PartiQL prop BETWEEN a AND b |
N/A | Both bounds inclusive; mixed bounds (> + <=) fall back to two comparisons |
Contains |
PartiQL contains(...) or IN [ ... ] |
N/A | Only string.Contains(string) and in-memory collection membership are supported |
string.StartsWith(string) |
begins_with(attr, <prefix>) |
N/A | Captured values are parameterized; inline literals may be inlined; other overloads are not translated |
Select |
Explicit projection list | Some computed projections can run client-side | No SELECT * |
Nested owned property path (x.Profile.Address.City) |
PartiQL dot-notation "Profile"."Address"."City" |
N/A | Supported in Where predicates only; not supported in Select projections |
List index access (x.Tags[0]) |
PartiQL bracket-notation "Tags"[0] |
N/A | Supported in Where predicates only; index must be a compile-time constant |
OrderBy / ThenBy |
PartiQL ORDER BY |
N/A | Precedence and parentheses preserved |
Limit(n) |
Sets ExecuteStatementRequest.Limit = n |
Single request, 0..n results | Last call wins; must be positive |
First* (key-only, no explicit limit) |
Sets implicit Limit=1; single request |
N/A | Safe path only; unsafe paths throw — use AsAsyncEnumerable() |
First* + Limit(n) |
Translation failure | — | Use .AsAsyncEnumerable().FirstOrDefaultAsync(ct) (optional Limit(n) only if you want a budget) |
First* on non-key/scan-like path |
Translation failure | — | Use .AsAsyncEnumerable().FirstOrDefaultAsync(ct) (add Limit(n) only for bounded sampling) |
AsAsyncEnumerable() + First* |
Optional Limit(n) server-side, then client-side |
Takes first from result set | Standard EF Core explicit client-side evaluation |
WithIndex(name) |
Sets query source to "Table"."Index" |
N/A | Name must resolve to an index on the queried entity type or its base types |
WithoutIndex() |
Suppresses index selection | N/A | Forces base-table execution and logs DYNAMO_IDX006; cannot be combined with WithIndex(...) |
General paging model
DynamoDB ExecuteStatementRequest.Limit controls evaluation (how many items are read), not the
number of matching rows returned. A page can return zero matches and still include NextToken
when more items could match.
Limit(n)+ToListAsync(): single request, evaluates at mostnitems. No paging.Limit(n)+First*: translation failure — use.AsAsyncEnumerable().FirstOrDefaultAsync(ct).First*key-only (no explicit limit): single request, implicitLimit=1.First*on non-key/scan-like path: translation failure — use.AsAsyncEnumerable().FirstOrDefaultAsync(ct).ToListAsync()(no limit): multi-page,Limit=nullper request. Provider followsNextToken.
The provider never emits SQL LIMIT; the limit is a request-level field on ExecuteStatementRequest.
DynamoDB PartiQL context (background)
- PartiQL
SELECTcan behave like a scan unless theWHEREclause uses partition-key equality or partition-keyIN. - DynamoDB PartiQL supports operators such as
BETWEEN(inclusive) andIN. The provider translatesBETWEENfrom a matching>=+<=LINQ pattern on the same property. - DynamoDB documents
INlimits as up to 50 hash-key values or up to 100 non-key values. - Query predicates do not imply GSI/LSI targeting. The current provider analyzes and executes queries against the modeled table key unless and until explicit index-aware execution support is added.
- Access-pattern guidance in this document therefore applies to the modeled DynamoDB table
partition/sort key, not to EF
HasKey(...)or to un-targeted secondary indexes.
Identifier quoting notes
- This provider always quotes identifiers in generated PartiQL.
- Quoted identifiers in PartiQL are case-sensitive.
Shared-table discriminator behavior
- For shared-table mappings (multiple concrete entity types in one table group), root
DbSet<T>queries include discriminator filtering. - The discriminator filter is injected at query-root creation and composes with user predicates
using
AND. - For inheritance hierarchies, querying a base type includes all concrete discriminator values in that hierarchy.
- For the currently supported operator surface, discriminator filtering therefore applies to:
Where,Select,OrderBy/ThenBy,Limit(n), andFirst/FirstOrDefault. First*on derived/shared-table queries requires a single-item base-table lookup before discriminator filtering (PK-only on PK-only table, or PK+SK equality on PK+SK table). Derived PK-only queries on PK+SK tables throw translation failure; use.AsAsyncEnumerable().FirstOrDefaultAsync(ct).- Unsupported operators (joins, groupings, set operations, skip, aggregates, single-result operators) are outside the discriminator coverage contract.
Conceptual example for DbSet<UserEntity> against a shared table:
Conceptual example for DbSet<PersonEntity> (base type) in a hierarchy:
SELECT Pk, Sk, Name, Department, Level, "$type"
FROM "app-table"
WHERE Pk = 'TENANT#H' AND ("$type" = 'EmployeeEntity' OR "$type" = 'ManagerEntity')
Where
Purpose
- Filter results by predicate.
Translation
- Translated to PartiQL
WHERE. - Boolean members are normalized to explicit comparisons (e.g.,
IsActivebecomesIsActive = TRUE).
Limitations / DynamoDB quirks
- Filters may return zero matches on a page even when more matches exist on later pages.
IS NULL / IS MISSING
Purpose
- Test whether a nullable attribute is absent or holds no value.
Translation DynamoDB has two representations of "no value" for an attribute:
| State | DynamoDB storage | PartiQL predicate |
|---|---|---|
| NULL type | Attribute present with { NULL: true } value |
attr IS NULL |
| MISSING | Attribute key absent from the item entirely | attr IS MISSING |
EF Core users should not need to distinguish which representation was used. The provider
therefore maps == null to cover both:
| LINQ expression | PartiQL emitted |
|---|---|
x.Prop == null |
"Prop" IS NULL OR "Prop" IS MISSING |
x.Prop != null |
"Prop" IS NOT NULL AND "Prop" IS NOT MISSING |
EF.Functions.IsNull(x.Prop) |
"Prop" IS NULL |
EF.Functions.IsNotNull(x.Prop) |
"Prop" IS NOT NULL |
EF.Functions.IsMissing(x.Prop) |
"Prop" IS MISSING |
EF.Functions.IsNotMissing(x.Prop) |
"Prop" IS NOT MISSING |
The EF.Functions methods are for advanced use cases where the NULL vs MISSING distinction
matters (e.g., schema migration, interop with non-EF-written items).
When == null is composed with AND, the OR sub-expression is automatically parenthesised:
Limitations / DynamoDB quirks
- Parameterized null (
x.Prop == someVarwheresomeVaris null at runtime) uses= ?withAttributeValue { NULL = true }, which only matches NULL type — not MISSING. This is a DynamoDB engine limitation (ISdoes not accept parameters). See Limitations. - Two-column nullable comparisons (
x.A == x.Bwhere both are nullable) are not supported and will not produce correct results. See Limitations.
Not
Purpose
- Negate a boolean predicate.
Translation
!exprtranslates to PartiQLNOT (expr).- The operand is always wrapped in parentheses:
NOT ("IsActive" = TRUE). - Compound operands are parenthesised correctly:
NOT ("IsActive" = TRUE AND "Score" > 0).
Limitations / DynamoDB quirks
- Only logical negation of boolean search conditions is supported; bitwise complement is not translated.
StartsWith
Purpose
- Test whether a string attribute begins with a given prefix.
Translation
entity.Name.StartsWith("prefix")translates tobegins_with("Name", ?)when the prefix is captured/parameterized.- Inline constants can be emitted directly as literals, for example
begins_with("Name", 'prefix').
// Captured variable
var prefix = "ORDER#";
db.Items.Where(x => x.Sk.StartsWith(prefix));
// WHERE begins_with("Sk", ?)
// Inline literal
db.Items.Where(x => x.Sk.StartsWith("ORDER#"));
// WHERE begins_with("Sk", 'ORDER#')
Limitations / DynamoDB quirks
- Only
string.StartsWith(string)is supported;char,StringComparison, and culture/ignore-case overloads are not translated and will throw. begins_withperforms a case-sensitive, literal prefix check — no wildcards, no escaping required.- DynamoDB
begins_withonly accepts string attributes; numeric or binary attributes will not match. - See DynamoDB begins_with documentation.
Contains
Purpose
- Check string substring membership or in-memory collection membership.
Translation
entity.Name.Contains("Ada")translates tocontains(Name, ?).ids.Contains(entity.Id)translates toId IN [?, ?, ...].- Collection membership placeholders are expanded at runtime based on collection size.
Limitations / DynamoDB quirks
- Only
string.Contains(string)is supported; overloads such ascharandStringComparisonare not translated. - Only in-memory collection membership is supported (for example
ids.Contains(entity.Id)). - Collection attribute containment (for example
entity.Tags.Contains("x")) is not supported. - Empty collections translate to a constant-false predicate (
1 = 0). - DynamoDB
INlimits apply: up to 50 partition-key values or up to 100 non-key values. NULLcollection elements are passed through as DynamoDBNULL; DynamoDB evaluation semantics apply.
BETWEEN
Purpose
- Test whether a property value falls within an inclusive range.
Translation
- A LINQ predicate of the form
x.Prop >= low && x.Prop <= highis rewritten to PartiQL"Prop" BETWEEN ? AND ?. - Both bounds must be inclusive (
>=and<=). If either bound is exclusive (>or<), the predicate is kept as two separate comparisons.
// Inclusive range → BETWEEN
db.Orders.Where(o => o.Total >= 10m && o.Total <= 100m)
// SELECT ... FROM "Orders" WHERE "Total" BETWEEN ? AND ?
// Mixed bounds → two comparisons (no BETWEEN rewrite)
db.Orders.Where(o => o.Total > 10m && o.Total <= 100m)
// SELECT ... FROM "Orders" WHERE "Total" > ? AND "Total" <= ?
Sort key range queries
DynamoDB allows only a single condition on the sort key per query. BETWEEN satisfies this as
one inclusive range condition. Using >= and <= without the BETWEEN rewrite would produce two
separate sort-key comparisons, which DynamoDB cannot use as a key condition and falls back to a
scan. To ensure the BETWEEN rewrite fires for sort key ranges:
- Use
>=for the lower bound and<=for the upper bound on the same property. - Write the lower bound first:
sk >= low && sk <= high(either left/right ordering of the conditions is accepted, but the bound values are not reordered).
var from = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var to = new DateTimeOffset(2026, 1, 31, 23, 59, 59, TimeSpan.Zero);
// ✅ Triggers BETWEEN — property on the left of each comparison, both bounds inclusive
db.Orders
.Where(o => o.Pk == "CUSTOMER#42" && o.CreatedAt >= from && o.CreatedAt <= to)
.ToList();
// WHERE "Pk" = ? AND "CreatedAt" BETWEEN ? AND ?
// ❌ Does NOT trigger BETWEEN — property must be on the LEFT of each comparison operator.
// Swapping to "from <= o.CreatedAt" places the property on the right, so the rewrite
// does not fire and two separate sort-key comparisons are emitted instead.
db.Orders
.Where(o => o.Pk == "CUSTOMER#42" && from <= o.CreatedAt && to >= o.CreatedAt)
.ToList();
// WHERE "Pk" = ? AND ? <= "CreatedAt" AND ? >= "CreatedAt"
// (two sort-key conditions → DynamoDB falls back to a scan)
// ❌ Does NOT trigger BETWEEN — exclusive lower bound, emits two comparisons
db.Orders
.Where(o => o.Pk == "CUSTOMER#42" && o.CreatedAt > from && o.CreatedAt <= to)
.ToList();
// WHERE "Pk" = ? AND "CreatedAt" > ? AND "CreatedAt" <= ?
// (two sort-key conditions → DynamoDB falls back to a scan)
Bound ordering is not validated
The provider does not reorder or normalize the bound values. If the bounds are logically
inverted — for example x.Score >= 500 && x.Score <= 100 — the predicate is still rewritten
to "Score" BETWEEN 500 AND 100, which DynamoDB evaluates as an empty range and returns no
results. Ensure low <= high at the call site.
Limitations / DynamoDB quirks
- Only single-property inclusive ranges trigger the rewrite. Multi-column range expressions are emitted as individual comparisons.
- DynamoDB BETWEEN is inclusive on both ends; see PartiQL operators.
Select
Purpose
- Shape the projection.
Translation
- Translated to explicit
SELECT <projection>; noSELECT *is emitted.
Limitations / DynamoDB quirks
- Projection is explicit to keep attribute reads predictable and aligned with type mapping.
OrderBy / ThenBy
Purpose
- Order results by one or more keys.
Translation
- Translated to PartiQL
ORDER BYwithASC/DESC. - Both
OrderBy/ThenBy(ascending) andOrderByDescending/ThenByDescending(descending) are supported in any combination per key.
Valid ordering attributes
- Only partition key and sort key attributes are valid ordering columns. Non-key attributes are rejected at query compilation with a provider error.
Single-partition queries (WHERE PK = value):
OrderBy(e => e.Pk)— order by partition keyOrderBy(e => e.Sk)— order by sort keyOrderBy(e => e.Pk).ThenBy(e => e.Sk)— order by PK then SK- Any
ASC/DESCcombination on the above is valid
Multi-partition queries (WHERE PK IN (...)):
- The partition key must lead the
ORDER BYchain. OrderBy(e => e.Pk)— validOrderBy(e => e.Pk).ThenBy(e => e.Sk)— validOrderBy(e => e.Sk)— invalid: partition key must come firstOrderByDescending(e => e.Pk).ThenByDescending(e => e.Sk)— valid (any direction)
// Single partition — order by PK or SK
db.Orders
.Where(o => o.Pk == "CUSTOMER#42")
.OrderBy(o => o.Pk)
.ThenByDescending(o => o.Sk)
.ToList();
// ORDER BY "Pk" ASC, "Sk" DESC
// Multi-partition — PK must lead
var customers = new[] { "CUSTOMER#1", "CUSTOMER#2" };
db.Orders
.Where(o => customers.Contains(o.Pk))
.OrderBy(o => o.Pk)
.ThenBy(o => o.Sk)
.ToList();
// ORDER BY "Pk" ASC, "Sk" ASC
Limitations / DynamoDB quirks
- A
WHEREclause with an equality or IN constraint on the partition key is required;ORDER BYwithout a partition key constraint throws a provider error. - For multi-partition queries, the partition key must be the first
ORDER BYcolumn; ordering by sort key first is not supported. - Non-key attributes in
ORDER BYalways produce a provider error. - See DynamoDB PartiQL SELECT for engine-level ordering behavior.
Limit(n)
Purpose
- Set a DynamoDB evaluation budget for a query.
Translation
- Sets
ExecuteStatementRequest.Limit = n. Always a single request. - DynamoDB evaluates at most
nitems, applies any non-key filters, and returns 0..n results. - When chained multiple times, the last call wins.
nmust be positive. Zero or negative throwsArgumentOutOfRangeExceptionat construction time for constants, or at execution time for runtime values.
Example
// Evaluate 25 items, apply filter, return 0..25.
var results = await db.Orders
.Where(x => x.IsActive)
.Limit(25)
.ToListAsync(cancellationToken);
// Chaining — last wins (effective limit: 20).
await db.Orders.Limit(10).Limit(20).ToListAsync(cancellationToken);
Compiled queries with runtime parameters
var query = EF.CompileAsyncQuery((OrderDbContext ctx, int n)
=> ctx.Orders.Limit(n));
var results = await query(db, 50).ToListAsync(cancellationToken);
Limitations
- Does not guarantee
nrows returned. DynamoDB readsnitems, applies filters, and returns whatever matches in that range. UseToListAsync()withoutLimit(n)to collect all matches. - There is no paging. If fewer than
nmatching items exist in the evaluated range, the result is simply shorter.
First / FirstOrDefault
Purpose
- Return the first result (throw if none exist for
First; returnnullforFirstOrDefault).
Translation
First* works server-side only on the safe key-only path. All other shapes throw at
translation time — use .AsAsyncEnumerable() to cross into client-side LINQ explicitly.
| Shape | Limit on request | Notes |
|---|---|---|
| Key-only (PK equality, key-only predicates, no user limit) | 1 (implicit) |
Safe path — single request |
Limit(n) + First* |
Translation failure | Use .Limit(n).AsAsyncEnumerable().FirstOrDefaultAsync(ct) |
| Non-key predicate or scan-like | Translation failure | Use .Limit(n).AsAsyncEnumerable().FirstOrDefaultAsync(ct) |
First* on the safe path is always a single request. The provider never pages for a First* terminal.
Safe path conditions — ALL must hold:
- No user-specified
Limit(n). - The
WHEREclause contains a partition-key equality condition. - The
WHEREclause contains only key predicates (no non-key attribute filters).
Special case — no sort key: When the queried source has no sort key, each partition contains
at most one item. First* with PK equality is always safe regardless of non-key predicates
(condition 3 is relaxed).
Example — key-only (safe)
// Safe path: PK + SK equality. Uses Limit=1 automatically.
var item = await db.Orders
.Where(o => o.UserId == userId && o.OrderId == orderId)
.FirstOrDefaultAsync(cancellationToken);
Example — non-key filter (use AsAsyncEnumerable)
// Non-key predicate: IsActive is not PK or SK.
// Fetch up to 50 items server-side, take the first match client-side.
var active = await db.Orders
.Where(o => o.UserId == userId && o.IsActive)
.Limit(50)
.AsAsyncEnumerable()
.FirstOrDefaultAsync(cancellationToken);
Limitations / DynamoDB quirks
- Unsafe
First*always throws at translation time — never silently returns null. Limit(n) + First*always throws.Limit(n)is an evaluation budget that may return multiple items; combining it directly withFirst*is ambiguous. UseAsAsyncEnumerable()to make the client-side selection explicit.- The
AsAsyncEnumerable()pattern is the standard EF Core approach for explicit client-side evaluation — it marks the boundary between server-side and LINQ-to-objects evaluation.
External references
- AWS ExecuteStatement API: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExecuteStatement.html
- DynamoDB PartiQL SELECT: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.select.html
- DynamoDB PartiQL operators: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-operators.html
- DynamoDB PartiQL functions: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-functions.html
- DynamoDB
begins_with: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-functions.beginswith.html - PartiQL identifiers: https://partiql.org/concepts/identifiers.html