Skip to main content

AWS DynamoDB with C#: Complete .NET Tutorial (2026)

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/SK attributes, 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 with new DynamoDBOperationConfig { IndexName = "StatusIndex" }.
  • Batch when you can. CreateBatchWrite<T>() and CreateBatchGet<T>() reduce round trips and cost—up to 25 writes or 100 reads per call.
  • Use transactions for multi-item consistency. TransactWriteItemsAsync gives 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 set ServiceURL = "http://localhost:8000" in AmazonDynamoDBConfig.

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 AmazonDynamoDBClient does 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 on LastEvaluatedKey.
  • Hot partitions. A partition key like "GLOBAL" or today's date funnels all traffic to one partition. Choose keys with high cardinality.
  • Storing decimal as string. The SDK stores decimal as DynamoDB Number natively, but float/double can lose precision. Always use decimal for 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, Count and dozens more require ExpressionAttributeNames aliases.

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.DynamoDBv2 and register a single IAmazonDynamoDB and IDynamoDBContext for 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...