Skip to main content

Lambda Expressions

Write type-safe queries using C# lambda expressions. This is the recommended approach for building DynamoDB expressions, offering compile-time type checking, IntelliSense support, and refactoring safety.

Key Benefits

  • Type Safety: Catch property name typos at compile time instead of runtime
  • IntelliSense Support: Get autocomplete suggestions for properties and methods
  • Refactoring Safety: Rename properties with confidence - expressions update automatically
  • Cleaner Code: No manual parameter naming or attribute mapping required
  • AOT Compatible: Works seamlessly in Native AOT environments

Query Expressions

Use Where<T>() to specify key conditions for Query operations. Key conditions define which items to retrieve based on partition and sort keys.

Basic Query by Partition Key

// Query all items for a specific user
var users = await table.Query<User>()
.Where(x => x.UserId == "user123")
.ToListAsync();

Query with Sort Key Conditions

// Query orders after a specific date
var orders = await table.Query<Order>()
.Where(x => x.CustomerId == customerId && x.OrderId > "ORDER#2024-01-01")
.ToListAsync();

// Query orders within a date range using Between
var orders = await table.Query<Order>()
.Where(x => x.CustomerId == customerId &&
x.OrderId.Between("ORDER#2024-01", "ORDER#2024-12"))
.ToListAsync();

// Query orders with a specific prefix using StartsWith
var orders = await table.Query<Order>()
.Where(x => x.CustomerId == customerId && x.OrderId.StartsWith("ORDER#2024-03"))
.ToListAsync();

Complex Query Pattern

var customerId = "CUSTOMER#123";
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);

var orders = await table.Query<Order>()
.Where(x =>
x.CustomerId == customerId &&
x.OrderDate.Between(thirtyDaysAgo, DateTime.UtcNow))
.ToListAsync();

Filter Expressions

Use WithFilter<T>() to apply additional filtering after items are retrieved. Filters can use any attribute, not just key attributes.

Basic Filter

var orders = await table.Query<Order>()
.Where(x => x.CustomerId == customerId)
.WithFilter(x => x.Status == "pending")
.ToListAsync();

Complex Filters with Multiple Conditions

// AND conditions
var orders = await table.Query<Order>()
.Where(x => x.CustomerId == customerId)
.WithFilter(x => x.Status == "ACTIVE" && x.Age >= 18)
.ToListAsync();

// OR conditions
var orders = await table.Query<Order>()
.Where(x => x.CustomerId == customerId)
.WithFilter(x => x.Status == "pending" || x.Status == "processing")
.ToListAsync();

// Combined logical operators with parentheses
var users = await table.Query<User>()
.Where(x => x.PartitionKey == userId)
.WithFilter(x => (x.Active && x.Score > 50) || x.Premium)
.ToListAsync();

Condition Expressions

Use WithCondition<T>() for conditional writes (Put, Update, Delete operations).

Conditional Put (Prevent Overwrites)

var newUser = new User 
{
Id = "USER#123",
Name = "John Doe"
};

await table.Put(newUser)
.WithCondition<User>(x => x.Id.AttributeNotExists())
.PutAsync();

Conditional Update (Optimistic Locking)

await table.Update<User>(userId)
.Set(x => new { Name = "Jane Doe" })
.WithCondition(x => x.Version == currentVersion)
.UpdateAsync();

Conditional Delete

await table.Delete<Order>(orderId)
.WithCondition(x => x.Status == "cancelled")
.DeleteAsync();

Supported Operators

Comparison Operators

All standard comparison operators are supported:

// Equality
table.Query.Where<User>(x => x.Id == userId);
// Translates to: #attr0 = :p0

// Inequality
table.Query.WithFilter<User>(x => x.Status != "DELETED");
// Translates to: #attr0 <> :p0

// Less than
table.Query.WithFilter<User>(x => x.Age < 65);
// Translates to: #attr0 < :p0

// Greater than
table.Query.WithFilter<User>(x => x.Score > 100);
// Translates to: #attr0 > :p0

// Less than or equal
table.Query.WithFilter<User>(x => x.Age <= 18);
// Translates to: #attr0 <= :p0

// Greater than or equal
table.Query.WithFilter<User>(x => x.Score >= 50);
// Translates to: #attr0 >= :p0

Logical Operators

Combine conditions with logical operators:

// AND
table.Query.Where<User>(x => x.PartitionKey == userId && x.SortKey == sortKey);
// Translates to: (#attr0 = :p0) AND (#attr1 = :p1)

// OR
table.Query.WithFilter<User>(x => x.Type == "A" || x.Type == "B");
// Translates to: (#attr0 = :p0) OR (#attr0 = :p1)

// NOT
table.Query.WithFilter<User>(x => !x.Deleted);
// Translates to: NOT (#attr0)

// Complex combinations with parentheses
table.Query.WithFilter<User>(x =>
(x.Active && x.Score > 50) || x.Premium);
// Translates to: ((#attr0) AND (#attr1 > :p0)) OR (#attr2)

DynamoDB Functions

StartsWith (begins_with)

Use string.StartsWith() for prefix matching on sort keys:

table.Query<Order>()
.Where(x => x.CustomerId == customerId && x.OrderId.StartsWith("ORDER#"))
.ToListAsync();
// Translates to: #attr0 = :p0 AND begins_with(#attr1, :p1)

Contains

Use string.Contains() for substring matching:

table.Query<User>()
.WithFilter(x => x.Email.Contains("@example.com"))
.ToListAsync();
// Translates to: contains(#attr0, :p0)

Between

Use the Between() extension method for range queries:

table.Query<User>()
.Where(x => x.PartitionKey == userId && x.SortKey.Between("2024-01", "2024-12"))
.ToListAsync();
// Translates to: #attr0 = :p0 AND #attr1 BETWEEN :p1 AND :p2

AttributeExists

Check if an attribute exists:

table.Query<User>()
.WithFilter(x => x.PhoneNumber.AttributeExists())
.ToListAsync();
// Translates to: attribute_exists(#attr0)

AttributeNotExists

Check if an attribute does not exist:

// Note: Scan requires [Scannable] attribute on the entity
table.Users.Scan()
.WithFilter(x => x.DeletedAt.AttributeNotExists())
.ToListAsync();
// Translates to: attribute_not_exists(#attr0)

Size

Get the size of a collection or string attribute:

table.Query<User>()
.WithFilter(x => x.Items.Size() > 5)
.ToListAsync();
// Translates to: size(#attr0) > :p0

Value Capture

Lambda expressions can capture values from the surrounding scope in several ways.

Constants

Direct constant values are automatically captured:

// String constant
table.Query.Where<User>(x => x.Id == "USER#123");

// Numeric constant
table.Query.WithFilter<User>(x => x.Age >= 18);

// Boolean constant
table.Query.WithFilter<User>(x => x.Active == true);

// Enum constant
table.Query.WithFilter<Order>(x => x.Status == OrderStatus.Pending);

Local Variables

Variables from the surrounding scope are captured:

var userId = "USER#123";
var minAge = 18;
var status = "ACTIVE";

table.Query<User>()
.Where(x => x.PartitionKey == userId)
.WithFilter(x => x.Age >= minAge && x.Status == status)
.ToListAsync();

Closure Captures

Properties from captured objects are evaluated:

var user = GetCurrentUser();
var config = GetConfiguration();

table.Query<Order>()
.Where(x => x.CustomerId == user.Id)
.WithFilter(x => x.Total > config.MinOrderAmount)
.ToListAsync();

Method Calls on Captured Values

You can call methods on captured values (but not on entity properties):

// ✓ Valid: Method call on captured value
var userId = GetUserId();
table.Query<User>()
.Where(x => x.PartitionKey == userId.ToString())
.ToListAsync();

// ✓ Valid: Complex expression on captured value
var date = DateTime.Now;
table.Query<Order>()
.WithFilter(x => x.CreatedDate > date.AddDays(-30))
.ToListAsync();

// ✗ Invalid: Method call on entity property
table.Query<User>()
.WithFilter(x => x.Name.ToUpper() == "JOHN") // Error!
.ToListAsync();

Where() vs WithFilter()

Understanding the difference between Where() and WithFilter() is crucial for efficient queries.

Where() - Key Condition Expression

Use Where() for partition key and sort key conditions. These are applied before reading items from DynamoDB:

// Efficient: Only reads matching items
table.Query<User>()
.Where(x => x.PartitionKey == userId && x.SortKey.StartsWith("ORDER#"))
.ToListAsync();

Characteristics:

  • Only partition key and sort key properties allowed
  • Reduces consumed read capacity
  • Most efficient way to query

WithFilter() - Filter Expression

Use WithFilter() for non-key attributes. These are applied after reading items:

// Less efficient: Reads all items for userId, then filters
table.Query<User>()
.Where(x => x.PartitionKey == userId)
.WithFilter(x => x.Status == "ACTIVE")
.ToListAsync();

Characteristics:

  • Any property allowed
  • Applied after items are read
  • Reduces data transfer but not read capacity
  • Still more efficient than filtering in application code

Common Mistake

A frequent error is using non-key attributes in Where():

// ✗ Error: Non-key property in Where()
table.Query<User>()
.Where(x => x.PartitionKey == userId && x.Status == "ACTIVE")
.ToListAsync();
// Throws: InvalidKeyExpressionException
// "Property 'Status' is not a key attribute and cannot be used in Query().Where().
// Use WithFilter() instead."

// ✓ Correct: Move non-key condition to WithFilter()
table.Query<User>()
.Where(x => x.PartitionKey == userId)
.WithFilter(x => x.Status == "ACTIVE")
.ToListAsync();

Best Practice

Always use key conditions for primary filtering, then apply filters for additional criteria:

// ✅ Good - use key condition for primary filtering
table.Query<Order>()
.Where(x => x.CustomerId == customerId && x.OrderId > "ORDER#2024-01-01")
.WithFilter(x => x.Status == "pending")
.ToListAsync();

// ❌ Avoid - using filter for what should be a key condition
table.Query<Order>()
.Where(x => x.CustomerId == customerId)
.WithFilter(x => x.OrderId > "ORDER#2024-01-01") // Should be in key condition
.ToListAsync();

Valid vs Invalid Patterns

✓ Valid Patterns

// Property access
x => x.PropertyName

// Constant values
x => x.Id == "USER#123"

// Local variables
x => x.Id == userId

// Closure captures
x => x.Id == user.Id

// Method calls on captured values
x => x.Id == userId.ToString()
x => x.CreatedDate > date.AddDays(-30)

// Complex conditions
x => (x.Active && x.Score > 50) || x.Premium

// DynamoDB functions
x => x.Name.StartsWith("John")
x => x.Age.Between(18, 65)
x => x.Email.AttributeExists()
x => x.Items.Size() > 0

✗ Invalid Patterns

// Assignment (use == for comparison)
x => x.Id = "123" // ✗ Error
x => x.Id == "123" // ✓ Correct

// Method calls on entity properties
x => x.Name.ToUpper() == "JOHN" // ✗ Error
var upperName = "JOHN";
x => x.Name == upperName // ✓ Correct

// Methods referencing entity parameter
x => x.Id == MyFunction(x) // ✗ Error
var computedId = MyFunction(someValue);
x => x.Id == computedId // ✓ Correct

// LINQ operations on entity properties
x => x.Items.Select(i => i.Name).Contains("test") // ✗ Error
x => x.Items.Contains("test") // ✓ Correct (if Items is a collection)

// Unsupported operators
x => x.Age % 2 == 0 // ✗ Error (modulo not supported)
// Filter in application code after retrieval instead

Troubleshooting

InvalidKeyExpressionException

Error:

Property 'Status' is not a key attribute and cannot be used in Query().Where(). 
Use WithFilter() instead.

Solution: Move non-key properties to WithFilter():

// ✗ Wrong
table.Query<User>().Where(x => x.PartitionKey == userId && x.Status == "ACTIVE");

// ✓ Correct
table.Query<User>()
.Where(x => x.PartitionKey == userId)
.WithFilter(x => x.Status == "ACTIVE");

UnsupportedExpressionException

Error:

Method 'ToUpper' cannot be used on entity properties in DynamoDB expressions.

Solution: Transform values before the query:

// ✗ Wrong
table.Query<User>().WithFilter(x => x.Name.ToUpper() == "JOHN");

// ✓ Correct
var upperName = "JOHN";
table.Query<User>().WithFilter(x => x.Name == upperName);

Complete Examples

E-commerce Order Query

var customerId = "CUSTOMER#123";
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
var minAmount = 50.00m;

var orders = await table.Query<Order>()
.Where(x =>
x.CustomerId == customerId &&
x.OrderDate.Between(thirtyDaysAgo, DateTime.UtcNow))
.WithFilter(x =>
x.Status == "SHIPPED" &&
x.Total >= minAmount &&
x.Items.Size() > 0)
.ToListAsync();

User Search with Multiple Criteria

var region = "US-WEST";
var minAge = 18;

var users = await table.Query<User>()
.OnIndex("RegionIndex")
.Where(x => x.Region == region)
.WithFilter(x =>
x.Active &&
x.Age >= minAge &&
x.Email.AttributeExists() &&
x.EmailVerified)
.ToListAsync();

Scan with Complex Filter

Scan Requires Opt-In

Scan operations read every item in a table and are expensive in terms of read capacity. They are not recommended as a primary access pattern. To use Scan, you must explicitly add the [Scannable] attribute to your entity.

// Entity must have [Scannable] attribute to enable Scan operations
[DynamoDbTable("Users")]
[Scannable] // Required for Scan operations
public partial class User
{
[PartitionKey]
[DynamoDbAttribute("pk")]
public string Pk { get; set; } = string.Empty;

// ... other properties
}
var minScore = 1000;

// Scan is available because User entity has [Scannable] attribute
var users = await table.Users.Scan()
.WithFilter(x =>
x.Premium ||
(x.Score >= minScore && x.Active))
.ToListAsync();

Next Steps