Limitations¶
The DynamoDB EF Core provider does not support all standard EF Core features. This page is the authoritative reference for what is not supported, why, and what workaround (if any) applies.
Database lifecycle¶
SaveChangesnever creates DynamoDB tables. CallEnsureCreatedAsyncexplicitly or provision tables outside the provider.- Lifecycle APIs are async-only. Synchronous
EnsureCreated,EnsureDeleted, andCanConnectthrowNotSupportedException. EnsureCreatedAsynccreates missing tables withPAY_PER_REQUESTbilling. It can add missing GSIs to existing on-demand tables, but cannot add GSIs to provisioned tables because lifecycle throughput configuration is not exposed yet. It cannot add LSIs after table creation. Lifecycle wait polling, backoff, timeout, and whether to wait for completion are configurable.- Existing schema validation is limited to table key schema and secondary-index key/projection shape.
Includesecondary-index projection cannot be created yet because non-key projected attributes are not represented in provider metadata.
See Table Lifecycle for full behavior and seeding semantics.
Unsupported LINQ Operators¶
The following operators throw InvalidOperationException at translation time. The provider does
not fall back to in-process evaluation for these — the exception surfaces before any DynamoDB
request is sent.
See Supported Operators for the full list of what does translate.
| Category | Operators | Why |
|---|---|---|
| Aggregation | Count, LongCount, Sum, Average, Min, Max |
DynamoDB PartiQL has no aggregate functions |
| Grouping | GroupBy |
GROUP BY is not supported in DynamoDB PartiQL |
| Joins | Join, GroupJoin, LeftJoin, RightJoin, SelectMany, DefaultIfEmpty |
DynamoDB does not support cross-item joins |
| Set operations | Union, Concat, Except, Intersect |
Not supported in DynamoDB PartiQL |
| Offset / paging | Skip, Take, ElementAt, ElementAtOrDefault |
DynamoDB has no offset semantics — use Limit(n) for an evaluation budget |
| Element operators | Any, All |
Not supported server-side |
| Reverse traversal | Last, LastOrDefault, Reverse |
Requires reverse index traversal, not implemented |
| Deduplication | Distinct |
SELECT DISTINCT is not supported in DynamoDB PartiQL |
| Type filtering | OfType<T>, Cast<T> |
Not supported |
| Conditional skipping | SkipWhile, TakeWhile |
Not supported |
Queryable Contains over query sources |
Queryable.Contains(dbSet, item) |
Not supported; in-memory membership translates to IN, native collection membership to contains |
Value-converted enum numeric casts are also rejected when compared to parameters. For example, (int)entity.Status == value is not translated if Status uses .HasConversion<string>(), because DynamoDB stores the converted string value. Compare entity.Status to an enum value directly, or map the enum numerically.
Complex property-to-property equality and equality against complex object parameters or inline complex object constants are supported.
Complex Type Equality¶
Complex type equality (==) translates to whole-map attribute equality in PartiQL. DynamoDB compares the entire stored map, not individual properties. If a DynamoDB item contains unmapped attributes written outside EF Core (out-of-band writes), two items that are structurally equal by CLR properties may not compare equal at the DynamoDB level because the stored maps differ.
Rely on complex type equality only when EF Core is the sole writer of those attributes. If out-of-band writes are possible, compare individual scalar properties instead.
Scalar value-converted collection membership is not translated. entity.Values.Contains(value) is
supported for native DynamoDB primitive list/set attributes, but not when Values is serialized into
a scalar string or blob by a property value converter. In that case DynamoDB would evaluate substring
or binary containment rather than collection membership.
Workaround for unsupported operators: switch to AsAsyncEnumerable() before the unsupported
operator to move evaluation in-process:
// ❌ Throws at translation time
var count = await context.Orders.CountAsync();
// ✅ In-process
var count = await context.Orders.AsAsyncEnumerable().CountAsync();
In-process evaluation fetches all matching pages from DynamoDB before applying the operator. Use with care on large result sets.
Take vs Limit(n)¶
Take(n) is not translated — use the DynamoDB-specific Limit(n) extension instead. The
distinction matters: Limit(n) maps to ExecuteStatementRequest.Limit, which is an evaluation
budget (DynamoDB reads up to n items then filters). It is not a result count. See
Ordering and Limiting for details.
Query Shape Constraints¶
First / FirstOrDefault — Key-Only Safe Path¶
FirstAsync and FirstOrDefaultAsync set an implicit Limit=1 on the server request. Because
DynamoDB counts evaluated items against Limit (not matched items), this is only safe when the
WHERE clause guarantees at most one evaluation pass before a match:
- No user-specified
Limit(n)on the query. - The
WHEREclause includes a partition-key equality condition. - Any sort-key predicate is a valid DynamoDB key condition (
=,<,<=,>,>=,BETWEEN,begins_with).
By default, filtered First* queries that fail the partition-key or sort-key safety checks throw
InvalidOperationException at translation time. AsUnsafeFilteredQuery() and
AllowUnsafeFilteredQueries() can bypass only that filtered First* safety validation for
controlled legacy code or tests. Explicit Limit(n) combined with First* is never supported.
Sort-key filter expressions are unsafe. SK IN (...) and SK = A OR SK = B reference only
key attributes but are DynamoDB filter expressions, not key conditions. Limit=1 on a filter
predicate can silently miss matching rows later in the partition:
var skValues = new[] { "ORDER#1", "ORDER#2" };
// ❌ Throws — SK IN is a filter expression
await context.Orders
.Where(x => x.Pk == pk && skValues.Contains(x.Sk))
.FirstOrDefaultAsync(ct);
// ✅ Client-side selection via AsAsyncEnumerable()
var result = await context.Orders
.Where(x => x.Pk == pk && skValues.Contains(x.Sk))
.AsAsyncEnumerable()
.FirstOrDefaultAsync(ct);
Unsafe filtered First* is not a best practice
`AsUnsafeFilteredQuery()` bypasses the provider's `First` / `FirstOrDefault` safety
validation for one query. `AllowUnsafeFilteredQueries()` applies the same bypass to every
query in the context.
This does not disable scan-like query protection, does not allow explicit `Limit(n)` or
`WithNextToken()` with `First*`, and does not change `First*` execution: the provider still
sends one request with implicit `Limit=1` when no user limit is specified.
DynamoDB applies filters after evaluating items, so `FirstOrDefaultAsync` can return `null`
and `FirstAsync` can throw even when a later item would match. See AWS' notes on
[filter expressions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.FilterExpression.html).
Use this only for tests or controlled legacy code.
var result = await context.Orders
.Where(x => x.Pk == pk && skValues.Contains(x.Sk))
.AsUnsafeFilteredQuery()
.FirstOrDefaultAsync(ct);
Exception — PK-only table. When the base table has no sort key, each partition holds at most
one item and First* with a PK equality condition is always safe.
Shared-table / inheritance. The provider injects a discriminator predicate automatically.
Server-side First* is safe only when the query evaluates at most one base-table item before
filtering: a PK-only lookup on a PK-only table, or a PK+SK equality on a PK+SK table. By default,
all other shapes throw — use AsAsyncEnumerable().FirstOrDefaultAsync(), or explicitly opt in to
unsafe filtered First* behavior when you accept the DynamoDB filter-expression risk.
Single / SingleOrDefault — Key-Condition-Only¶
SingleAsync and SingleOrDefaultAsync are supported only for key-condition-only query shapes.
The provider sends one DynamoDB request with implicit ExecuteStatementRequest.Limit = 2 and lets
EF Core enforce cardinality:
- zero returned items:
SingleAsyncthrows,SingleOrDefaultAsyncreturnsnull/ default - one returned item: returns that item
- two returned items: throws
Sequence contains more than one element.
Because DynamoDB Limit counts evaluated items, not matched rows, Single* does not allow non-key
filters, scan-like predicates, or sort-key filter expressions. Unlike First*, there is no unsafe
filtered escape hatch yet: AsUnsafeFilteredQuery() and AllowUnsafeFilteredQueries() do not
bypass Single* validation.
Allowed partition-key conditions are equality and IN. Sort-key predicates are allowed only when
they are DynamoDB key conditions (=, <, <=, >, >=, BETWEEN, begins_with). For
shared-table inheritance queries with discriminator filters, Single* requires a base-table lookup
that identifies one physical item: PK equality on a PK-only table, or PK+SK equality on a PK+SK
table. Secondary-index derived-type queries can fail validation even when they include index key
conditions. Explicit Limit(n) and WithNextToken() combined with Single* throw at translation
time.
If DynamoDB returns any NextToken for a validated Single* query, the provider throws
InvalidOperationException. This is treated as a guard against an unexpected provider/DynamoDB
invariant break, not as a signal to page. Without a continuation token, two returned items use
EF Core's normal Single* duplicate detection and throw Sequence contains more than one element.
Find — Primary-Key Lookup¶
FindAsync is supported for primary-key lookup. It checks the change tracker first and otherwise
executes a base-table key-equality PartiQL query with Limit=1. It does not use secondary indexes
or automatic index selection; use LINQ with .WithIndex(...) or automatic selection for
secondary-index lookups.
Synchronous Find follows EF Core's normal change-tracker behavior: it can return an already
tracked entity without DynamoDB I/O. If it needs to query DynamoDB, synchronous query execution
throws InvalidOperationException. Use FindAsync for database lookups:
WithNextToken Cannot Combine with First* or Single*¶
Combining .WithNextToken(token) with FirstAsync, FirstOrDefaultAsync, SingleAsync, or
SingleOrDefaultAsync throws InvalidOperationException. A seeded continuation token implies
resuming an arbitrary position in a result set, which is incompatible with provider-managed
terminal limits (Limit=1 for First*, Limit=2 for Single*).
OrderBy — Only Key Columns¶
OrderBy and OrderByDescending only accept partition-key and sort-key column expressions.
Non-key attribute ordering throws at translation time. For multi-partition queries, the partition
key must be the first ORDER BY column.
Automatic Index Selection — ALL Projection Only¶
Automatic index selection (On or SuggestOnly mode) rejects GSI/LSI candidates
whose projection type is not ALL. KEYS_ONLY and INCLUDE index candidates are logged as
rejected (DYNAMO_IDX005) and excluded from selection. Use an explicit .WithIndex("name")
hint to route to a non-ALL index.
No string.StartsWith or string.Contains Overloads with Culture / Char¶
string.StartsWith(s) and string.Contains(s) translate to begins_with and contains in
PartiQL only for the single-string-argument overloads. Overloads that accept a char, a
StringComparison, or a CultureInfo argument throw at translation time.
SELECT * Never Emitted¶
The provider always emits an explicit column list. This means projected types must have all required attributes available in the index or table projection. See Projection.
Write Constraints¶
Synchronous SaveChanges Not Supported¶
SaveChanges() throws NotSupportedException. Use SaveChangesAsync().
The AWS SDK for .NET exposes only async I/O for DynamoDB; the provider does not wrap async calls synchronously to avoid deadlocks in ASP.NET Core and other async-first hosts.
Key Mutation Not Supported¶
Changing a primary key (partition key or sort key) value on an entity and calling
SaveChangesAsync throws NotSupportedException. DynamoDB items are identified by their key
attributes; updating a key requires deleting the old item and inserting a new one. The provider
does not perform this two-step operation automatically — detach and re-add the entity with the
new key instead.
DynamoDB Transaction Limits¶
DynamoDB ExecuteTransaction enforces two hard limits:
-
Maximum 100 write statements per transaction. When
AutoTransactionBehaviorisWhenNeededorAlwaysand the save unit exceedsMaxTransactionSize(default 100, max 100), the provider throwsInvalidOperationExceptionunlessTransactionOverflowBehavior.UseChunkingis configured. -
No duplicate items within a single transaction. Writing the same DynamoDB item more than once in a single transaction throws
InvalidOperationException— the provider validates this client-side before sending the request to DynamoDB.
See Transactions for configuration details.
acceptAllChangesOnSuccess: false Restrictions¶
Chunked transactional writes (TransactionOverflowBehavior.UseChunking) and non-atomic batched
writes (AutoTransactionBehavior.Never) both require acceptAllChangesOnSuccess: true.
Calling SaveChangesAsync(acceptAllChangesOnSuccess: false) with either path throws, because
partial chunk commits must be accepted immediately in the change tracker to avoid replaying
already-persisted writes on retry.
PartiQL Statement Length Limit¶
DynamoDB enforces an 8 192-byte limit on ExecuteStatement statement text. The provider
validates statement length before sending and throws InvalidOperationException if the limit is
exceeded. This can happen with entities that have a large number of scalar properties. Consider
splitting such entities across multiple SaveChanges calls or reducing the number of mapped
properties.
EF Core Bulk Operations Not Supported¶
ExecuteUpdateAsync() and ExecuteDeleteAsync() (EF Core 7+ bulk operations) are not
implemented. Bulk mutations must be performed by loading entities, modifying them in the change
tracker, and calling SaveChangesAsync().
BatchExecuteStatement Partial Success¶
When AutoTransactionBehavior.Never is set, the provider executes writes via BatchExecuteStatement.
DynamoDB executes each statement independently — a batch can partially succeed, meaning some
writes commit while others fail. The provider throws if the response contains any failed
operations, but successful statements within that batch have already been persisted.
Modeling Constraints¶
Relationships and Foreign Keys Are Not Supported¶
The provider does not support EF Core relationship modeling. HasOne(...), HasMany(...),
WithOne(...), WithMany(...), HasForeignKey(...), skip navigations, and relationship
attributes such as [ForeignKey] and [InverseProperty] throw during model building or model
validation.
DynamoDB has no relational foreign-key enforcement or joins. Model embedded document data with EF Core complex types, and model separate DynamoDB items or tables as separate root entity types without EF navigation relationships.
// ✅ Embedded data: complex types
modelBuilder.Entity<Customer>(b =>
{
b.ComplexProperty(x => x.Profile);
b.ComplexCollection(x => x.Contacts);
});
// ❌ Not supported: relational navigation/foreign-key modeling
modelBuilder.Entity<Order>()
.HasOne(x => x.Customer)
.WithMany(x => x.Orders)
.HasForeignKey(x => x.CustomerId);
Owned Entity Types Are Not Supported¶
The provider does not support EF Core owned entity types. OwnsOne(...), OwnsMany(...), and
other owned-type configuration paths throw during model validation with guidance to switch to
complex types instead.
Use EF Core complex types for embedded document data:
modelBuilder.Entity<Customer>(b =>
{
b.ComplexProperty(x => x.Profile);
b.ComplexCollection(x => x.Contacts);
});
Key Configuration¶
Root entities must use HasPartitionKey(...) and, when needed, HasSortKey(...). Using
HasKey(...) or [Key] on a root entity throws during model validation.
Key properties must be non-nullable and resolve to a DynamoDB key-compatible provider type:
string, a numeric type (int, long, decimal, etc.), or byte[]. bool key properties
are rejected — bool has no built-in converter to a key-compatible type. Other non-primitive
types such as Guid, DateTime, and enum work because EF Core's built-in converters map
them to key-compatible store types (for example Guid/DateTime to string, and enum to
its numeric underlying value).
All entity types mapped to the same table must agree on key shape (PK-only or PK+SK) and must use identical physical attribute names for the partition key and sort key.
See Entities and Keys.
Secondary-Index Key Constraints¶
Secondary-index key properties follow the same type requirements as table keys but may be nullable (items without a scalar key-compatible value for a GSI/LSI key attribute are simply not indexed).
Local secondary indexes additionally require the table to define a sort key.
See Secondary Indexes.
Primitive Collection CLR Shapes¶
Primitive collection properties are supported only for specific CLR shapes. Custom or derived collection types throw during model validation.
| Collection kind | Supported CLR shapes |
|---|---|
| List | T[], List<T>, IList<T> |
| Set | HashSet<T>, ISet<T>, IReadOnlySet<T> |
| Dictionary | Dictionary<string, TValue>, IDictionary<string, TValue>, IReadOnlyDictionary<string, TValue>, ReadOnlyDictionary<string, TValue> |
Dictionary keys must be string. Non-string-keyed dictionary types are not supported.
Complex collection properties use a narrower CLR shape set than primitive collections. Complex
collections support only List<T> and IList<T>. ICollection<T>, IReadOnlyList<T>, and
arrays are not supported for complex collections. See Complex Types
for complex collection mapping details.
Concurrency Tokens — Application-Managed Only¶
Concurrency tokens (IsConcurrencyToken() / [ConcurrencyCheck]) are supported, but the
provider does not generate or increment token values automatically. Your application code must
update the token value before calling SaveChangesAsync.
IsRowVersion() and ValueGenerated.OnAddOrUpdate throw during model validation because the
provider cannot guarantee auto-increment semantics on DynamoDB item writes.
Shared-Table Discriminator Constraints¶
When multiple entity types share the same DynamoDB table, a discriminator is required. The following constraints are validated at startup:
- Discriminator values must be unique within the table group.
- All entity types in the group must use the same discriminator attribute name.
- The discriminator attribute name must not collide with the partition key or sort key attribute names.
The default discriminator attribute name is $type.
See Single-Table Design.
Behavioral Differences from Standard EF Core¶
Async-Only Execution¶
Synchronous query execution throws InvalidOperationException. This applies to all query
enumeration, not just SaveChanges. Methods like ToList(), First(), and Count() on a
DbSet will throw. Find() can still return an already-tracked entity without querying. Use
ToListAsync(), FirstAsync(), FindAsync(), AsAsyncEnumerable(), etc.
ToQueryString() Is Debug-Only¶
IQueryable<T>.ToQueryString() returns generated PartiQL and formatted parameter comments without
sending a request to DynamoDB. It does not execute scan warnings, log command events, or validate
that DynamoDB accepts the statement at runtime. See Diagnostics and Logging.
Parameterized Null Inconsistency¶
When a nullable variable is null at runtime in a comparison (x.Prop == someVar where
someVar is null), the provider parameterizes the query as WHERE "Prop" = ? with an
AttributeValue { NULL = true }. This matches attributes stored with the DynamoDB NULL type but
does not match MISSING attributes (attributes absent from the item).
By contrast, a constant null comparison (x.Prop == null) translates to
"Prop" IS NULL OR "Prop" IS MISSING, which covers both representations.
DynamoDB PartiQL does not support attr IS ? (parameterized IS), so the two behaviors cannot
be unified. If you need to match both NULL and MISSING via a runtime variable, use explicit
functions:
// Explicit: matches both NULL and MISSING at runtime
.Where(x => EF.Functions.IsNull(x.Prop) || EF.Functions.IsMissing(x.Prop))
Two-Column Nullable Comparison¶
Comparing two nullable columns directly (x.A == x.B where both are nullable) generates a
binary = predicate. When either column holds a NULL type or is MISSING, DynamoDB PartiQL
returns MISSING (not TRUE) for the equality comparison — the row is excluded from results.
There is no provider-level workaround for this shape.
Consistent Read Semantics Follow the Final Query Source¶
The provider can set ExecuteStatementRequest.ConsistentRead globally with
options.ConsistentRead(true) or per query with .WithConsistentRead(). Per-query settings take
precedence, including .WithConsistentRead(false) overriding a global strongly consistent default.
Strong consistency is sent only when the finalized query source is the base table or an LSI. If a
global strongly consistent default query is finalized to a GSI through explicit index routing or
automatic index selection, the provider leaves ConsistentRead unset because DynamoDB GSIs are
always eventually consistent. If a query explicitly calls .WithConsistentRead() and the finalized
source is a GSI, the provider throws before sending the request.
The provider does not warn or fail for scan-like queries. It passes allowed consistency settings through to DynamoDB and lets DynamoDB apply the service semantics for the specific statement.
Per-Entity Response Metadata Requires Tracking¶
context.Entry(entity).GetExecuteStatementResponse() returns null for entities loaded via
AsNoTracking(). The ExecuteStatementResponse is stored in a shadow property that only exists
on tracked entity entries. See Diagnostics and Logging.
Owned Types in Select Project the Full Container¶
Accessing a nested owned property path in a Select projection
(x.Profile.Address.City) triggers client-side extraction: the full owned container attribute
("Profile") is fetched from DynamoDB and the nested value is read in-process. The path does
translate server-side in Where predicates.