
Learn AWS DynamoDB with C# and .NET: setup, CRUD, the Object Persistence Model, queries, and serverless Lambda best practices. Start building today.
If you are building serverless .NET applications on AWS, sooner or later you will need a database that scales as effortlessly as AWS Lambda does. That database is almost always Amazon DynamoDB. In this DynamoDB C# tutorial, you will learn how to connect a .NET application to DynamoDB using the AWS SDK for .NET, perform CRUD operations with both the low-level and high-level APIs, run efficient queries, and avoid the pitfalls that catch most developers on their first serverless project.
DynamoDB is a fully managed NoSQL key-value and document database. There are no servers to patch, no connection pools to tune, and it delivers single-digit millisecond latency at any scale. For Lambda functions written in C#, it is the natural fit: both services are pay-per-use, both scale horizontally, and neither requires you to manage infrastructure.
Why Use DynamoDB with C# for Serverless .NET?
Relational databases such as SQL Server or PostgreSQL work well behind long-running ASP.NET Core apps, but they have friction in a serverless world:
- Connection limits: Hundreds of concurrent Lambda invocations each opening a database connection will exhaust a relational server quickly. DynamoDB is accessed over HTTPS with no persistent connections.
- Cold starts: Entity Framework Core model building adds noticeable startup time. The DynamoDB client is lightweight and initializes in milliseconds.
- Pricing model: With on-demand capacity you pay only for the reads and writes you make, which matches Lambda's per-invocation billing.
- Scalability: DynamoDB tables scale to millions of requests per second without schema migrations or read replicas.
The trade-off is that DynamoDB requires you to design your table around your access patterns up front. We will cover that in the best practices section.
Setting Up the AWS SDK for .NET DynamoDB Packages
Create a new console or Lambda project and install the DynamoDB NuGet package. The package includes both the low-level client and the high-level Object Persistence Model.
dotnet new console -n DynamoDbDemo
cd DynamoDbDemo
dotnet add package AWSSDK.DynamoDBv2
dotnet add package AWSSDK.Extensions.NETCore.Setup
For local development, configure credentials with the AWS CLI (aws configure) or use a named profile. In Lambda, credentials come automatically from the function's execution role, so never hard-code access keys in your code.
Registering the DynamoDB Client with Dependency Injection
Whether you are using ASP.NET Core or the Lambda Annotations framework, register a single IAmazonDynamoDB instance. The client is thread-safe and designed to be reused for the lifetime of the process.
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DataModel;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddDefaultAWSOptions(new Amazon.Extensions.NETCore.Setup.AWSOptions
{
Region = Amazon.RegionEndpoint.USEast1
});
services.AddAWSService<IAmazonDynamoDB>();
services.AddSingleton<IDynamoDBContext>(sp =>
new DynamoDBContextBuilder()
.WithDynamoDBClient(() => sp.GetRequiredService<IAmazonDynamoDB>())
.Build());
var provider = services.BuildServiceProvider();
Creating a DynamoDB Table from C#
In production you will normally create tables with CloudFormation, CDK, or Terraform, but creating one from code is useful for tests and local development. This example creates an Orders table with a composite primary key: CustomerId as the partition key and OrderId as the sort key.
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
public static async Task CreateOrdersTableAsync(IAmazonDynamoDB client)
{
var request = new CreateTableRequest
{
TableName = "Orders",
AttributeDefinitions = new List<AttributeDefinition>
{
new("CustomerId", ScalarAttributeType.S),
new("OrderId", ScalarAttributeType.S)
},
KeySchema = new List<KeySchemaElement>
{
new("CustomerId", KeyType.HASH), // partition key
new("OrderId", KeyType.RANGE) // sort key
},
BillingMode = BillingMode.PAY_PER_REQUEST
};
await client.CreateTableAsync(request);
// Wait until the table is ACTIVE before using it
string status;
do
{
await Task.Delay(1000);
var describe = await client.DescribeTableAsync("Orders");
status = describe.Table.TableStatus;
} while (status != TableStatus.ACTIVE);
}
Why a composite key? A partition key alone only lets you fetch one item by ID. Adding a sort key lets you store all orders for a customer in the same partition and retrieve them with a single, cheap Query call rather than an expensive full-table Scan.
DynamoDB CRUD in C# with the Object Persistence Model
The AWS SDK for .NET offers three programming models: the low-level IAmazonDynamoDB client, the Document model (Table / Document), and the high-level Object Persistence Model (DynamoDBContext). For most application code, the Object Persistence Model is the best choice because it maps plain C# classes to items, similar to how Entity Framework maps entities to rows.
Defining the Entity Class
using Amazon.DynamoDBv2.DataModel;
[DynamoDBTable("Orders")]
public class Order
{
[DynamoDBHashKey]
public string CustomerId { get; set; } = default!;
[DynamoDBRangeKey]
public string OrderId { get; set; } = default!;
[DynamoDBProperty]
public decimal Total { get; set; }
[DynamoDBProperty]
public string Status { get; set; } = "Pending";
[DynamoDBProperty]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[DynamoDBProperty]
public List<string> Items { get; set; } = new();
[DynamoDBVersion]
public int? Version { get; set; }
}
The [DynamoDBVersion] attribute enables optimistic locking. Every save increments the version, and if two Lambda invocations try to update the same item concurrently, the second one throws a ConditionalCheckFailedException instead of silently overwriting data.
Create, Read, Update, and Delete
public class OrderRepository
{
private readonly IDynamoDBContext _context;
public OrderRepository(IDynamoDBContext context) => _context = context;
// CREATE or full UPDATE (PutItem under the hood)
public Task SaveAsync(Order order) => _context.SaveAsync(order);
// READ a single item by its full primary key
public Task<Order?> GetAsync(string customerId, string orderId) =>
_context.LoadAsync<Order?>(customerId, orderId);
// UPDATE with optimistic locking
public async Task MarkShippedAsync(string customerId, string orderId)
{
var order = await _context.LoadAsync<Order>(customerId, orderId)
?? throw new KeyNotFoundException($"Order {orderId} not found");
order.Status = "Shipped";
await _context.SaveAsync(order); // throws if Version changed since load
}
// DELETE
public Task DeleteAsync(string customerId, string orderId) =>
_context.DeleteAsync<Order>(customerId, orderId);
}
Usage looks exactly like any other repository:
var repo = new OrderRepository(provider.GetRequiredService<IDynamoDBContext>());
await repo.SaveAsync(new Order
{
CustomerId = "CUST-1001",
OrderId = $"ORD-{DateTime.UtcNow:yyyyMMddHHmmss}",
Total = 149.99m,
Items = new() { "Keyboard", "Mouse" }
});
var order = await repo.GetAsync("CUST-1001", "ORD-20260830120000");
Console.WriteLine($"{order?.OrderId}: {order?.Status} - ${order?.Total}");
Querying DynamoDB from C#: Query vs. Scan
This is the single most important concept in DynamoDB. A Query uses the partition key (and optionally the sort key) and reads only the matching items. A Scan reads every item in the table and filters afterwards, so you are billed for the entire table even if you only want ten rows. Always design your keys so that your hot paths are Queries.
Query All Orders for a Customer
public async Task<List<Order>> GetOrdersForCustomerAsync(string customerId)
{
var query = _context.QueryAsync<Order>(customerId);
return await query.GetRemainingAsync();
}
Query with a Sort Key Condition
Because our OrderId begins with a timestamp, we can fetch only orders from a given month using BeginsWith:
using Amazon.DynamoDBv2.DocumentModel;
public async Task<List<Order>> GetOrdersForMonthAsync(string customerId, string yearMonth)
{
var query = _context.QueryAsync<Order>(
customerId,
QueryOperator.BeginsWith,
new object[] { $"ORD-{yearMonth}" });
return await query.GetRemainingAsync();
}
Filtering with the Low-Level Client
When you need full control—projection expressions, consistent reads, or limits—drop down to the low-level client:
public async Task<List<string>> GetShippedOrderIdsAsync(IAmazonDynamoDB client, string customerId)
{
var request = new QueryRequest
{
TableName = "Orders",
KeyConditionExpression = "CustomerId = :cid",
FilterExpression = "#s = :status",
ProjectionExpression = "OrderId",
ExpressionAttributeNames = new() { ["#s"] = "Status" }, // Status is a reserved word
ExpressionAttributeValues = new()
{
[":cid"] = new AttributeValue { S = customerId },
[":status"] = new AttributeValue { S = "Shipped" }
}
};
var response = await client.QueryAsync(request);
return response.Items.Select(i => i["OrderId"].S).ToList();
}
Note the ExpressionAttributeNames entry: Status is one of DynamoDB's many reserved words, and forgetting to alias it produces a confusing ValidationException.
Using DynamoDB in an AWS Lambda Function with C#
Here is a complete Lambda handler using the Lambda Annotations framework. The key detail for performance is that the client is created once in the constructor, not per invocation, so warm invocations reuse the HTTP connection.
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DataModel;
using Amazon.Lambda.Annotations;
using Amazon.Lambda.Annotations.APIGateway;
using Amazon.Lambda.Core;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
public class OrdersFunction
{
private static readonly IDynamoDBContext Context =
new DynamoDBContextBuilder()
.WithDynamoDBClient(() => new AmazonDynamoDBClient())
.Build();
[LambdaFunction]
[HttpApi(LambdaHttpMethod.Get, "/customers/{customerId}/orders")]
public async Task<IHttpResult> GetOrders(string customerId, ILambdaContext ctx)
{
var orders = await Context.QueryAsync<Order>(customerId).GetRemainingAsync();
ctx.Logger.LogInformation($"Returned {orders.Count} orders for {customerId}");
return HttpResults.Ok(orders);
}
[LambdaFunction]
[HttpApi(LambdaHttpMethod.Post, "/customers/{customerId}/orders")]
public async Task<IHttpResult> CreateOrder(string customerId, [FromBody] Order order)
{
order.CustomerId = customerId;
order.OrderId = $"ORD-{DateTime.UtcNow:yyyyMMddHHmmssfff}";
await Context.SaveAsync(order);
return HttpResults.Created($"/customers/{customerId}/orders/{order.OrderId}", order);
}
}
Grant the function's IAM role only the actions it needs (dynamodb:Query, dynamodb:PutItem) scoped to the table ARN. Least privilege is not just a security best practice; it also makes accidental Scans impossible.
DynamoDB C# Best Practices
- Design for access patterns first. List every query your app needs before you choose keys. Single-table design, where multiple entity types share one table with generic
PK/SKattributes, is the standard approach for serverless apps. - Use Global Secondary Indexes (GSIs) for alternative lookups, such as finding orders by status. Add
[DynamoDBGlobalSecondaryIndexHashKey("StatusIndex")]to the property and query withnew DynamoDBOperationConfig { IndexName = "StatusIndex" }. - Batch when you can.
CreateBatchWrite<T>()andCreateBatchGet<T>()reduce round trips and cost—up to 25 writes or 100 reads per call. - Use transactions for multi-item consistency.
TransactWriteItemsAsyncgives all-or-nothing writes across up to 100 items. - Enable TTL on a numeric epoch attribute to expire sessions, carts, or idempotency keys automatically for free.
- Prefer on-demand billing for unpredictable serverless traffic; switch to provisioned capacity with auto-scaling only when usage is steady and high enough for the savings to matter.
- Test locally with DynamoDB Local in Docker:
docker run -p 8000:8000 amazon/dynamodb-local, then setServiceURL = "http://localhost:8000"inAmazonDynamoDBConfig.
Common Pitfalls to Avoid
- Using Scan in production code paths. It works with 100 items and becomes a cost and latency disaster at 10 million.
- Creating a new client per request. Each
AmazonDynamoDBClientdoes credential resolution and TLS setup. Make it static or a singleton. - Ignoring pagination. A Query returns at most 1 MB per call.
GetRemainingAsync()handles this for you, but if you use the low-level client you must loop onLastEvaluatedKey. - Hot partitions. A partition key like
"GLOBAL"or today's date funnels all traffic to one partition. Choose keys with high cardinality. - Storing
decimalas string. The SDK storesdecimalas DynamoDB Number natively, butfloat/doublecan lose precision. Always usedecimalfor money. - Item size limit. Items are capped at 400 KB. Store large blobs in S3 and keep the key in DynamoDB.
- Forgetting reserved words.
Name,Status,Date,Countand dozens more requireExpressionAttributeNamesaliases.
Conclusion: Key Takeaways for DynamoDB with C#
Combining DynamoDB with C# gives you a database that scales exactly like your Lambda functions do, with no connection pools, no servers, and no schema migrations. The AWS SDK for .NET makes it approachable through the Object Persistence Model, while the low-level client is there when you need fine-grained control.
- Install
AWSSDK.DynamoDBv2and register a singleIAmazonDynamoDBandIDynamoDBContextfor the process lifetime. - Map entities with
[DynamoDBTable],[DynamoDBHashKey], and[DynamoDBRangeKey]; add[DynamoDBVersion]for optimistic locking. - Design your partition and sort keys around your queries so every hot path is a Query, never a Scan.
- Use batches, transactions, GSIs, and TTL to keep your code simple and your bill low.
- Test against DynamoDB Local before deploying, and lock down IAM permissions to the exact actions your function performs.
With these fundamentals in place, you are ready to build fast, cost-efficient serverless .NET applications on AWS. Start with a single table, model your access patterns, and let DynamoDB handle the scale.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment