<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ DynamoDB - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ DynamoDB - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 25 May 2026 15:48:04 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/dynamodb/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Query Data in DynamoDB Using .Net ]]>
                </title>
                <description>
                    <![CDATA[ If you're coming to DynamoDB from a relational background, the first thing to understand is this: it's a completely different way of thinking. DynamoDB isn't a relational database, it's a NoSQL key-va ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-query-data-in-dynamodb-using-net/</link>
                <guid isPermaLink="false">69fa1ffca386d7f121b4955b</guid>
                
                    <category>
                        <![CDATA[ C# ]]>
                    </category>
                
                    <category>
                        <![CDATA[ DynamoDB ]]>
                    </category>
                
                    <category>
                        <![CDATA[ dotnet ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Grant Riordan ]]>
                </dc:creator>
                <pubDate>Tue, 05 May 2026 16:51:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/66b52b176a1b17f6b28d9822/93c4db14-f870-47d6-99c9-e0816d8b628b.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you're coming to DynamoDB from a relational background, the first thing to understand is this: it's a completely different way of thinking.</p>
<p>DynamoDB isn't a relational database, it's a NoSQL key-value and document store. You don't write arbitrary queries against your data. Instead, you design your tables around the specific access patterns your application needs.</p>
<p>DynamoDB is driven by your queries, not your data.</p>
<p>There's no need for joins or heavy normalisation. To get the performance DynamoDB is built for, model your data so it can be retrieved efficiently using keys – partition keys and sort keys – rather than relying on table scans or complex query logic.</p>
<p>If you try to use DynamoDB like SQL, it will fight you — and you will lose.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>There are a few things you'll need and some general knowledge that you should have to follow along most effectively here:</p>
<p><strong>AWS</strong></p>
<ul>
<li><p>An active AWS account with permissions to create and modify DynamoDB</p>
</li>
<li><p>Basic familiarity with the AWS Console (navigating services, not deep expertise)</p>
</li>
</ul>
<p><strong>C# / .NET</strong></p>
<ul>
<li><p>Comfortable with C#</p>
</li>
<li><p>Dependency injection</p>
</li>
<li><p>NuGet package management — you'll need to install <code>AWSSDK.DynamoDBv2</code></p>
</li>
</ul>
<p><strong>Databases (Conceptual)</strong></p>
<ul>
<li>(Optional) A working understanding of relational databases / SQL is actually helpful here — the article explicitly addresses readers coming from that background and explains the mental shift required</li>
</ul>
<p><strong>What you don't need</strong></p>
<ul>
<li><p>Prior DynamoDB experience — the article covers core concepts from scratch</p>
</li>
<li><p>Deep AWS infrastructure knowledge — IAM, VPCs, and so on aren't covered</p>
</li>
</ul>
<p><strong>Optional but useful</strong></p>
<ul>
<li><p>AWS CLI installed locally if you want to follow the AWS CLI examples directly</p>
</li>
<li><p>Terraform experience if following the infrastructure-as-code example (Terraform section can be skipped without losing context)</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table Of Contents</h2>
<ul>
<li><p><a href="#heading-core-dynamodb-concepts">Core DynamoDB Concepts</a></p>
<ul>
<li><p><a href="#heading-partition-key">Partition Key</a></p>
</li>
<li><p><a href="#heading-sort-key-optional">Sort Key (Optional)</a></p>
</li>
<li><p><a href="#heading-global-secondary-index-gsi">Global Secondary Index (GSI)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-synthetic-keys-the-old-way">Synthetic Keys — The Old Way</a></p>
</li>
<li><p><a href="#heading-multi-attribute-gsis-the-new-way">Multi-Attribute GSIs — The New Way</a></p>
<ul>
<li><p><a href="#heading-defining-a-multi-attribute-gsi">Defining a Multi-Attribute GSI</a></p>
</li>
<li><p><a href="#heading-query-rules-you-must-follow">Query Rules You Must Follow</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-c-sdk-options">C# SDK Options</a></p>
<ul>
<li><p><a href="#heading-low-level-client">Low-Level Client</a></p>
</li>
<li><p><a href="#heading-document-model">Document Model</a></p>
</li>
<li><p><a href="#heading-object-persistence-model">Object Persistence Model</a></p>
</li>
<li><p><a href="#heading-setting-up-the-context">Setting Up the Context</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-querying-a-multi-attribute-gsi-from-c">Querying a Multi-Attribute GSI from C</a></p>
</li>
<li><p><a href="#heading-query-vs-scan">Query vs Scan</a></p>
<ul>
<li><p><a href="#heading-query">Query</a></p>
</li>
<li><p><a href="#heading-scan">Scan</a></p>
</li>
<li><p><a href="#heading-when-is-a-scan-acceptable">When Is A Scan Acceptable?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-filter-expressions">Filter Expressions</a></p>
</li>
<li><p><a href="#heading-paging-results-and-user-interfaces">Paging Results and User Interfaces</a></p>
<ul>
<li><p><a href="#heading-how-it-works-in-dynamodb">How It Works in DynamoDB</a></p>
</li>
<li><p><a href="#heading-how-this-works-in-the-dynamodb-c-sdk">How This Works In The DynamoDB C# SDK</a></p>
</li>
<li><p><a href="#heading-manual-pagination-the-right-approach-for-uis">Manual Pagination — The Right Approach For UIs</a></p>
</li>
<li><p><a href="#heading-what-about-go-to-to-page-7-navigation">What About "go to to page 7" Navigation?</a></p>
</li>
<li><p><a href="#heading-the-filterexpression-trap">The FilterExpression Trap</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-final-thoughts-amp-conclusion">Final Thoughts &amp; Conclusion</a></p>
</li>
</ul>
<h2 id="heading-core-dynamodb-concepts">Core DynamoDB Concepts</h2>
<p>DynamoDB is built around a few core concepts that directly influence how you query and structure your data.</p>
<p>Remember: DynamoDB is driven by how you retrieve your data, not by the shape of the data itself.</p>
<h3 id="heading-partition-key">Partition Key</h3>
<ul>
<li><p>Required for every query</p>
</li>
<li><p>Required to create a table – each table must have a partition key</p>
</li>
<li><p>Determines data distribution and how items are stored</p>
</li>
</ul>
<p>Data in DynamoDB is physically distributed across partitions. The partition key decides where a given item lives, which is why it's critical for both performance and scalability.</p>
<p>You can think of a partition key like a filing cabinet drawer label: it tells DynamoDB which drawer to open so it can go straight to your data without searching every drawer.</p>
<p>Partition keys are typically strings or numbers.</p>
<h3 id="heading-sort-key-optional">Sort Key (Optional)</h3>
<p>Also known as the <strong>Range Key</strong>.</p>
<ul>
<li>Enables range queries (<code>between</code>, <code>begins_with</code>, and so on)</li>
</ul>
<p>When you add a sort key, items with the same partition key are grouped together and ordered by the sort key. For string sort keys, ordering is lexicographical (dictionary order). For numeric sort keys, ordering is numeric (ascending).</p>
<p>This has an important effect when working with dates. If you store dates as strings in a non-ISO format (for example, <code>DD/MM/YYYY</code>), they won't sort in chronological order. For example, the resulting sorted items would look like this:</p>
<pre><code class="language-plaintext">01/01/2026
01/02/2026
01/03/2026
08/01/2026
15/02/2026
</code></pre>
<p>Here, all dates starting with <code>01</code> are grouped together, even though they span different months. This breaks range queries and ordering.</p>
<p>To avoid this, use either:</p>
<ul>
<li><p><strong>ISO 8601 format</strong> (<code>YYYY-MM-DD</code>), which sorts correctly as a string</p>
</li>
<li><p><strong>Unix timestamps</strong> (typically milliseconds since January 1st, 1970), which sort numerically and correctly</p>
</li>
</ul>
<p>Both approaches ensure your data is ordered correctly and can be queried efficiently. This is commonly used for timestamps, versioning, or logical groupings (for example, <code>ORDER#2024-01</code>).</p>
<h3 id="heading-global-secondary-index-gsi">Global Secondary Index (GSI)</h3>
<p>A Global Secondary Index (GSI) allows you to query your data using a different partition key and optional sort key than your base table. This is how you support additional access patterns without redesigning your primary key schema.</p>
<p>For example, if your base table is keyed by <code>UserId</code>, but you also need to query by <code>OrderId</code>, a GSI makes that possible.</p>
<h4 id="heading-projection-types">Projection Types</h4>
<p>A GSI doesn't have to include all attributes from the base table. When configuring your GSI you can choose:</p>
<ul>
<li><p><strong>ALL</strong> — all attributes are projected</p>
</li>
<li><p><strong>KEYS_ONLY</strong> — only index and primary keys</p>
</li>
<li><p><strong>INCLUDE</strong> — a subset of selected attributes</p>
</li>
</ul>
<p>Choosing the right projection helps reduce storage and read costs. For example, for an orders view you could project only <code>orderNumber</code>, <code>dateOrdered</code>, and <code>cost</code> rather than all other attributes which aren't needed.</p>
<h4 id="heading-important-considerations">Important Considerations</h4>
<p><strong>Additional cost:</strong> GSIs consume their own read/write capacity and storage in addition to your base table's costs. Using <code>ProjectionType = INCLUDE</code> or <code>KEYS_ONLY</code> instead of <code>ALL</code> reduces the storage cost of the GSI since less data is duplicated into the index, which can offset the additional read/write cost.</p>
<p><strong>Eventual consistency:</strong> In DynamoDB, when you write directly to the base table you have the option to perform a strongly consistent read immediately after — meaning you're guaranteed to get the latest data. GSIs don'r support this. GSI reads are always eventually consistent.</p>
<p>When a customer places an order, DynamoDB writes that order to your main Orders table. It then replicates that change to any GSIs asynchronously in the background. During that brief window (typically milliseconds), a query against a GSI may not return the newly written order yet.</p>
<p>A real-world example of where this can catch you out:</p>
<ol>
<li><p>Customer places an order which is written to the Orders table</p>
</li>
<li><p>User is redirected to the "Your Orders" page</p>
</li>
<li><p>"Your Orders" page queries a GSI for all orders by this customer</p>
</li>
<li><p>New order doesn't appear yet — GSI replication is still in progress</p>
</li>
<li><p>Customer refreshes the page 50ms later</p>
</li>
<li><p>Order now appears</p>
</li>
</ol>
<p>For most queries — browsing a product catalogue, viewing order history, filtering by status — this is completely acceptable and unnoticeable to the user. Where it matters is when your application writes a record and immediately queries a GSI for that same record. In this scenario you have a couple of options:</p>
<ul>
<li><p>Query the base table directly after a write using a strongly consistent read, rather than the GSI</p>
</li>
<li><p>Pass the order data directly to the UI from the write response, without a follow-up query at all — the cleanest solution in most cases</p>
</li>
</ul>
<p><strong>Write amplification:</strong> Every write to the base table may also write to one or more GSIs. GSIs are powerful, but they're not free. Overusing them is often a sign that your primary access patterns weren't well defined upfront.</p>
<p><strong>Note:</strong> DynamoDB allows a maximum of 20 GSIs per table by default, though this can be increased via an AWS service limit request.</p>
<h2 id="heading-synthetic-keys-the-old-way">Synthetic Keys — The Old Way</h2>
<p>Before we look at multi-attribute GSIs, it's worth understanding the pattern they replace — because you'll encounter it in existing DynamoDB codebases.</p>
<p>Imagine you want to query orders by both status and date — for example, all "pending" orders placed in the last 30 days. Previously, DynamoDB GSIs only supported a single attribute as the partition key and a single attribute as the sort key. To filter on multiple attributes you had to combine them into a single synthetic attribute:</p>
<pre><code class="language-csharp">[DynamoDBTable("Orders")]
public class OrderDto
{
    [DynamoDBHashKey("customerId")]
    public string CustomerId { get; set; }

    [DynamoDBRangeKey("createdAt")]
    public long CreatedAt { get; set; }

    [DynamoDBProperty("orderId")]
    public string OrderId { get; set; }

    [DynamoDBProperty("status")]
    public string Status { get; set; }

    // Synthetic key — manually constructed before saving
    [DynamoDBProperty("statusDate")]
    public string StatusDate { get; set; } // e.g. "PENDING#2025-11-01"
}
</code></pre>
<p>Constructing this value before saving the record:</p>
<pre><code class="language-csharp">order.StatusDate = $"{order.Status}#{order.CreatedAt:yyyy-MM-dd}";
</code></pre>
<p>Then create a GSI on <code>statusDate</code> as the partition key, allowing you to query:</p>
<pre><code class="language-csharp">var results = await _context.QueryAsync&lt;OrderDto&gt;(
    "PENDING#2025-11-01",
    config // IndexName = "statusDate-index"
).GetRemainingAsync();
</code></pre>
<p>This worked, but came with real downsides:</p>
<ul>
<li><p><strong>Brittle</strong> — every developer writing to the table must know about and correctly format the synthetic key</p>
</li>
<li><p><strong>Hard to query ranges</strong> — filtering all pending orders across a date range required careful <code>begins_with</code> or <code>between</code> conditions on a concatenated string</p>
</li>
<li><p><strong>Maintenance overhead</strong> — if status values change, every existing record needs updating</p>
</li>
<li><p><strong>Invisible in the schema</strong> — a new developer has no idea what <code>statusDate</code> means without documentation</p>
</li>
<li><p><strong>Backfilling</strong> — adding a new synthetic-key GSI to an existing table meant updating every existing record to populate the new attribute via a script re-processing the existing items.</p>
</li>
</ul>
<h2 id="heading-multi-attribute-gsis-the-new-way">Multi-Attribute GSIs — The New Way</h2>
<p>On November 19, 2025, AWS <a href="https://aws.amazon.com/about-aws/whats-new/2025/11/amazon-dynamodb-multi-attribute-composite-keys-global-secondary-indexes/">announced multi-attribute composite keys for GSIs</a>. You can now define a GSI partition key or sort key comprised of up to <strong>4 attributes each</strong> — <strong>8 attributes in total</strong> across the partition and sort key combined.</p>
<p>A few important things to note:</p>
<ul>
<li><p><strong>GSIs only:</strong> This applies to GSIs only — your base table primary key structure is unchanged, still a single partition key and an optional single sort key.</p>
</li>
<li><p><strong>DynamoDB handles composition internally:</strong> You don't concatenate values yourself. DynamoDB hashes the partition key attributes together for data distribution, and maintains hierarchical sort order across the sort key attributes.</p>
</li>
<li><p><strong>Strict query rules still apply:</strong> You must supply <strong>all</strong> partition key attributes with equality conditions when querying. Sort key attributes must be queried <strong>left-to-right</strong> in the order they were defined — you can't skip attributes.</p>
</li>
<li><p><strong>No backfilling required.</strong> When you add a multi-attribute GSI to an existing table, DynamoDB automatically indexes all existing items using their natural attributes.</p>
</li>
<li><p><strong>No additional cost</strong> beyond standard GSI pricing.</p>
</li>
</ul>
<p>The model stays clean — no synthetic attributes needed:</p>
<pre><code class="language-csharp">[DynamoDBTable("Orders")]
public class OrderDto
{
    [DynamoDBHashKey("customerId")]
    public string CustomerId { get; set; }

    [DynamoDBRangeKey("createdAt")]
    public long CreatedAt { get; set; }

    [DynamoDBProperty("orderId")]
    public string OrderId { get; set; }

    [DynamoDBProperty("status")]
    public string Status { get; set; }

    [DynamoDBProperty("total")]
    public decimal Total { get; set; }
}
</code></pre>
<h3 id="heading-defining-a-multi-attribute-gsi">Defining a Multi-Attribute GSI</h3>
<p>You can create the GSI via the AWS Console (select the attributes you want in order), via Terraform (requires AWS provider v6.29.0+), or via the AWS CLI.</p>
<p>The key concept to understand: you provide <strong>multiple</strong> <code>HASH</code> <strong>entries</strong> for the composite partition key and <strong>multiple</strong> <code>RANGE</code> <strong>entries</strong> for the composite sort key, in the exact order they should be evaluated. DynamoDB treats them internally as one composite partition key and one composite sort key.</p>
<p>Here's an AWS CLI example — a GSI on Orders with a composite partition key (<code>customerId</code> + <code>status</code>) and a single-attribute sort key (<code>createdAt</code>):</p>
<pre><code class="language-bash">aws dynamodb update-table \
  --table-name Orders \
  --attribute-definitions \
    AttributeName=customerId,AttributeType=S \
    AttributeName=status,AttributeType=S \
    AttributeName=createdAt,AttributeType=N \
  --global-secondary-index-updates \
  "[{\"Create\":{
    \"IndexName\":\"customerStatus-createdAt-index\",
    \"KeySchema\":[
      {\"AttributeName\":\"customerId\",\"KeyType\":\"HASH\"},
      {\"AttributeName\":\"status\",\"KeyType\":\"HASH\"},
      {\"AttributeName\":\"createdAt\",\"KeyType\":\"RANGE\"}
    ],
    \"Projection\":{\"ProjectionType\":\"ALL\"}
  }}]"
</code></pre>
<p>The two <code>HASH</code> entries here are valid. They define a composite partition key of <code>(customerId, status)</code>. This is the syntax AWS introduced specifically for multi-attribute GSIs. It would have been rejected before November 2025.</p>
<p>Here's a Terraform example (AWS provider v6.29.0+):</p>
<pre><code class="language-hcl">global_secondary_index {
  name            = "customerStatus-createdAt-index"
  projection_type = "ALL"

  key_schema {
    attribute_name = "customerId"
    key_type       = "HASH"
  }

  key_schema {
    attribute_name = "status"
    key_type       = "HASH"
  }

  key_schema {
    attribute_name = "createdAt"
    key_type       = "RANGE"
  }
}
</code></pre>
<h3 id="heading-query-rules-you-must-follow">Query Rules You Must Follow</h3>
<p>The flexibility gain with multi-attribute GSIs is real, but the query constraints are not the same as SQL. Two rules matter most:</p>
<h4 id="heading-1-partition-key-attributes-must-all-be-supplied-with-equality-only">1. Partition key attributes must all be supplied, with equality only.</h4>
<p>For a partition key of <code>(customerId, status)</code>:</p>
<p><strong>Valid</strong>:</p>
<pre><code class="language-plaintext">customerId = 'C123' AND status = 'PENDING'
</code></pre>
<p><strong>Invalid</strong> — missing <code>status</code>:</p>
<pre><code class="language-plaintext">customerId = 'C123'
</code></pre>
<p><strong>Invalid</strong> — inequality on a partition key attribute:</p>
<pre><code class="language-plaintext">customerId = 'C123' AND status &gt; 'P'
</code></pre>
<h4 id="heading-2-sort-key-attributes-must-be-queried-left-to-right-with-inequality-only-as-the-final-condition">2. Sort key attributes must be queried left-to-right, with inequality only as the final condition.</h4>
<p>For a sort key of <code>(tournamentRound, rank, matchId)</code>:</p>
<p><strong>Valid</strong>:</p>
<pre><code class="language-plaintext">tournamentRound = 'SEMIFINALS'

tournamentRound = 'SEMIFINALS' AND rank = 'UPPER'

tournamentRound = 'SEMIFINALS' AND rank = 'UPPER' AND matchId = 'match-002'

tournamentRound = 'SEMIFINALS' AND rank = 'UPPER' AND matchId &gt; 'match-001'

tournamentRound BETWEEN 'QUARTERFINALS' AND 'SEMIFINALS'
</code></pre>
<p><strong>Invalid</strong> — skipping the first attribute:</p>
<pre><code class="language-plaintext">rank = 'UPPER'
</code></pre>
<p><strong>Invalid</strong> — leaving a gap (skipping <code>bracket</code>)</p>
<pre><code class="language-plaintext">tournamentRound = 'SEMIFINALS' AND matchId = 'match-002'
</code></pre>
<p><strong>Invalid</strong> — adding a condition after an inequality:</p>
<pre><code class="language-plaintext">tournamentRound &gt; 'QUARTERFINALS' AND rank = 'UPPER'
</code></pre>
<p><strong>Design tip:</strong> Order your sort key attributes from most general to most specific for example, <code>tournamentRound → rank → matchId</code>). This maximises query flexibility, since each left-to-right prefix becomes a valid query pattern.</p>
<h4 id="heading-going-deeper">Going deeper:</h4>
<p>AWS publishes a detailed design pattern guide with worked examples for time-series data, e-commerce orders, hierarchical organisation data, and multi-tenant SaaS platforms. The examples use the JavaScript SDK, but the schema design principles apply regardless of language. The article can be found <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.DesignPattern.MultiAttributeKeys.html">here</a>.</p>
<h2 id="heading-c-sdk-options">C# SDK Options</h2>
<p>When working with DynamoDB in C#, the <code>AWSSDK.DynamoDBv2</code> NuGet package gives you three different ways to interact with your tables, each with different levels of abstraction.</p>
<h3 id="heading-low-level-client">Low-Level Client</h3>
<pre><code class="language-csharp">var client = new AmazonDynamoDBClient();
</code></pre>
<p>The <code>AmazonDynamoDBClient</code> gives you full control over every aspect of your DynamoDB interactions. You construct requests manually, specifying every attribute, condition, and configuration explicitly.</p>
<pre><code class="language-csharp">var request = new QueryRequest
{
    TableName = "Orders",
    KeyConditionExpression = "customerId = :customerId",
    ExpressionAttributeValues = new Dictionary&lt;string, AttributeValue&gt;
    {
        { ":customerId", new AttributeValue { S = "customer-123" } }
    }
};

var response = await client.QueryAsync(request);
</code></pre>
<p>This is the most verbose approach, but nothing is hidden from you. You can see exactly what's being sent to DynamoDB, which makes it easier to debug, optimise, and understand exactly what Read Capacity Units (RCUs) you're consuming. It's also the most flexible — anything DynamoDB supports, you can do here.</p>
<p><strong>When to use it:</strong><br>When you need fine-grained control, are doing something complex, or want full visibility into your queries.</p>
<h3 id="heading-document-model">Document Model</h3>
<pre><code class="language-csharp">var client = new AmazonDynamoDBClient();
var table = Table.LoadTable(client, "Orders");
</code></pre>
<p>The Document Model sits a level above the low-level client. Rather than working with raw <code>AttributeValue</code> types, you work with <code>Document</code> objects which feel more like JSON — familiar to most .NET developers.</p>
<pre><code class="language-csharp">var filter = new QueryFilter("customerId", QueryOperator.Equal, "customer-123");
var search = table.Query(filter);
var documents = await search.GetRemainingAsync();

// access the data
foreach (var doc in documents)
{
    Console.WriteLine(doc["orderId"].AsString());
    Console.WriteLine(doc["total"].AsDecimal());
}
</code></pre>
<p>Less boilerplate than the low-level client, but you're still working with loosely typed <code>Document</code> objects rather than your own C# classes. There's no mapping to strongly typed models out of the box.</p>
<p><strong>When to use it:</strong><br>Useful for dynamic or loosely structured data where you don't want to define a fixed model, or for quick tooling and scripts.</p>
<h3 id="heading-object-persistence-model">Object Persistence Model</h3>
<p>The Object Persistence Model is the highest level of abstraction and the most natural fit for typical .NET development.</p>
<p>You decorate your C# classes with attributes, and the <code>DynamoDBContext</code> handles serialisation and deserialisation automatically — similar to an ORM like Entity Framework.</p>
<pre><code class="language-csharp">[DynamoDBTable("Orders")]
public class OrderRecord
{
    [DynamoDBHashKey("customerId")]
    public string CustomerId { get; set; }

    [DynamoDBRangeKey("createdAt")]
    public long CreatedAt { get; set; }

    [DynamoDBProperty("orderId")]
    public string OrderId { get; set; }

    [DynamoDBProperty("total")]
    public decimal Total { get; set; }

    [DynamoDBProperty("status")]
    public string Status { get; set; }
}
</code></pre>
<p>Querying feels clean and strongly typed:</p>
<pre><code class="language-csharp">var orders = await dbContext
    .QueryAsync&lt;OrderRecord&gt;("customer-123")
    .GetRemainingAsync();
</code></pre>
<p>The trade-off is that the abstraction hides some important details: you don't always see exactly what's being sent to DynamoDB under the hood, which can make debugging and performance optimisation harder.</p>
<h3 id="heading-setting-up-the-context">Setting Up the Context</h3>
<p>When creating the db context there are a couple of options:</p>
<h4 id="heading-option-1-using-dependency-injection">Option 1 — using Dependency Injection:</h4>
<pre><code class="language-csharp">// Program.cs
builder.Services.AddSingleton&lt;IAmazonDynamoDB, AmazonDynamoDBClient&gt;();

builder.Services.AddSingleton&lt;IDynamoDBContext&gt;(sp =&gt;
{
    var client = sp.GetRequiredService&lt;IAmazonDynamoDB&gt;();
    return new DynamoDBContext(client);
});

// Then in repository / service, inject IDynamoDBContext
public class OrderRepository
{
    private readonly IDynamoDBContext _context;

    public OrderRepository(IDynamoDBContext context)
    {
        _context = context;
    }
}
</code></pre>
<h4 id="heading-option-2-register-amazondynamodbclient-only-and-instantiate-the-context-per-operation">Option 2 — register <code>AmazonDynamoDBClient</code> only, and instantiate the context per operation:</h4>
<pre><code class="language-csharp">// Program.cs
builder.Services.AddSingleton&lt;IAmazonDynamoDB, AmazonDynamoDBClient&gt;();
</code></pre>
<p>Then:</p>
<pre><code class="language-csharp">public class OrderRepository
{
    private readonly IAmazonDynamoDB _client;

    public OrderRepository(IAmazonDynamoDB client)
    {
        _client = client;
    }

    public async Task&lt;List&lt;OrderDto&gt;&gt; GetOrdersAsync(string customerId)
    {
        var context = new DynamoDBContext(_client); // lightweight to instantiate
        return await context.QueryAsync&lt;OrderDto&gt;(customerId).GetRemainingAsync();
    }
}
</code></pre>
<p><strong>Which is better?</strong> Option 1 is cleaner and more testable — you can mock <code>IDynamoDBContext</code> in unit tests easily. Option 2 is also valid since <code>DynamoDBContext</code> is lightweight to instantiate, but you lose the ability to mock it cleanly.</p>
<p><strong>When to use Object Persistence:</strong> the recommended approach for most .NET applications. Clean, strongly typed, and fits naturally into existing C# codebases.</p>
<h2 id="heading-querying-a-multi-attribute-gsi-from-c">Querying a Multi-Attribute GSI From C#</h2>
<p>At the time of writing, the <code>DynamoDBContext.QueryAsync&lt;T&gt;</code> convenience overloads don't support multi-attribute GSI key conditions directly — you need to use the low-level client (<code>IAmazonDynamoDB</code>) and pass a <code>KeyConditionExpression</code>. The good news is the deserialisation back to your typed model is still straightforward.</p>
<p>Here's a query against a GSI with a composite partition key of <code>(customerId, status)</code> and a sort key of <code>createdAt</code>, returning all pending orders for a customer since a given date:</p>
<pre><code class="language-csharp">public async Task&lt;List&lt;OrderDto&gt;&gt; GetOrdersByStatusSinceAsync(
    string customerId,
    string status,
    long fromDate)
{
    var request = new QueryRequest
    {
        TableName = "Orders",
        IndexName = "customerStatus-createdAt-index",
        KeyConditionExpression =
            "customerId = :customerId " +
            "AND #status = :status " +           // #status because 'status' is a reserved word
            "AND createdAt &gt; :fromDate",
        ExpressionAttributeNames = new Dictionary&lt;string, string&gt;
        {
            { "#status", "status" }
        },
        ExpressionAttributeValues = new Dictionary&lt;string, AttributeValue&gt;
        {
            { ":customerId", new AttributeValue { S = customerId } },
            { ":status",     new AttributeValue { S = status } },
            { ":fromDate",   new AttributeValue { N = fromDate.ToString() } }
        },
        ScanIndexForward = false // reverse sort key order — newest first, since sort key is a timestamp
    };

    var response = await _client.QueryAsync(request);

    // manually deserialise back to OrderDto using the DynamoDBContext
    return _context.FromDocuments&lt;OrderDto&gt;(
        response.Items.Select(Document.FromAttributeMap)
    ).ToList();
}
</code></pre>
<p>Looking at the code above, notice that:</p>
<ul>
<li><p>Both partition key attributes (<code>customerId</code> and <code>status</code>) are supplied with equality — this is required.</p>
</li>
<li><p>The sort key (<code>createdAt</code>) uses an inequality <code>&gt;</code> (greater than) as the final condition, which is allowed.</p>
</li>
<li><p>No synthetic string construction, no brittle formatting conventions, no backfilling existing records.</p>
</li>
</ul>
<p>If you're working on an existing codebase that uses synthetic keys, it's worth evaluating whether migrating to multi-attribute GSIs makes sense. The backfilling problem that made migrations painful before is gone, DynamoDb multi-attribute GSIs handle it automatically.</p>
<h2 id="heading-query-vs-scan">Query vs Scan</h2>
<p>This is one of the most important concepts to understand when working with DynamoDB — and one of the most common sources of performance and cost problems.</p>
<h3 id="heading-query">Query</h3>
<p>A <code>Query</code> retrieves items using the partition key, and optionally narrows results using the sort key. DynamoDB knows exactly which partition to look in, reads only the relevant items, and returns them efficiently.</p>
<pre><code class="language-csharp">// Get all orders for a customer
var orders = await _context
    .QueryAsync&lt;OrderDto&gt;("customer-123")
    .GetRemainingAsync();
</code></pre>
<p>You can narrow further using a sort key condition — for example, all orders placed in the last 30 days:</p>
<pre><code class="language-csharp">var thirtyDaysAgo = DateTimeOffset.UtcNow.AddDays(-30).ToUnixTimeMilliseconds();

var orders = await _context.QueryAsync&lt;OrderDto&gt;(
    "customer-123",
    QueryOperator.GreaterThan,
    new List&lt;object&gt; { thirtyDaysAgo }
).GetRemainingAsync();
</code></pre>
<p>Queries are fast and cheap — you only pay RCUs for the records actually read.</p>
<h3 id="heading-scan">Scan</h3>
<p>A <code>Scan</code> reads every single item in the table, then filters the results. It doesn't use keys or indexes — it brute-forces through everything.</p>
<pre><code class="language-csharp">var conditions = new List&lt;ScanCondition&gt;
{
    new ScanCondition("status", ScanOperator.Equal, "pending")
};

var orders = await _context
    .ScanAsync&lt;OrderDto&gt;(conditions)
    .GetRemainingAsync();
</code></pre>
<p>This works — but on a table with 10 million orders, DynamoDB reads all 10 million records and then filters down to the pending ones. You pay RCUs for every single record read, not just the ones returned.</p>
<p><strong>Important:</strong> Scans should be avoided in production for large tables. They're slow, expensive, and get worse as your table grows.</p>
<p>The difference visualised:</p>
<pre><code class="language-plaintext">Query:
Table [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■]
       └── Jump straight to partition "customer-123"
               └── Read only these items — cheap

Scan:
Table [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■]
       └── Read every single item — expensive
               └── Then discard non-matching items
</code></pre>
<h3 id="heading-when-is-a-scan-acceptable">When Is A Scan Acceptable?</h3>
<p>Scans aren't always wrong — there are legitimate use cases:</p>
<ul>
<li><p><strong>Small tables</strong> — a lookup table with 50 items, a scan is perfectly fine</p>
</li>
<li><p><strong>One-off data migrations or admin scripts</strong> — not user-facing, run occasionally</p>
</li>
<li><p><strong>Development and debugging</strong> — scanning locally or against a small dataset</p>
</li>
</ul>
<p><strong>Rule of thumb:</strong> if it's a user-facing query on a growing table, it should be a Query, not a Scan.</p>
<p>Coming from SQL, developers often reach for a Scan because it feels like:</p>
<pre><code class="language-sql">SELECT * FROM orders WHERE status = 'pending'
</code></pre>
<p>In SQL with a good index, that's fine. In DynamoDB, without a GSI on <code>status</code>, that's a full table scan every time. The solution is to design a GSI for the access patterns you need — exactly what we covered in the GSI section earlier.</p>
<h2 id="heading-filter-expressions">Filter Expressions</h2>
<p>Both Query and Scan support an optional <code>FilterExpression</code> — a condition applied <em>after</em> DynamoDB has read the records but <em>before</em> they're returned to you. It looks superficially like a SQL <code>WHERE</code> clause, and that's exactly the trap.</p>
<pre><code class="language-csharp">var request = new QueryRequest
{
    TableName = "Orders",
    KeyConditionExpression = "customerId = :customerId",
    FilterExpression = "#status = :status",
    ExpressionAttributeNames = new Dictionary&lt;string, string&gt;
    {
        { "#status", "status" }
    },
    ExpressionAttributeValues = new Dictionary&lt;string, AttributeValue&gt;
    {
        { ":customerId", new AttributeValue { S = "customer-123" } },
        { ":status",     new AttributeValue { S = "pending" } }
    }
};
</code></pre>
<p>The critical thing to understand: <code>FilterExpression</code> does <strong>not</strong> reduce the cost of the query. DynamoDB still reads every record first, charges you RCUs for all of them, and only then discards the ones that don't match the filter.</p>
<p>For a Query, that means every record matched by the <code>KeyConditionExpression</code>. For a Scan, that means every record in the table.</p>
<p>It's a convenience for trimming the <em>response payload</em>, not a tool for efficient querying. If you find yourself reaching for <code>FilterExpression</code> to support a real access pattern, that's a signal you need a GSI instead.</p>
<h2 id="heading-paging-results-and-user-interfaces">Paging Results and User Interfaces</h2>
<p>Pagination is one of the most misunderstood aspects of DynamoDB, especially if you're coming from a SQL background.</p>
<p>In SQL you might write:</p>
<pre><code class="language-sql">SELECT * FROM orders LIMIT 10 OFFSET 20
</code></pre>
<p>DynamoDB doesn't work like this. There is no concept of <code>OFFSET</code> or page numbers. Instead, DynamoDB uses cursor-based pagination via a <code>LastEvaluatedKey</code>.</p>
<h3 id="heading-how-it-works-in-dynamodb">How It Works In DynamoDB</h3>
<p>DynamoDB returns a maximum of 1MB of data per request. If your results exceed 1MB, DynamoDB returns a <code>LastEvaluatedKey</code> — a pointer to where it stopped reading. Pass this back in to the next request to continue from that position. When no <code>LastEvaluatedKey</code> is returned, you've reached the end of the data.</p>
<h3 id="heading-how-this-works-in-the-dynamodb-c-sdk">How This Works In The DynamoDB C# SDK</h3>
<p>The SDK's <code>GetRemainingAsync()</code> method handles pagination automatically, it keeps making requests until there is no <code>LastEvaluatedKey</code> left, returning everything as a single list:</p>
<pre><code class="language-csharp">// Handles all pages automatically — but loads everything into memory
var orders = await _context
    .QueryAsync&lt;OrderDto&gt;("customer-123")
    .GetRemainingAsync();
</code></pre>
<p>This is convenient but dangerous on large datasets. If a customer has 50,000 orders, you're loading all 50,000 into memory in one go.</p>
<h3 id="heading-manual-pagination-the-right-approach-for-uis">Manual Pagination — The Right Approach For UIs</h3>
<p>For a UI with "load more" or "next/previous" navigation, control pagination manually using <code>GetNextSetAsync()</code>:</p>
<pre><code class="language-csharp">// ---- Paging Model ----
public class PagedResult&lt;T&gt;
{
    public List&lt;T&gt; Items { get; set; }
    public string? PaginationToken { get; set; }
}

// ---- Repository Method ----
public async Task&lt;PagedResult&lt;OrderDto&gt;&gt; GetOrdersPageAsync(
    string customerId,
    string? paginationToken = null)
{
    var config = new DynamoDBOperationConfig
    {
        BackwardQuery = true // reverse sort key order — newest first if sort key is a timestamp
    };

    var search = _context.QueryAsync&lt;OrderDto&gt;(customerId, config);

    if (paginationToken != null)
        search.PaginationToken = paginationToken;

    var items = await search.GetNextSetAsync(25); // fetch exactly 25 records

    return new PagedResult&lt;OrderDto&gt;
    {
        Items = items,
        PaginationToken = search.PaginationToken // null if no more pages
    };
}
</code></pre>
<p>The <code>PaginationToken</code> is the SDK's serialised representation of the <code>LastEvaluatedKey</code> — pass it directly to the client as a string and receive it back on the next request.</p>
<h3 id="heading-what-about-go-to-to-page-7-navigation">What About "go to to page 7" Navigation?</h3>
<p>This isn't possible in DynamoDB. The <code>LastEvaluatedKey</code> is a position cursor, to reach page 7 you'd have to paginate through pages 1 to 6 first to obtain the correct cursor placement.</p>
<p>For most modern UIs this isn't a problem. Infinite scroll and "load more" patterns map naturally to cursor-based pagination.</p>
<h3 id="heading-the-filterexpression-trap">The <code>FilterExpression</code> Trap</h3>
<p>We've already seen that <code>FilterExpression</code> is a poor substitute for a well-designed GSI. Pagination is where it goes from "wasteful" to actively broken.</p>
<p>DynamoDB's pagination works in two stages when a <code>FilterExpression</code> is involved:</p>
<ol>
<li><p>Read records until the 1MB limit is reached</p>
</li>
<li><p>Apply the <code>FilterExpression</code>, discarding non-matching records</p>
</li>
</ol>
<p>The <code>LastEvaluatedKey</code> is generated <strong>after step 1 — before filtering</strong>. So DynamoDB can return a <code>LastEvaluatedKey</code> implying there are more results, even if the filtered page returned only a handful of records.</p>
<p>With 1,000 orders where only 50 are "pending":</p>
<pre><code class="language-plaintext">Page 1: Read 200 records → filter applied → 3 "pending" returned + LastEvaluatedKey

Page 2: Read 200 records → filter applied → 1 "pending" returned + LastEvaluatedKey

Page 3: Read 200 records → filter applied → 0 "pending" returned + LastEvaluatedKey

...and so on until all 1,000 records are read
</code></pre>
<p><strong>Important:</strong> You pay RCUs for every record <strong>read</strong>, NOT every record <strong>returned</strong>.</p>
<p>The <code>Limit</code> parameter doesn't rescue you here. <code>GetNextSetAsync(25)</code> caps the records <em>read</em> before filtering, not the records <em>returned</em>. You can read 25, filter down to 3, and still get a <code>LastEvaluatedKey</code> back, meaning your "page size of 25" actually returns somewhere between 0 and 25 results, unpredictably.</p>
<p>The real fix isn't a smarter pagination strategy, it's removing the <code>FilterExpression</code> entirely. Design a GSI keyed on the attribute you're filtering by (here, <code>status</code>, or a multi-attribute GSI with <code>status</code> in the partition key). DynamoDB then reads only the matching records directly, <code>Limit</code> caps what you actually want, and pagination behaves predictably.</p>
<h2 id="heading-final-thoughts-amp-conclusion">Final Thoughts &amp; Conclusion</h2>
<p>DynamoDB rewards you for designing around your access patterns up front, and punishes you for pretending it's SQL. The two biggest shifts from a relational mindset are:</p>
<ol>
<li><p><strong>Queries are the schema.</strong> You model tables, keys, and GSIs around the queries you need to run. You don't normalise and figure out queries later.</p>
</li>
<li><p><strong>Keys do the work.</strong> The Query operation is fast and cheap precisely because it uses the partition key to jump straight to the right data. Scans read everything, and they get worse as your data grows.</p>
</li>
</ol>
<p>The November 2025 multi-attribute GSI release is a genuinely welcome change, and is a huge improvement to the AWS resource. It removes one of the most painful ergonomic issues with DynamoDB, synthetic key construction and backfilling, without loosening the constraints that make DynamoDB fast.</p>
<p>The query rules (all partition-key attributes supplied, sort-key attributes queried left-to-right) stay exactly the same. What you gain is cleaner, typed, natural data models and the ability to add new access patterns to existing tables without a data migration.</p>
<p>For new projects, my recommendation is to use multi-attribute GSIs by default. For existing codebases built on synthetic keys, evaluate whether a migration makes sense, the painful part of such migrations is now gone.</p>
<p>As always if you want to discuss this further, or hear about my other articles drop me a follow on <a href="https://x.com/grantdotdev">'X'</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
