After eight-plus years building enterprise applications on SQL Server and Oracle, DynamoDB was the first database that actually made me unlearn habits instead of just picking up new syntax. There's no JOIN, no stored procedures, and no "I'll fix the schema later" — the schema has to follow your access patterns from day one, or you end up fighting the database instead of using it. This is what I wish someone had told me before my first production table.
1. The Mental Shift: Access Patterns Before Schema
In SQL Server, you normalize first and figure out queries later —
that's just how relational modeling works, and EF Core's migrations
make iterating on the schema cheap. DynamoDB punishes that approach.
You define your access patterns — "get all orders for a customer,"
"get an order by ID," "get recent orders by status" — before you
design a single table, because every query has to be answerable by a
partition key and, optionally, a sort key. There's no ad-hoc
WHERE clause to bail you out later.
This is also why single-table design, which looks bizarre coming from a relational background, is actually the recommended pattern — cramming multiple entity types into one table with a generic partition/sort key scheme is normal in DynamoDB, not a hack.
2. Setting Up the .NET SDK
dotnet add package AWSSDK.DynamoDBv2
The SDK gives you two ways to work: the low-level
AmazonDynamoDBClient, which maps closely to the raw API
and gives you full control, and the high-level
DynamoDBContext, which maps POCOs the way EF Core maps
entities. I'd recommend starting with the high-level context for
anything CRUD-shaped, and dropping to the low-level client only when
you need fine control over consistency, conditional writes, or
transactions.
3. Modeling an Entity
using Amazon.DynamoDBv2.DataModel;
[DynamoDBTable("Orders")]
public class Order
{
[DynamoDBHashKey("CustomerId")]
public string CustomerId { get; set; }
[DynamoDBRangeKey("OrderId")]
public string OrderId { get; set; }
[DynamoDBProperty("Status")]
public string Status { get; set; }
[DynamoDBProperty("Amount")]
public decimal Amount { get; set; }
[DynamoDBProperty("CreatedAt")]
public DateTime CreatedAt { get; set; }
}
CustomerId as the partition key and OrderId
as the sort key means "all orders for a customer" is a cheap, fast
Query — exactly the access pattern we decided on before writing any
code. If I'd picked OrderId alone as the key, that same
query would require a full table Scan, which is the single most
common performance mistake I see from developers new to DynamoDB.
4. Reading and Writing Data
var context = new DynamoDBContext(client);
// Write
var order = new Order
{
CustomerId = "CUST-1001",
OrderId = "ORD-5001",
Status = "Pending",
Amount = 149.99m,
CreatedAt = DateTime.UtcNow
};
await context.SaveAsync(order);
// Read a single item
var fetched = await context.LoadAsync<Order>("CUST-1001", "ORD-5001");
// Query all orders for a customer — uses the partition key, fast and cheap
var query = context.QueryAsync<Order>("CUST-1001");
var results = await query.GetRemainingAsync();
5. Query vs. Scan — The Line Between Fast and Expensive
This is the comparison that matters more than anything else in this
post. Coming from SQL Server, it's tempting to treat
Scan like a SELECT * — it isn't, and
treating it that way is how a table that performed fine in testing
starts timing out and racking up cost in production.
| Operation | What it does | Cost behavior |
|---|---|---|
| Query | Uses the partition key (and optional sort key condition) to fetch a targeted set of items | Reads only the matching items — predictable and cheap |
| Scan | Reads every item in the table, then optionally filters | Billed for the entire table read, regardless of how few items match your filter |
If you find yourself writing a Scan with a FilterExpression
in production code, that's usually a sign your table's key design
doesn't match an access pattern you actually need — which means
going back to step 1, not reaching for a bigger read capacity.
6. Global Secondary Indexes (and Their Catch)
A Global Secondary Index (GSI) lets you query the same data by a
different key — for example, querying orders by Status
instead of CustomerId. This is the closest DynamoDB gets
to a SQL Server non-clustered index, but there's a catch that bit me
early on: GSIs are eventually consistent by default, and unlike the
base table, you can't request strongly consistent reads on a GSI at
all. If your application logic assumes "I just wrote this, so it
must be queryable immediately," design that assumption out before it
becomes a hard-to-reproduce bug report.
[DynamoDBGlobalSecondaryIndexHashKey("StatusIndex")]
public string Status { get; set; }
7. Capacity Modes
DynamoDB gives you two billing/throughput models:
- On-demand — no capacity planning, scales automatically, billed per request. Good for unpredictable or spiky traffic, and for getting a new feature into production without guessing at load.
- Provisioned (with auto-scaling) — you set read/write capacity units and let AWS scale within bounds. Usually cheaper once your traffic pattern is well understood, but requires tuning to avoid throttling during spikes.
My practical rule: start on-demand while a feature is new and its traffic is unknown, then switch to provisioned with auto-scaling once you have a few weeks of real traffic data to size it against. Flipping too early means guessing; staying on-demand forever usually costs more at steady, predictable volume.
Common Mistakes .NET Developers Make With DynamoDB
- Designing the table like a normalized SQL Server schema — one table per entity type — then being surprised by how many round trips and GSIs it takes to answer a single screen's worth of data.
- Using Scan with a filter instead of designing a Query-friendly key or GSI, usually because it "worked" in local testing with a handful of rows.
- Assuming GSI reads are as fresh as a base-table strongly consistent read — they're not, and there's no setting to make them so.
- Storing numbers as strings or vice versa inconsistently across items, which breaks sort-key range queries and comparisons in ways that don't show up until you query a wider range of data.
- Not setting a Time To Live (TTL) attribute on data that should expire — session tokens, temporary locks — and manually cleaning it up instead, which is exactly what DynamoDB's built-in TTL exists to avoid.
Final Thoughts
DynamoDB isn't "SQL Server without SQL" — treating it that way is how most of the pain starts. It's a genuinely different tool that rewards knowing your access patterns up front and punishes modeling by habit. For enterprise .NET teams used to relational design, the adjustment is real, but once it clicks, the performance and scaling characteristics are hard to argue with for the right workloads — high-traffic, predictable-access, read/write-heavy systems where a normalized schema was never buying you much anyway.